IntersectionObserver 实战:列表曝光埋点与滚动加载

判断"一个元素进没进视口",手上有两套做法。老做法是监听 scroll 事件,在回调里调 getBoundingClientRect() 拿元素位置,自己算它和视口的交叠;新做法是 IntersectionObserver,把元素交给浏览器,交叠状态变化时浏览器来告诉你。两套代码都能跑通,但它们在两个层面上的行为完全不一样,这个差别最近在做曝光埋点时被我完整地撞了一遍。

第一个层面是性能。scroll 事件在主线程上高频触发,而 getBoundingClientRect() 会强制同步布局——如果这一帧里恰好有别的代码改过样式,浏览器必须立刻把攒着的样式计算和布局做完才能返回几何信息,这就是所谓的强制重排。列表里有一百个元素要判断,每次滚动都问一百遍位置,滚动帧率肉眼可见地掉。IO 则是异步回调,交叠计算发生在浏览器内部的布局流程之后,不打断滚动本身。

第二个层面是语义,这个反而更致命。scroll 方案的本质是"轮询":只有滚动发生时才会去算。可元素进入视口未必是因为滚动——页面首屏渲染出来时它可能就在视口里,窗口 resize 会让它进来,列表上方某个折叠面板收起会把它顶进来,甚至 CSS transform 动画也能把它挪进来。这些场景 scroll 回调一个都收不到。scroll + getBoundingClientRect 回答的是"滚动之后它在哪",IntersectionObserver 回答的是"它和视口的交叠关系变了",后者才是曝光埋点真正要问的问题。 我们的埋点数据之前一直有一块对不上,运营那边反馈某些活动位的曝光量明显低于点击量——点击比曝光还多,逻辑上不可能——查下来就是首屏直出的卡片没滚动就被点了,老方案根本没记到曝光。

背景一句话说完

需求来自运营:商品运营位改版后要精确的曝光数据来算点击率,口径定为"卡片可见面积超过一半、且持续可见超过一秒,记一次曝光;同一次页面停留内同一张卡片只记一次"。老的埋点代码就是 scroll + 节流 + getBoundingClientRect 那套,既有上面说的漏报,性能上也被 QA 抓过包——列表页在低配测试机上滑动明显卡。所以这次重写直接换 IntersectionObserver。兼容性今年已经不是问题:Chrome、Firefox 早就支持,Safari 从去年三月的 12.2 起也跟上了,剩下的 IE11 走降级,文末再说。

先解剖一下老方案卡在哪

动手换之前,我把老代码翻出来看了一遍,大概长这样:

1// 老方案(简化):scroll + 节流 + getBoundingClientRect
2function checkExposure() {
3  const viewportHeight = window.innerHeight;
4  cards.forEach((card) => {
5    if (card._exposed) return;
6    const rect = card.el.getBoundingClientRect();
7    const visibleHeight =
8      Math.min(rect.bottom, viewportHeight) - Math.max(rect.top, 0);
9    if (visibleHeight > rect.height * 0.5) {
10      card._exposed = true;
11      report(card.id);
12    }
13  });
14}
15
16window.addEventListener('scroll', throttle(checkExposure, 200), {
17  passive: true,
18});

问题一层套一层。节流 200ms 是个两头不讨好的值:设小了,滚动期间回调打得密,每次回调里一排 getBoundingClientRect 都是潜在的强制重排;设大了,快速滑动时一屏内容在两次采样之间划过去,该记的曝光漏掉。用 Performance 面板录一段滚动就能看到,回调里那串紫色的 Layout 块跟在黄色的脚本块后面,一帧的预算轻松被吃穿。而且这段代码只算了纵向、只算了窗口滚动——后来页面里加了横向滑动的卡片橱窗,又 copy 了一份改横向的版本,两套逻辑一起维护。

更根本的问题是前面说的语义缺口:checkExposure 只挂在 scroll 上,首屏、resize、面板展开这些路径全是盲区。给每条路径都补一个监听?那就是第三份、第四份重复逻辑。这种"补丁越打越多"的形状,一般说明抽象选错了层级,正好借这次需求换掉。

换成 IO:回调参数先认全

IO 的回调拿到的是一组 IntersectionObserverEntry,字段比想象中丰富,先认全再干活:

1const observer = new IntersectionObserver((entries) => {
2  entries.forEach((entry) => {
3    entry.target;             // 目标元素
4    entry.isIntersecting;     // 是否与根交叠(跨过任一 threshold 后的状态)
5    entry.intersectionRatio;  // 交叠面积占目标面积的比例
6    entry.boundingClientRect; // 目标的几何信息(等价 gBCR,但没有强制重排代价)
7    entry.intersectionRect;   // 交叠区域的矩形
8    entry.rootBounds;         // 根的矩形(跨域 iframe 里是 null)
9    entry.time;               // 交叠发生的时间戳,DOMHighResTimeStamp
10  });
11});

entry.boundingClientRect 值得单独说一句:它是浏览器在做交叠计算时"顺手"给出的几何信息,拿它替代业务里散落的 getBoundingClientRect() 调用,等于把强制重排也一并省了。entry.time 是相对页面打开的高精度时间戳,做可见时长统计时用它比在回调里自己 Date.now() 更准——回调本身是异步派发的,可能比交叠真正发生的时刻晚几十毫秒,快速滑动时这点误差会积累。

entry.rootBounds 在跨域 iframe 场景下是 null,这个特性后面 iframe 一节还会碰到。

threshold 和 rootMargin 到底在说什么

IO 的构造函数就两个参数,回调和配置,但配置里 thresholdrootMargin 这两项的语义值得掰开讲,因为口径能不能翻译准确全看它们。

threshold 是交叠比例的阈值列表,取值 0 到 1,指的是目标元素自身面积被根(默认是视口)覆盖的比例。0 表示"哪怕一个像素交叠"就触发,1 表示完全可见才触发。它是个数组,可以同时埋多个观察点:

1const observer = new IntersectionObserver(onIntersect, {
2  threshold: [0, 0.5, 1],
3});

这样元素在露头、过半、全露三个时刻都会触发回调。要注意回调触发的时机是"跨越阈值",而不是"处于某个比例"——从 40% 滚到 60% 会触发一次(跨过 0.5),从 55% 滚到 60% 就不会。回调参数里 entry.intersectionRatio 给出触发那一刻的精确比例,entry.isIntersecting 给出是否交叠的布尔值。我们的口径是"可见过半",所以 threshold 给 [0.5],用 entry.intersectionRatio >= 0.5 来区分进入还是退出这个阈值。

这里有个前人踩过、我复述一遍的坑:threshold 设 1.0 时,如果目标元素比视口还高(长图、大卡片),intersectionRatio 永远到不了 1,回调永远不触发。 通用的曝光 SDK 不能写死 1.0,要么用 0.5 这类中间值,要么对超高元素单独降级判断。

rootMargin 是给根的判定边界加一圈"外扩或内收",语法和 CSS margin 一样,四个值上右下左,必须带单位(px 或百分比),写 rootMargin: '200' 会直接抛异常,这个我替大家试过了。正值是把判定区向外扩——元素还没真正进入视口、离底边还有 200px 时就算"交叠";负值是向内收——元素要深入视口 100px 才算数。曝光埋点里我们用了一个小的负值下边距,把"刚在屏幕底边露了一条线"的状态排除掉,避免快速滑动时擦边的卡片被计入;预加载场景则反过来用正值,后面触底加载一节会用到。

曝光去重与可见时长

口径里"持续可见超过一秒"这个条件,IO 本身不管——它只报状态变化,不管持续时间。所以要自己在进入和退出两个事件之间夹一个计时器:

1// exposure-tracker.js
2const EXPOSE_RATIO = 0.5;
3const EXPOSE_DURATION = 1000;
4
5function createExposureTracker(report) {
6  const timers = new Map();   // el -> setTimeout id
7  const exposed = new WeakSet(); // 已上报过的元素
8
9  const observer = new IntersectionObserver(
10    (entries) => {
11      entries.forEach((entry) => {
12        const el = entry.target;
13        if (exposed.has(el)) return;
14
15        if (entry.intersectionRatio >= EXPOSE_RATIO) {
16          // 过半进入,起一个一秒的计时器
17          if (!timers.has(el)) {
18            timers.set(el, setTimeout(() => {
19              exposed.add(el);
20              observer.unobserve(el); // 记过曝光就不用再观察了
21              timers.delete(el);
22              report(el.dataset.exposeId);
23            }, EXPOSE_DURATION));
24          }
25        } else {
26          // 一秒内又退出去了,计时作废
27          if (timers.has(el)) {
28            clearTimeout(timers.get(el));
29            timers.delete(el);
30          }
31        }
32      });
33    },
34    { threshold: [EXPOSE_RATIO], rootMargin: '0px 0px -20px 0px' }
35  );
36
37  return {
38    observe: (el) => observer.observe(el),
39    unobserve: (el) => {
40      observer.unobserve(el);
41      if (timers.has(el)) {
42        clearTimeout(timers.get(el));
43        timers.delete(el);
44      }
45    },
46    destroy: () => {
47      timers.forEach((id) => clearTimeout(id));
48      timers.clear();
49      observer.disconnect();
50    },
51  };
52}

几个设计点。去重用 WeakSet 存已曝光元素,元素被列表复用逻辑销毁后引用自动释放,不用手动清;一旦记过曝光就 unobserve,把浏览器的观察成本也省掉——观察列表越短,IO 的开销越接近零,用完就 unobserve 应该成为肌肉记忆。计时器那张 Map 必须在组件销毁时统一 clear,不然快速切换路由时会有一批"幽灵曝光"在页面已经不在了之后触发上报。

在 Vue 里接起来很自然,列表项组件 mounted 时把根节点交给 tracker,页面组件 beforeDestroy 时调 destroy。我们没有给每个列表项各建一个 IO 实例,而是整页共享一个——同一组观察参数下,一个 IO 实例观察一千个元素和一千个实例各观察一个,语义相同但前者内存和回调调度开销小得多,这也是官方文档推荐的用法。

还有个隐蔽的边界:用户切到别的标签页时,页面不可见,但 IO 的交叠状态不变,计时器照跑,一秒后照样记曝光。这不符合"用户看到了"的本意。补丁是叠加 Page Visibility API:document.hidden 为真时暂停所有计时器,visibilitychange 回来时重新计时。运营还提过更进一步的"可见总时长"需求(卡片累计被看了多少秒),实现思路一样,在进入/退出/页面隐藏三种事件上打时间戳做累加,这里就不展开了。

上报本身也有讲究。曝光是高频低价值事件,一条一条发请求太奢侈,我们攒批发送:

1const queue = [];
2let flushTimer = null;
3
4function report(exposeId) {
5  queue.push({ id: exposeId, ts: Date.now() });
6  if (queue.length >= 20) {
7    flush();
8  } else if (!flushTimer) {
9    flushTimer = setTimeout(flush, 10000);
10  }
11}
12
13function flush() {
14  clearTimeout(flushTimer);
15  flushTimer = null;
16  if (!queue.length) return;
17  const body = JSON.stringify(queue.splice(0));
18  navigator.sendBeacon('/track/expose', body);
19}
20
21// 页面隐藏或卸载前把最后一批送出去
22document.addEventListener('visibilitychange', () => {
23  if (document.hidden) flush();
24});

积到二十条或者每十秒 flush 一次;最后一批用 navigator.sendBeacon 送出去——它由浏览器接管、不受页面卸载打断,比在 beforeunload 里发同步 XHR 体面得多。触发时机选 visibilitychange 而不是 beforeunload,是因为移动端 webview 里用户直接杀掉页面时 beforeunload 经常根本不触发,visibilitychange 到 hidden 这一步反而更靠得住。

包成 Vue 指令,业务方少写样板代码

tracker 写好之后,怎么让业务同事用起来不嫌烦也是设计的一部分。列表页有七八种卡片组件,每个都在 mounted/beforeDestroy 里手写 observe/unobserve 太啰嗦,我把它包成了一个自定义指令:

1// v-expose 指令
2import tracker from './exposure-tracker';
3
4Vue.directive('expose', {
5  inserted(el, binding) {
6    el.dataset.exposeId = binding.value;
7    tracker.observe(el);
8  },
9  unbind(el) {
10    tracker.unobserve(el);
11  },
12});

模板里一行就接上:

1<goods-card
2  v-for="item in list"
3  :key="item.id"
4  v-expose="item.trackId"
5  :goods="item"
6/>

inserted 钩子保证元素已经插入文档——这点对 IO 很重要,observe 一个游离节点不会报错,但也永远不会有回调。有一个真实出现过的边界:列表用 :key 复用节点时,数据换了、DOM 没换,inserted 不会再触发,dataset.exposeId 还是旧值。补上 update 钩子同步 binding 的新值就行,但这个坑值得记下来——指令的生命周期跟的是 DOM 节点,不是数据。

触底加载:哨兵元素的写法

同一个页面的第二个需求是无限滚动,列表滚到底部附近就加载下一页。scroll 方案的老写法是每次滚动算 scrollTop + clientHeight >= scrollHeight - 200,IO 的写法则换了个思路:在列表末尾放一个空的"哨兵"元素,观察它。

1// 列表末尾:<div class="load-sentinel" ref="sentinel"></div>
2const loadObserver = new IntersectionObserver(
3  (entries) => {
4    if (entries[0].isIntersecting && !this.loading && !this.finished) {
5      this.loadNextPage();
6    }
7  },
8  { rootMargin: '0px 0px 300px 0px' } // 提前 300px 预加载
9);
10loadObserver.observe(this.$refs.sentinel);

rootMargin 的正值下边距在这里就是"提前量":哨兵离视口底边还有 300px 时就触发加载,用户滚到真正的底部时数据多半已经回来了,体感上就是"根本没等"。这个值要跟滚动速度和接口耗时匹配着调,我们列表页接口平均两百多毫秒,300px 够用;如果接口慢,宁可加大提前量也别让用户盯着 loading 转圈。

两个容易踩的细节。一是加载状态锁:数据回来、列表变长、哨兵被推远,这个过程中 IO 可能再触发一次回调,没有 loading 标志位就会重复请求;finished(没有更多数据)时最好直接 unobserve,别留着空转。二是哨兵不能是 display: none——不渲染的元素没有几何信息,永远不会和任何东西交叠,加载完最后一页想"藏起来"哨兵时,用 visibility: hidden 或者干脆 unobserve 加移除节点,别顺手改 display,这个坑我们真踩了,现象就是加载了一页之后无限滚动"死"了。

另外要提醒的是回调的首次触发时机:observe() 之后 IO 会立刻对当前状态报告一次,哪怕元素根本不在视口里(此时 isIntersecting 为 false)。写逻辑时不能假设"第一次回调等于第一次进入视口",要看 isIntersecting 的值,不然首页数据会被莫名其妙地请求两次。

root、滚动容器和 iframe 的坑

前面所有例子的"根"都是浏览器视口,但中后台的实际布局经常是外层固定、内层 overflow: auto 的滚动容器。这时必须显式指定 root

1const observer = new IntersectionObserver(callback, {
2  root: document.querySelector('.table-scroll-wrapper'),
3  threshold: [0.5],
4});

root 必须是目标元素的祖先,否则回调永远不会触发,而且不报任何错。 这是我这两周排查时间最长的一个问题:组件抽得太通用,列表有时渲染在弹窗里,弹窗的 DOM 被 Element UI 的 append-to-body 挪到了 body 下面,root 还指着原来的容器,祖先关系断了,曝光静悄悄地全丢。类似的还有 root 元素中途被 v-if 销毁重建——IO 实例还攥着旧节点的引用,观察对象全部失效。这类问题没有报错、没有警告,只能靠意识到"root 和 target 的 DOM 关系是 IO 的隐含前提"去查。

iframe 是另一个特殊场景。我们有个营销活动页会被嵌到 App 的 webview 和 PC 站的 iframe 里,曝光 SDK 也要在里面跑。这恰好是 IO 相对老方案的一个本质优势:在跨域 iframe 里,getBoundingClientRect 只能拿到元素相对 iframe 自己视口的位置,外层页面把 iframe 滚出屏幕了,里面的代码毫无感知,老方案会把根本没人看见的内容记成曝光。而 IO 的默认根(不指定 root 时)是所谓"隐式根",浏览器会把顶层视口的裁剪也算进交叠计算——iframe 被滚出去,里面元素的 isIntersecting 就是 false。这件事只有浏览器自己能算,任何 JS 轮询方案在跨域 iframe 里都做不到,这也是 IO 设计初衷里最硬的一条。 顺带一提,跨域场景下 rootMargin 会被忽略(不然外面的页面布局信息就泄露给 iframe 了),预加载提前量在 iframe 里要换思路。

还有一个"可见"语义的坑要坦白:IO v1 判断的是几何交叠,不是"用户真的看得见"。元素 opacity: 0、被 visibility: hidden 的祖先罩住、或者被一个 position: fixed 的悬浮球盖住,交叠照样成立,曝光照样记。Chrome 正在实验的 IO v2 加了 isVisible 字段专门回答这个问题,但目前只有 Chrome 一家、还要在回调参数里显式传 trackVisibility,生产环境只能观望。我们的现状是接受这点误差,只对"被固定弹层完全遮挡"这一种高频情况在业务层做了标记位规避。

IE11 的降级

最后是绕不开的 IE11。官方有一个 intersection-observer polyfill,内部实现就是 scroll + resize + getBoundingClientRect 加节流的老一套,接口对齐了 IO 的 API。我们的选择是按需加载:

1function ensureIO() {
2  if ('IntersectionObserver' in window) {
3    return Promise.resolve();
4  }
5  return import(/* webpackChunkName: "io-polyfill" */ 'intersection-observer');
6}
7
8ensureIO().then(() => {
9  // 这之后再初始化曝光和触底加载
10});

动态 import() 让现代浏览器一个字节都不多下。要对 polyfill 的精度有预期:它默认 100ms 轮询一次,交叠时刻会有最多百毫秒级的误差,rootMargin 在部分老浏览器上也有兼容缺陷。我们的态度是曝光数据在 IE11 上允许粗糙——中后台用户里 IE 占比已经掉到个位数,为它优化精度不值得。真正重要的是降级路径上口径不变:同样的 threshold、同样的一秒计时、同样的去重逻辑,数据分析的人不需要知道浏览器差异的存在。

图片懒加载按说也是 IO 的经典场景,不过我们列表里的图走的是另一条线,这里不展开,值得单独写一篇的时候再写。

收尾

回到开头那两套做法。换完 IO 之后,低配机上列表页的滚动掉帧问题没有再被 QA 提过,运营那边"点击大于曝光"的数据倒挂也消失了——性能问题和语义问题,一次迁移同时解决,这在重构里不常见。scroll + getBoundingClientRect 这套老手艺不会彻底退役,它还活在 polyfill 里、活在少数 IO 覆盖不到的场景里,但"元素进没进视口"这个问题的默认答案,从今年起在我们团队就是 IntersectionObserver 了。