小程序列表页的曝光埋点:IntersectionObserver 节流、回收与懒加载的实战记录
适用读者:做过小程序信息流或商品列表页的前端同学,正在被曝光数据不准、页面越滑越卡这类问题折腾。需要熟悉 Page 生命周期和 WXML 节点结构,不涉及任何算法和数学。
九月初的一个周三,运营拿着后台截图来找我,说商品列表页的卡片曝光率标到了 180%,比页面 UV 还高出一大截。组里实习生小周的原话是:「这数据看着就不对,卡片都还没滑进屏幕,后台就记了一笔曝光。」排查下来,老埋点方案是监听 scroll 事件加 200 毫秒节流(Throttle),节流窗口里路过视口边缘的卡片全被算成了曝光。这篇把那次改造从头到尾记下来:IntersectionObserver(节点相交观察器)怎么接、上报怎么节流、thresholds(阈值数组)和重复监听两个坑、页面卸载没回收导致的内存泄漏(Memory Leak),还有长列表虚拟化场景下 observer 怎么复用。
数据虚得离谱,锅在老的 scroll 方案
先交代老方案的实现。页面 onScroll 里拿 scrollTop,再用 createSelectorQuery 去量每张卡片的位置,算出哪些卡片进了视口,然后记曝光。这事儿有三个先天缺陷:

- scroll 回调在快速滑动时一秒能触发几十次,逻辑层每次都要发起一次节点查询,查询本身是异步的,等结果回来,手指已经滑过那张卡片了。
- 节流窗口设 200 毫秒,窗口内经过的所有卡片都会被算进去,曝光边界糊成一团,这是数据虚高的直接原因。
- 每次查询都要跨逻辑层和渲染层往返一次,列表一长,查询数量跟着节点数线性涨,低端机上掉帧很明显。
这套代码是老同事阿凯两年前写的,注释里留了句「先这样,回头再优化」。这一回头,就是两年。
| 对比项 | scroll 事件加节点查询 | relativeToViewport 的 Observer |
|---|---|---|
| 回调频率 | 滚动期间高频触发 | 跨过阈值才触发一次 |
| 节点位置获取 | 每次都要查询往返 | native 层算好直接给结果 |
| 边界判定 | 节流窗口内路过就算曝光 | 露出比例精确到阈值 |
| 内存回收 | 闭包持有节点列表,容易漏 | disconnect 一次清干净 |
先把 scroll 换成 relativeToViewport 的 Observer
改造的核心代码不长,去掉注释三十行以内。基础库要求 2.10.3 及以上,环境是 pages/list/list,卡片节点 class 为 feed-card。
// 依赖:微信小程序基础库 2.10.3 及以上
// 环境:pages/list/list 商品列表页,卡片节点 class 为 feed-card
Page({
data: { list: [] },
onLoad() {
// 已经记过曝光的卡片 id 集合,防止重复上报
this.reported = new Set()
// 待上报队列,攒够 20 条批量发
this.queue = []
this.initObserver()
},
initObserver() {
this.io = wx.createIntersectionObserver(this, {
// 卡片露出 50% 才算一次有效曝光
thresholds: [0.5],
// 页面里有多张卡片,不开 observeAll 只会监听第一个
observeAll: true
})
// 相对视口做相交判断,从此不用再监听 scroll
this.io.relativeToViewport().observe('.feed-card', (res) => {
const id = res.dataset.id
// 同一张卡片只记一次,去重放在入队前
if (this.reported.has(id)) return
this.reported.add(id)
this.queue.push({ id, t: Date.now() })
// 攒够一批再发,别一条一条打请求
if (this.queue.length >= 20) this.flush()
})
},
flush() {
// 队列是空的就不用发
if (!this.queue.length) return
// 真实项目里换成你们自己的打点接口
wx.request({ url: 'https://your-domain.com/track/exposure', data: { items: this.queue } })
this.queue = []
},
onHide() {
// 页面切后台不等同于卸载,监听要留着
// 但队列里的数据必须补发,不然漏数据
this.flush()
},
onUnload() {
// 不 disconnect 会一直挂着回调,造成内存泄漏
if (this.io) {
this.io.disconnect()
this.io = null
}
// 退页前把尾巴上的数据也发掉
this.flush()
}
})
几个关键参数说一下。thresholds 设 0.5,意思是卡片露出面积超过一半才算曝光,这个值是后面和运营反复对出来的,别照抄。observeAll 不开的话,选择器只命中第一个节点,多卡片的列表页等于白写。relativeToViewport() 表示参照区域是整个视口,也支持传 margin 把参照区往外扩,比如 relativeToViewport({ bottom: 100 }) 会把视口下边界往外推 100 像素,用来做预加载挺省事。
这套基础结构里最要紧的一行是 onUnload 里的 disconnect,后面会专门讲少了它会出什么事。
节流要放在上报侧,不是监听侧
接完 observer 我第一反应是给回调再加一层节流,被自己拦下来了。原因想明白才发现之前白费劲:native 侧只在相交比例跨过阈值的时刻通知一次,滚动期间不会刷屏,监听侧节流属于画蛇添足,还可能把真正的跨界通知拦掉。节流和去重该做的是上报这一层,挡的是业务侧的重复记录和请求风暴。
改造后同样刷完 100 条内容,打点请求从原来四十多次降到 5 次,请求体里每条曝光还带上了精确的时间戳。上报侧这层节流由三个件组成:
- Set 去重:一张卡片的生命周期内只记一次曝光。
- 攒批队列:凑满 20 条或页面 onHide 时统一发。
- 退页兜底:onUnload 里把队列尾巴 flush 掉。
flowchart LR
A[卡片渲染进入视口] --> B[native 层计算相交比例]
B --> C{比例跨过阈值}
C -->|是| D[通过桥通知逻辑层]
C -->|否| B
D --> E[Set 去重判断]
E --> F[写入待上报队列]
F --> G[凑满二十条批量上报]
踩坑一:thresholds 写了 0.5,总有卡片死活不触发
灰度第一周,曝光率从虚高的 180% 掉到 61%,但运营抽样人工复核发现还是漏记了大概一成。对着日志排查出两类漏记:
一是快速滑动场景。卡片从视口下方一路掠过,等 native 做下一次布局采样时,它已经滑出参照区了,「跨过 0.5」这个动作没被捕捉到。这类漏记我们最后没完全堵死,只能靠抽样修正系数兜底,好在占比不到 3%。
二是 thresholds 数组的理解偏差。写 [0.5] 不是「相交比例从 0 涨到 0.5 的过程中每个档位都通知」,而是只在比例跨越 0.5 这一条线时通知。首屏那批卡片初始就完整露在视口里,通知时机和滑入的卡片不一样,测试机上碰到过不回调的机型。所以 onLoad 之后延时 500 毫秒,主动把首屏卡片的曝光补记一遍。
| thresholds 取值 | 实际触发时机 | 适合场景 |
|---|---|---|
| 0 | 目标刚碰到参照区就触发 | 首屏图片懒加载 |
| 0.5 | 露出面积过半触发 | 列表卡片曝光埋点 |
| 1 | 完整露出才触发 | 关键物料的强曝光校验 |
踩坑二:observeAll 加分页,一条曝光报了两次
分页加载上线两天后,后台出现同一条曝光 id 出现两次的记录,payload 一模一样。重放日志发现是重复监听:observer 监听的是选择器匹配到的节点集合,渲染层每次布局更新都会重新匹配,而我们在分页 setData 的回调里又手动 observe 了一遍新节点,同一张卡片身上挂了两个回调,一条曝光报两遍。
修法是加一张 watched 集合,挂过监听的节点直接跳过。这段逻辑后来抽成了一个小类,虚拟化场景也在用它。
// 环境:虚拟化长列表,节点随时销毁重建
// 思路:全页只建一个 observer,新节点渲染完再补挂
class ExposurePool {
constructor(page) {
// 复用同一个实例,省 native 侧的监听开销
this.io = wx.createIntersectionObserver(page, { thresholds: [0.5] })
this.io.relativeToViewport()
// 记录已经挂过监听的节点 id,防止重复 observe
this.watched = new Set()
}
// 每次分页 setData 之后调用一次
watch(nodes) {
nodes.forEach((node) => {
// 挂过的直接跳过,不然回调会翻倍
if (this.watched.has(node.id)) return
this.watched.add(node.id)
// 用节点 id 精确挂载,别再依赖全局 class 选择器
this.io.observe('#' + node.domId, (res) => {
// 命中阈值后走统一的去重入队逻辑
this.enqueue(res.dataset)
})
})
}
destroy() {
// 页面卸载时统一回收,和前面讲的 disconnect 是一回事
this.io.disconnect()
}
}
踩坑三:onUnload 少了一行 disconnect,内存被一点点吃掉
这个坑是测试同学帮忙抓出来的。提测第三天她反馈:从列表页反复进出十几次之后,小程序整体变卡,切别的页面动画都掉帧。真机调试面板里看 JS Heap,从 40MB 一路爬到 110MB,退页也不回落。
profile 下来原因很直白:observer 实例注册在 native 侧,页面卸载并不会自动解除监听,回调闭包还活着,把 Page 实例连带整个 list 数据都拽在引用链上,GC 收不走。每进出一次列表页就漏一份,量小的时候没感觉,堆到几十次就压不住了。修复就是代码块一里 onUnload 那三行:disconnect、置空、flush。修完真机连刷二十次进出,堆稳定在 45MB 上下。
所有 createIntersectionObserver 出来的实例,都必须有对应的 disconnect,写在 onUnload 或组件的 detached 里,不能指望框架自动回收。
自定义组件里同理,detached 生命周期就是组件版的 onUnload,写法一样,这里不重复贴了。
原理剖析:双线程架构下,通知是怎么走过来的
小程序是渲染层和逻辑层分离的双线程架构。渲染层在 WebView 里负责布局(Skyline 引擎下换成自研渲染管线),逻辑层跑在独立的 JavaScriptCore 里,两边互不阻塞,中间由 native 做桥接。搞清楚这个结构,前面几个坑就都能解释通了。
scroll 方案为什么贵:逻辑层想知道卡片位置,得通过 native 找渲染层查询节点布局,一来一回是两次线程间通信,外加序列化开销,滚动期间还要反复做。
IntersectionObserver 把相交计算整个搬到了 native。渲染层每次布局更新,native 拿到最新布局信息,直接计算目标节点和参照区域的相交比例,只有当比例跨越了 thresholds 里的某条线,才通过 bridge 异步回调逻辑层。这个通知节奏是事件式的,不是滚动期间每帧都来。想通这一点,三件事就顺了:
- 监听侧不需要节流,跨阈值才叫一次,天然省。
- 节流和去重放在上报侧才有意义,挡的是业务层的重复和请求风暴。
- 回调是异步的,别在回调里依赖当下这一帧的滚动位置,拿到手的 res 才是可信的。
sequenceDiagram
participant 渲染层
participant Native层
participant 逻辑层
渲染层->>Native层: 布局更新推送最新节点位置
Native层->>Native层: 计算目标节点相交比例
Native层->>逻辑层: 比例跨过阈值才回调
逻辑层->>逻辑层: Set 去重后写入队列
逻辑层->>Native层: onUnload 时发送 disconnect
Native层->>Native层: 解除监听并释放回调
长列表虚拟化里,observer 怎么复用
列表页后来上了虚拟化(Virtualization),视口外的节点直接销毁,一页 40 条数据,监听对象一直在变。最初图省事,每次分页新建一个 observer,实例数最多攒到四十多个,native 侧挂着几十份监听,低端机又掉帧了,等于从一个坑爬进另一个坑。
改成用 ExposurePool 全页共用一个实例之后,效果立竿见影:observer 实例从四十多个降到 1 个,拖动时的平均帧耗时从 17ms 回落到 9ms 左右。复用时有两个注意点:
- observe 用节点 id 精确挂载,别再用全局 class 选择器,虚拟化下 class 匹配到的集合一直在变,结果不可预期。
- 节点销毁后它的监听 native 会自动清掉,但 watched 集合要跟着清理,不然节点复用同 id 重建时会漏挂。
顺手把首屏图片懒加载也做了
同一套 API 顺手解决了首屏图片加载慢的问题。图片节点初始渲染占位图,进视口再换真实地址,阈值给 0,一碰参照区就换。
// 环境:列表页首屏,图片节点 class 为 feed-img
// 占位图先行,真实地址挂在 data-src 上
Page({
onReady() {
// 首屏场景监听一批就够了,不需要 observeAll
// 阈值给 0,一进入参照区就换真图
this.lazyIo = wx.createIntersectionObserver(this, { thresholds: [0] })
// 参照区下边界外扩 100 像素,提前预热下一屏
this.lazyIo.relativeToViewport({ bottom: 100 })
.observe('.feed-img', (res) => {
const d = res.dataset
// 把占位图换成真实地址,提前 100 像素预热
this.setData({ ['list[' + d.index + '].src']: d.src })
// 首屏就这一批,换完立刻断开,别留着空跑
this.lazyIo.disconnect()
})
},
onUnload() {
// 离开页面同样要回收,规矩和埋点那份一样
if (this.lazyIo) this.lazyIo.disconnect()
}
})
另外 image 组件本身有 lazy-load 属性,页面结构简单时直接用属性更省事,一行搞定。需要自定义加载时机的时候,比如预加载距离、渐进式占位、加载失败的降级,再上 observer 自己控制。
改造一周后的数据:曝光率稳定在 92% 到 95% 区间,抽样人工复核吻合;列表页滑动掉帧的用户反馈清零;打点接口的请求量降了八成多。运营后来在周会上说了句:「这个数终于敢拿去汇报了。」
写在最后:几个容易想岔的点
收尾澄清三个常见误区:
- 不是所有页面都该上 observer。页面就一屏、卡片数量固定,直接在 onReady 里记一次就够了,省掉整套监听和回收逻辑,引入复杂度划不来。
- 曝光不等于有效曝光。阈值定多少要跟运营对齐着定,0.5 是我们反复对比抽样之后的结果,直接抄数字不如抄对齐的过程。
- 别在 observer 回调里做重活。入队就好,计算和请求都挪到攒批之后,回调里每多一行代码,快速滑动场景就多一分压力。
趋势上,Skyline 渲染引擎在逐步铺开,渲染层不再是 WebView,但 IntersectionObserver 这套接口是跨渲染引擎的标准姿势,节流加回收的写法短期内不会过时。thresholds 边界和虚拟化复用这两个坑,踩过的人应该不少,欢迎评论区交流各自的解法。
参考与延伸
曝光埋点、IntersectionObserver、微信小程序、懒加载、节流、内存泄漏、性能优化