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 的构造函数就两个参数,回调和配置,但配置里 threshold 和 rootMargin 这两项的语义值得掰开讲,因为口径能不能翻译准确全看它们。
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 了。