IntersectionObserver 实战指南:懒加载、无限滚动与曝光统计

📅 发布时间:2026/10/20 20:49:34
IntersectionObserver 实战指南:懒加载、无限滚动与曝光统计
我最早接触 IntersectionObserver是被一张张加载不出来的图片逼的。当时在做一个图片社区的长列表页图片动辄几十张首屏只能看到五六张但页面一次性把全部高清图都发起了请求服务器压力大移动端流量也撑不住必须做懒加载。我先用了经典的 scroll getBoundingClientRect 方案代码看起来相当标准window.addEventListener(scroll, function onScroll() { const rect image.getBoundingClientRect(); const viewportHeight window.innerHeight || document.documentElement.clientHeight; if (rect.top 0 rect.top viewportHeight) { loadImage(image); window.removeEventListener(scroll, onScroll); } }, { passive: true });看似不差的方案其实有硬伤。第一哪怕加了节流scroll 回调依然会在用户滚动时密集执行每次执行都涉及布局信息读取长期占用主线程。第二所有需要判断的元素都堆在同一个事件里做 if代码后期很难维护。第三scroll 事件只在滚动发生时触发如果页面内容在滚动后动态改变、元素尺寸变化还需要额外处理 resize 和内容变更边界情况非常容易漏。换用 IntersectionObserver 以后这套困扰基本被根治了。这篇文章就把我这几年实际用这个 API 的经验、踩过的坑和可以直接抄的代码一起整理出来。1. 为什么我把 scroll 事件全家桶换成了 IntersectionObserver1.1 手动监听 scroll 的成本为什么那么高问题其实不在 scroll 事件本身而在于我们在事件回调里强行读取布局信息。getBoundingClientRect 返回的是实时坐标数据浏览器为了生成这个结果必须先强制更新样式和布局。在滚动过程里频繁调用等于不断告诉浏览器“快给我算一版”次数多了主线程任务堆积掉帧就成了必然。我当时在一个真实项目里做过统计长列表页面里有 50 张待处理图片每张图片在滚动回调里都做一次 getBoundingClientRect 判断一次滚动大约触发 3~5 次回调页面还有其他组件也在监听 resize最终低端安卓机上的表现就是滚动一卡一停。后来我简化了判断逻辑只计算首尾可见索引再根据索引范围决定哪些图片要加载情况有所缓解但依然没法很好处理整体布局变化后的重新计算问题。这个方案还伴随一个隐藏负担每次内容渲染完成后我得手动遍历所有新增节点重新绑定监听。一旦某些节点被移除原来的监听器没解绑内存就会慢慢堆积。1.2 原生 IntersectionObserver 到底改变了什么IntersectionObserver 做的事情本质上是在浏览器内部维护一个兴趣区域的交集检测。当观察目标与 root 发生交叉或比例变化时浏览器会异步把这个变化投递到我们注册的回调里。这个过程不依赖滚动事件不需要我们主动轮询浏览器用自己的调度机制去执行检测。从编码模型上看它也不需要大量手写事件逻辑。创建 observer 之后想盯着哪个元素就 observe 它不需要了就 unobserve 它。整体观察集合由浏览器统一维护没有全局事件绑定带来的重复监听问题。把过去手动处理的判断和节流成本转移给浏览器引擎自然更省心。更直观的区别是代码量显著减少原来 30 行左右的滚动判断逻辑现在变成短短几行配置和一次 observe。2. 核心参数之间root、rootMargin、threshold 的语义与实践看一遍文档很容易真到配参数的时候很多人还是会纠结该填什么。尤其是 rootMargin 和 threshold 的组合用错的效果比不用还糟糕。2.1 root 定为 null 与指定滚动容器构造参数里的 options.root 缺省情况下是 null表示用浏览器视口作为参照物。如果某个内部容器需要滚动到特定区域才触发可以传一个元素进去const container document.querySelector(.scroll-container); const io new IntersectionObserver(callback, { root: container, rootMargin: 0px, threshold: 0.5 });这种情况下交集计算的参照物就从视口变成了容器本身。这里有三个容易翻车的前提root 元素必须是实际渲染的盒子不能是 display: contents 或者宽高为 0 的空壳root 元素本身需要能产生滚动区域否则“和 root 相交”这个概念意义不大移动端里如果 root 容器带有 transform 或 will-change 属性IntersectionObserver 的计算结果在不同浏览器间会有差异建议尽量避免这种结构。2.2 rootMargin 和触发范围调制rootMargin 的格式和 CSS margin 一样填写带 px 或 % 的字符串四个值分别对应上右下左。它的作用是改变 root 矩形的大小从而调整“可视区”的判定边界。举例来看const io new IntersectionObserver(callback, { root: null, rootMargin: 300px 0px 300px 0px, threshold: 0 });上下都外扩 300px意味着元素在真正进入视口之前 300px 就会被视为相交从而提前加载。这里不要误解成 rootMargin 把屏幕内容变大了它只是把观察判定区域扩大了。在无限滚动场景里下方提前量设为视口高度的 100% 到 200% 都有人用具体看网络环境和容器高度。额外提醒一个坑rootMargin 使用百分比时要特别小心。规范的百分比解析基于 root 的宽高但不同浏览器早期版本对它的支持程度并不一致我用 px 写已经足够稳定没必要在这个字段上追求“响应式”写法。2.3 threshold 配置粒度不要一上来就套 [0, 0.5, 1]threshold 表示目标元素的可见比例阈值。0 表示任意像素交叉就触发1 表示完全可见才触发数组则是逐点触发。调整这个值就是在取舍回调触发次数和判定精度。一个很重要的经验是绝大多数业务场景threshold 只用 0 就够了。图片懒加载、无限滚动、大部分曝光判断只需要知道“看到还是没看到”。一旦你配置了 [0, 0.25, 0.5, 0.75, 1]滚动过程中回调至少被触发五次每个 entry 都要重新走业务判断纯属浪费。如果你在做曝光统计threshold 选一个 0.3 或 0.5 的固定值就足以区分“露一角”和“完整露出”没必要把频率抬到每秒十几次。2.4 回调 entries 里的关键字段真正写业务逻辑时回调里拿到的是一个 IntersectionObserverEntry 数组。我常用字段其实就这几个字段含义典型使用场景isIntersecting目标当前是否与 root 相交判断是否触发逻辑的主开关intersectionRatio可见面积与目标面积的比例0~1精确判断可见程度target当前的观察目标元素拿到 DOM 节点做后续操作time从 observe 调用到回调记录的时间戳曝光时长计算辅助boundingClientRect目标当前坐标矩形调试时查看位置关系其中 boundingClientRect 在使用 rootMargin 扩大区域时表示的是目标元素真实几何矩形而不是原视口里可见部分的矩形。调试时不要把这两个概念混在一起画示意图时 root 矩形变了但 boundingClientRect 依然是元素真实位置否则会对着坐标数据半天找不到问题。3. 从懒加载到曝光统计四类常见场景的实现源码IntersectionObserver 的 API 只有一个真正的价值在于怎么和不同业务结合。下面这几个场景都是我在线上用过的代码可以直接改改用。3.1 图片懒加载给 rootMargin 一个提前量最简单的图片懒加载特点是只关心布尔值不受精确比例影响const observer new IntersectionObserver((entries, self) { entries.forEach(entry { if (entry.isIntersecting) { const img entry.target; img.src img.dataset.src; img.addEventListener(load, () img.classList.add(loaded)); self.unobserve(entry.target); } }); }, { root: null, rootMargin: 200px 0px, threshold: 0 }); document.querySelectorAll(img[data-src]).forEach(img observer.observe(img));建议 rootMargin 上下都设置值不要只写底部。很多长页面里用户可能在中间位置直接刷新从顶部向下滚或者从底部向上滚都有可能上下对称的提前量更稳妥。还有一点拿到>div idsentinel/divlet loading false; let page 1; const sentinel document.querySelector(#sentinel); const io new IntersectionObserver((entries) { if (!entries[0].isIntersecting || loading) return; loading true; fetchMore(page) .then(renderItems) .finally(() { page 1; loading false; }); }, { root: null, rootMargin: 200px 0px, threshold: 0 }); io.observe(sentinel);loading 状态必须挂在闭包变量里。如果没有它用户快速滚动穿过哨兵区域回调可能在一秒内触发好几次请求堆积会非常难看。另外在 tab 切换到后台再切回时IntersectionObserver 可能重新触发一次回调这会造成不必要的重复请求。可以对 sentinel 做一层保护比如记录上一次请求时间300ms 内的重复触发直接忽略更完整的方案是把 loading 状态和请求去重都做进公共的数据层里。3.3 滚动渐显与自动播放状态切换别贪多内容进场动画是另一个高频场景。一个元素在视口内露出一定比例后从透明度 0 过渡到 1。我推荐这个思路const io new IntersectionObserver((entries) { entries.forEach(entry { if (entry.isIntersecting) { entry.target.classList.add(enter-active); io.unobserve(entry.target); } }); }, { threshold: 0.2 }); document.querySelectorAll([data-animate]).forEach(el io.observe(el));使用 CSS transition 处理透明度而不要在回调里操作 style 细节。更进阶的方案是配合 CSS 自定义属性把动画延迟放到 data 属性里比如>const io new IntersectionObserver((entries) { entries.forEach(entry { const video entry.target; if (entry.isIntersecting entry.intersectionRatio 0.5) { if (video.paused) video.play(); } else { if (!video.paused) video.pause(); } }); }, { threshold: [0, 0.5] });threshold 数组里同时写 0 和 0.5是因为“相交”这个状态在比例低于 0.5 时切换不会触发 0.5 的判定。通过两档阈值浏览器会在穿过 0.5 边界时都回调播放和暂停能够更及时地响应滚动速度。3.4 曝光埋点只有时长才是有效曝光曝光埋点表面做法很简单元素在视口内累计可见超过一定时间才上报一次。但实现时有个隐蔽复杂度——用户切到别的 tab 时页面可能依然有元素处于“相交”状态如果计算曝光时长时直接累加就会把用户留在后台的半小时也算进去数据完全失真。我在实际项目里用过一个可落地的版本const exposureMap new WeakMap(); const EXPOSURE_THRESHOLD 1000; function trackExposure(el) { let meta exposureMap.get(el); if (!meta) { meta { visibleMs: 0, lastVisibleTs: 0, reported: false }; exposureMap.set(el, meta); } if (document.visibilityState visible) { meta.lastVisibleTs Date.now(); } } function pauseExposure(el) { const meta exposureMap.get(el); if (!meta || meta.lastVisibleTs 0) return; meta.visibleMs Date.now() - meta.lastVisibleTs; meta.lastVisibleTs 0; if (meta.visibleMs EXPOSURE_THRESHOLD !meta.reported) { reportExposure(el); meta.reported true; exposureObserver.unobserve(el); } } const exposureObserver new IntersectionObserver((entries) { entries.forEach(entry { if (entry.isIntersecting) { trackExposure(entry.target); } else { pauseExposure(entry.target); } }); }, { threshold: 0.3 });把 visibilitychange 和 IntersectionObserver 搭配起来在后台时强制把所有目标的 lastVisibleTs 置零回到前台后再重新开始累计。这样才能保证曝光时长基本等于屏幕上真实可见的连续时间。统计系统里一致性比绝对值更重要宁可少报也不要虚报。4. 真实项目里踩过的几个坑排查过程与结果这部分我想聊上线生产环境后淌过的雷每个坑背后都有一段具体的排查经历没遇到过的同学可以提前绕开。4.1 root 容器宽高为 0导致观察全部失效有一次给横向滚动列表做自动加载我把观察 root 设成外层卡片元素它本身用了 display: inline-flex宽度靠内容撑开但 CSS 还没渲染完成时我就在 DOMContentLoaded 里把 observer 建好了。结果等了很久列表尾部的加载始终不触发。排查时我先在回调里打印 entry发现回调确实执行了但 isIntersecting 一直是 false。问题最终锁定在 root 面积是 0 上。当我通过代码给外层容器设置固定宽度后观察立刻恢复。工程上的建议是如果要观察一个元素与内部滚动容器的相交情况必须先确认 root 的宽高计算完成。最简单的方法是在 requestAnimationFrame 之后再创建 observer或者搭配 ResizeObserver 监听 root 的尺寸变化。4.2 threshold 和 rootMargin 的类型与格式问题有一个印象很深的线上问题某个页面新增了大量滚动动画我在配置里传了 threshold: [0.5]结果有的元素在可见度超过 50% 时依然没有生效。控制台里查 intersectionRatio已经明显大于 0.5回调却迟迟不来。我在不同浏览器下反复调试终于定位到细节数组里放的是字符串浏览器对数组元素的处理比较严格直接忽略了整个配置。threshold 本身就是数字类型单值写成字符串 0.5 也许还能隐式转换但放进数组里就容易出问题。rootMargin 也有类似的格式坑比如用中文全角空格分隔或者不带单位解析失败以后观察区域保持默认。建议在封装公共方法时对配置做一次 typeof 校验和标准格式化可以帮业务同事提前发现这类错误。4.3 与原生 loadinglazy 共存时连出问题现代浏览器自带 loadinglazy但它和 IntersectionObserver 同时用在同一个 img 上时会出现一种奇怪现象图片在上方很远的地方就被原生属性提前加载而 observer 里的 rootMargin 又设置了更大的提前量两者最终都执行了赋值 src 的逻辑后执行的覆盖了前一次的 srcset导致图片清晰度不符合预期。我后来调整了策略先检测浏览器是否支持原生 loadinglazy支持时直接依赖原生实现IntersectionObserver 只管外层容器的曝光或降级逻辑不支持的环境再并发观察 img 列表。这样两层逻辑各管一摊不会互相覆盖配置。如果真的需要双保险也要确保回调里首先判断图片 src 是否已经完整不要重复赋值。4.4 把动画逻辑塞进回调帧率不升反降这是最容易被忽略的性能问题。IntersectionObserver 回调不是逐帧触发的它有自己的异步调度机制两个相邻回调之间可能相差好几帧。如果你在回调里直接修改元素的 transform期望让元素跟随滚动位置平滑移动结果会是一格一格的跳变。正确姿势是把 observer 当作状态机回调里只修改类或 CSS 变量然后让 CSS transition 和 Web Animations API 处理帧级平滑过渡。比如滚动渐显从 opacity 0 到 1 的过渡不需要逐帧控制一条 CSS 规则就能解决。这也说明 IntersectionObserver 适合做“门控逻辑”不适合做“驱动动画”。5. 兼容性、性能验证与 API 的进阶用法无论 API 多顺手最终都要落到线上环境的兼容性和后期维护上。5.1 不支持环境下的降级方案IntersectionObserver 已经普及很多年但部分老系统的 WebView 仍不支持。我在项目里统一写了一个工具函数function createIntersectionObserver(callback, options) { if (IntersectionObserver in window) { return new IntersectionObserver(callback, options); } return { observe() {}, unobserve() {}, disconnect() {}, takeRecords() { return []; } }; }这样在降级环境里直接对 observe 调用空实现所有业务代码不需要额外分支。懒加载场景下再补一段逻辑如果创建出的 observer 是降级空对象就直接把带>let count 0; let timer 0; const io new IntersectionObserver(() { count; const now performance.now(); if (now - timer 1000) { console.log(entries per second: ${count}); timer now; count 0; } }, { threshold: [0, 0.5, 1] });如果某个 threshold 数组在常见滚动场景下让每秒回调冲到很高就要考虑降低阈值颗粒度。阈值粒度每增加一档回调次数几乎线性增长。准确率和性能之间没有免费的午餐需要根据业务的实际可接受范围来平衡。5.3 处理隐藏 tab 的可见性状态做曝光统计或播放器自动暂停时最重要的配套 API 是 Page Visibility API。只监听 IntersectionObserver用户切走 tab 一整天再切回来页面元素可能一直处于相交状态没有变化但这一整天的“可见”没有任何意义。所以在曝光、播放这类场景我至少会在 visibilitychange 事件里做一次状态同步document.addEventListener(visibilitychange, () { if (document.hidden) { exposureMap.forEach((meta, el) { pauseExposure(el); }); } else { document.querySelectorAll([data-track-exposure]).forEach(el { trackExposure(el); }); } });实践上还有个体感切回 tab 以后IntersectionObserver 会自动触发一次回调此时 isIntersecting 会按照当前可见性重新计算所以 visibilitychange 里只需要配合执行曝光逻辑的开关不用额外去触摸每个元素的 observer 状态。5.4 配合 takeRecords 和 disconnect 处理路由变化单页面应用里切换路由上一个页面创建的 observer 如果不清理观察目标会留在内存里回调还可能继续触发极易造成状态串页。正确做法是在路由切换的钩子里执行function handleRouteChange() { const pending observer.takeRecords(); pending.forEach(entry { // 根据业务需求决定处理或丢弃最后一批回调 }); observer.disconnect(); }takeRecords 会把队列中尚未投递的记录取出来这时候可以决定是丢弃还是补偿。disconnect 会终止所有观察。如果业务组件按需创建 observer记得在组件卸载时调用清理方法使用前面封装的降级工具函数也能避免老环境里方法不存在的问题。5.5 工程化落地把观察器封装成通用 hook最后一个建议是工程层面的。不要在每个业务文件里直接 new IntersectionObserver代码一多观察目标重复、配置不统一、清理不彻底这些问题会反复出现。我通常会把通用逻辑抽成一个小工具懒加载、曝光统计、滚动渐显各自独立。以 React 场景为例最简单的封装长这样function useInView(options) { const ref React.useRef(null); const [inView, setInView] React.useState(false); React.useEffect(() { const element ref.current; if (!element) return; const observer new IntersectionObserver((entries) { const entry entries[0]; setInView(entry.isIntersecting); }, options); observer.observe(element); return () observer.disconnect(); }, [options]); return [ref, inView]; }useEffect 的清理函数一定要把 disconnect 写清楚否则组件卸载后 observer 还在后台接收回调可能引发内存泄漏。这个 hook 还可以扩展一层回调把完整 entry 对象交给业务侧自己决定怎么用。如果你在项目里有类似的懒加载或埋点需求试着把初始化逻辑收拢到一个入口后面能省掉很多踩坑的时间。