IntersectionObserver实战:高效实现懒加载与可视区域检测
核心代码就这几行
别被名字吓到,IntersectionObserver 用起来其实特别简单。我第一次用它做懒加载图片的时候,核心逻辑就不到 10 行。
先看最基础的用法:监听某个元素是否进入视口。比如页面里有一堆图片,一开始只放占位图,等用户滚动到附近再加载真实图片。亲测有效,比以前用 scroll 事件 + getBoundingClientRect 算位置稳多了,性能也更好。
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
observer.unobserve(img); // 加载完就取消监听,省点资源
}
});
});
// 所有带 data-src 的 img 都交给它管
document.querySelectorAll('img[data-src]').forEach(img => {
observer.observe(img);
});
HTML 长这样:
<img data-src="https://jztheme.com/real-image.jpg" alt="示例图" />
就这么简单。你甚至不用关心滚动事件、节流、防抖这些破事,浏览器原生帮你搞定。而且它在主线程之外运行,不会卡 UI,这点比手写 scroll 监听强太多。
这个场景最好用
除了懒加载,我还用它做过「无限滚动」和「组件曝光埋点」。
比如商品列表,用户快滚到底部时自动加载下一页。关键不是“到底了”,而是“快到底了”——这时候提前加载,体验更顺滑。靠 threshold 和 rootMargin 就能轻松实现。
const options = {
rootMargin: '0px 0px 200px 0px', // 提前 200px 触发
threshold: 0
};
const sentinel = document.querySelector('#sentinel'); // 页面底部的一个空 div
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMoreData(); // 自己封装的加载函数
}
}, options);
observer.observe(sentinel);
埋点更简单。只要元素出现在屏幕上(哪怕只露 1 像素),就上报。我们之前用这个统计 Banner 位、广告位的曝光率,准确率比 scroll 计算高不少,因为不会漏掉快速滚动跳过的区域。
const trackObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// 上报 entry.target.id 或其他标识
fetch('https://jztheme.com/api/track', {
method: 'POST',
body: JSON.stringify({ elementId: entry.target.id })
});
// 注意:这里一般不 unobserve,因为可能多次进出
}
});
}, { threshold: 0.1 }); // 至少露出 10% 才算曝光
document.querySelectorAll('.track-item').forEach(el => {
trackObserver.observe(el);
});
踩坑提醒:这三点一定注意
我踩过好几次坑,这里重点说三个。
第一,别忘了处理 SSR 或服务端渲染的兼容问题。 如果你在 Next.js、Nuxt 这类框架里用,记得把 new IntersectionObserver 包在 useEffect 或 mounted 里,或者加个 typeof window !== 'undefined' 判断。不然服务端跑会直接报错,因为 Node 环境没有 IntersectionObserver。
第二,unobserve 和 disconnect 别乱用。 如果你只监听一次(比如懒加载),记得在回调里调 unobserve(target),避免重复触发。但如果你要持续监听(比如埋点),就别 unobserve,否则第二次进入视口就收不到通知了。另外,页面销毁时记得 observer.disconnect(),防止内存泄漏——虽然现代浏览器 GC 很强,但养成习惯不吃亏。
第三,rootMargin 的单位必须带 px,不能用百分比或 rem。 我之前想用 rootMargin: '10%',结果完全没反应,折腾半天才发现规范只认像素值。文档里写得挺清楚,但我就是没细看,血泪教训。
高级技巧:动态调整阈值
有时候你可能需要同一个元素在不同阶段触发不同行为。比如一个视频组件,露出 10% 时预加载,露出 50% 时开始播放,露出 90% 时上报深度曝光。这时候可以复用同一个 observer,靠判断 intersectionRatio 来分层处理。
const videoObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const ratio = entry.intersectionRatio;
const video = entry.target;
if (ratio >= 0.1 && !video.preloaded) {
video.preload = 'auto';
video.preloaded = true;
}
if (ratio >= 0.5 && !video.played) {
video.play();
video.played = true;
}
if (ratio >= 0.9) {
// 上报深度曝光
}
});
}, { threshold: [0.1, 0.5, 0.9] }); // 关键:传入多个阈值
videoObserver.observe(document.querySelector('#my-video'));
注意 threshold 要写成数组,这样每次跨越任一阈值都会触发回调。不过要小心频繁触发,尤其是用户来回滚动时。如果业务允许,可以在状态标记后忽略后续相同阶段的触发(比如上面的 preloaded、played 标志位)。
最后说两句
IntersectionObserver 真的是前端性能优化的利器,尤其在长列表、懒加载、动画触发这些场景下,代码简洁又高效。虽然有些老项目还在用 scroll + getBoundingClientRect,但新项目我建议直接上它。
当然,它也不是万能的。比如你需要精确知道元素滚动了多少像素,或者要做复杂的视差效果,那还是得回到 scroll 事件。但 80% 的“是否可见”类需求,它都能优雅解决。
以上是我踩坑后的总结,希望对你有帮助。这个技术的拓展用法还有很多(比如配合 CSS 动画、做虚拟列表的简化版),后续会继续分享这类博客。有更优的实现方式欢迎评论区交流!

暂无评论