小程序列表页的曝光埋点:IntersectionObserver 节流、回收与懒加载的实战记录

2026-10-02 01:18:59 0 次浏览
微信小程序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、微信小程序、懒加载、节流、内存泄漏、性能优化

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。