IntersectionObserver 实战:一个 API 搞定图片懒加载、无限滚动与曝光埋点
为什么不再用 scroll 事件算可见性
图片懒加载、滚动到底部自动加载、广告与内容的曝光埋点,这三件事过去通常都挂在 scroll 事件上,回调里反复调用 getBoundingClientRect()。问题在于:滚动事件触发极其频繁,而读取布局属性会强制浏览器同步刷新布局,稍不注意就造成掉帧;再加上节流与去抖逻辑,代码很快变得难维护。
IntersectionObserver 把「元素是否进入视口」这件事交给浏览器在合成阶段异步计算,主线程只在交叉状态真正变化时收到一次回调。它不监听滚动,也就没有滚动监听带来的性能与精度问题。本文用三个真实场景把用法串起来,并列出几个容易踩的坑。
一、最小可用写法
构造函数接收回调与配置对象。root 为参照容器,null 表示视口;rootMargin 可以像 margin 一样扩展或收缩判定区域;threshold 决定在可见比例达到多少时触发回调。
const io = new IntersectionObserver((entries, observer) => {
for (const entry of entries) {
if (entry.isIntersecting) {
console.log('进入视口', entry.target, entry.intersectionRatio);
observer.unobserve(entry.target); // 只触发一次时记得取消观察
}
}
}, {
root: null, // null 表示以浏览器视口为参照
rootMargin: '0px 0px 200px 0px', // 提前 200px 触发
threshold: [0, 0.25, 0.5, 1]
});
document.querySelectorAll('.observe-me').forEach(el => io.observe(el));
回调的第一个参数是 IntersectionObserverEntry 数组,常用的字段有 isIntersecting、intersectionRatio、target 和 boundingClientRect。第二个参数就是 observer 本身,方便在回调里直接 unobserve。
二、场景一:图片懒加载
先把真实地址放在 data-src,用一张体积很小的占位图撑住布局,等元素进入预加载区域再赋值 src。给 rootMargin 留出 300px,用户滚动到图片前就已经开始下载,视觉上几乎无感。
// <img class="lazy" data-src="real.jpg" src="placeholder.svg" alt="...">
const lazyIO = new IntersectionObserver((entries, observer) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const img = entry.target;
img.src = img.dataset.src; // 真正开始加载图片
img.addEventListener('load', () => img.classList.add('loaded'), { once: true });
observer.unobserve(img); // 加载后不再观察,避免重复触发
}
}, { rootMargin: '300px 0px' }); // 向下预加载一屏
document.querySelectorAll('img.lazy').forEach(img => lazyIO.observe(img));
两个细节值得注意:一是加载完成后要 unobserve,否则来回滚动会重复触发;二是占位图应与真实图片保持相同的宽高比,否则图片加载瞬间会引起页面跳动,累计布局偏移(CLS)会很难看。
三、场景二:无限滚动
无限滚动不需要监听页面滚动位置,只要在列表末尾放一个不可见的哨兵元素,观察它是否进入视口即可。用 loading 标志位做并发保护,避免快速滚动时发出重复请求。
const sentinel = document.querySelector('#load-more-sentinel');
let page = 1, loading = false;
const io = new IntersectionObserver(async (entries) => {
if (!entries[0].isIntersecting || loading) return;
loading = true;
try {
const list = await fetch(`/api/articles?page=${++page}`).then(r => r.json());
renderList(list);
if (list.length === 0) io.disconnect(); // 没有更多数据,直接断开
} finally {
loading = false;
}
}, { rootMargin: '400px 0px' });
io.observe(sentinel);
当接口返回空数组时调用 disconnect() 彻底关闭观察器,比反复判断更省心。如果列表是虚拟滚动、节点会被回收,记得在节点销毁时 unobserve,避免观察器持有已移除的 DOM。
四、场景三:曝光埋点
「有效曝光」一般要求元素露出一定比例并停留一小段时间。用 threshold: [0.5] 过滤掉只擦过边缘的情况,再用 WeakSet 去重,保证同一元素只上报一次。
const seen = new WeakSet();
const trackIO = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting && !seen.has(entry.target)) {
seen.add(entry.target);
navigator.sendBeacon('/api/exposure', JSON.stringify({
id: entry.target.dataset.trackId,
ratio: +entry.intersectionRatio.toFixed(2),
ts: Date.now()
}));
}
}
}, { threshold: [0.5] }); // 露出一半以上才算有效曝光
document.querySelectorAll('[data-track-id]').forEach(el => trackIO.observe(el));
上报用 navigator.sendBeacon 而不是 fetch:页面跳转或关闭时,浏览器仍会尽力把 beacon 发出去,埋点丢失率明显更低。如果业务还要求「停留超过 1 秒才算曝光」,可以在进入视口时记时间戳、离开时用 setTimeout 校验,这里不再展开。
五、几个容易踩的坑
- rootMargin 只认 px 与 %:写
10rem或省略单位会直接抛错,这是最常见的配置错误; - 回调在主线程:虽然交叉计算是异步的,但回调仍在主线程执行,别在里面做重活,拿到结果后应尽快转交给空闲时段的任务;
- threshold 是数组:传单个数字也能用,但需要多档位(例如 0、0.5、1)判断「进入 / 半露 / 全露」时必须是数组;
- 元素本身要有尺寸:高度为 0 或未脱离文档流的隐藏元素不会产生预期的交叉状态;
- 记得收尾:组件卸载时调用
disconnect(),否则观察器会一直持有 DOM 引用。
小结
把「可见性判断」交给浏览器之后,懒加载、无限滚动、曝光埋点这三段逻辑都可以压到二三十行,且不再需要自己写节流去抖。现代浏览器对 IntersectionObserver 的支持已经很完整,只有在必须兼容老旧内核时才需要引入 polyfill。如果你的项目里还留着基于 scroll + getBoundingClientRect() 的实现,这是一个成本低、收益明确的替换点。
本文为原创实践记录,示例代码均可直接运行,欢迎交流更稳妥的边界处理方式。
- 点赞
- 收藏
- 关注作者
评论(0)