用IntersectionObserver实现高性能懒加载和滚动检测
核心代码就这几行,但得先搞懂它到底在干啥
我第一次用 IntersectionObserver 是为了做图片懒加载,结果写了半天发现滚动时图片根本不动——不是没触发,是触发了但 observer 把元素“看丢”了。折腾了半天才发现:它默认只监听一次,而且初始状态不自动检查。亲测有效的方式是:观察前先手动调用 observer.observe(el),再补一句 observer.takeRecords() 拿到当前所有可见项。
先甩最精简、能直接跑的代码:
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
const src = img.dataset.src;
if (src) {
img.src = src;
img.removeAttribute('data-src');
// 观察完就停,避免反复触发(懒加载场景下推荐)
observer.unobserve(img);
}
}
});
},
{
threshold: [0, 0.1, 0.5, 1.0], // 多阈值,更精细控制
rootMargin: '0px 0px -50px 0px' // 向上多看 50px,提前加载
}
);
document.querySelectorAll('img[data-src]').forEach(img => {
observer.observe(img);
});
这个场景最好用:列表无限滚动 + 预加载占位符
我们有个商品列表页,每页 20 条,用户滚到底部就 fetch 下一页。以前用 window.onscroll + getBoundingClientRect(),卡顿明显,尤其安卓低端机。换 IntersectionObserver 后,首屏加载快了 300ms,滚动也顺滑了。
关键点在于:别让 observer 去“盯”最后一个 item,而是盯一个专门的 <div class="loader-trigger"></div>,插在列表末尾:
<ul id="product-list">
<!-- 商品项 -->
<li>...</li>
<li>...</li>
</ul>
<div class="loader-trigger"></div>
const trigger = document.querySelector('.loader-trigger');
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !loading) {
loading = true;
fetch('https://jztheme.com/api/products?page=' + (page + 1))
.then(r => r.json())
.then(data => {
appendProducts(data);
page++;
loading = false;
})
.catch(() => loading = false);
}
},
{ threshold: 0.1 }
);
observer.observe(trigger);
注意:loading 是个全局 flag,不然快速滚动可能触发多次请求。这个方案比监听 scroll 更稳,亲测在 iOS Safari 和 Chrome Android 上都 OK。
踩坑提醒:这三点一定注意
- root 不设默认就是 viewport,但如果你的容器加了
transform或overflow: hidden,observer 就会失效——我踩过两次。解决方案:显式传{ root: document.querySelector('.scroll-container') },并确保该容器有明确 height 和 overflow:auto threshold是比例,不是像素值,且必须是 0~1 的数组或单个数字。写成threshold: 50是无效的,浏览器不报错但永远不触发。我曾误以为它是像素偏移,浪费半小时查文档。- observer 实例不能复用监听不同 root。比如你既想监听页面级滚动,又想监听某个弹窗里的滚动,必须新建两个 observer。复用会导致行为不可预测,我试过,Chrome 表现正常,Firefox 直接不触发。
高级技巧:监听“离开视口”+防抖式回调
有些需求要“进入时加载,离开时卸载”,比如视频自动播放/暂停。但 isIntersecting === false 并不等于“完全移出”,它只是“交叉比例低于阈值”。所以不能只靠它判断“彻底离开”。
我的做法是:用 entry.intersectionRatio === 0 作为“完全离开”的信号,再加一层节流(防止快速进出反复触发):
let timeoutId = null;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
clearTimeout(timeoutId);
if (entry.isIntersecting && entry.intersectionRatio > 0) {
entry.target.play?.();
} else if (entry.intersectionRatio === 0) {
// 真正离开才 pause,且加 100ms 防抖
timeoutId = setTimeout(() => {
entry.target.pause?.();
}, 100);
}
});
},
{ threshold: [0, 0.01, 0.5] }
);
这个逻辑在短视频 Feed 流里跑了半年,没出过问题。虽然不如原生 playbackState 精准,但兼容性好,够用。
还有个骚操作:用它代替 resize 监听元素尺寸变化?
是的,可以。只要把 rootMargin 设成极小的负值(比如 '-1px'),再配合 threshold: [0],就能在元素宽高变化时触发(因为 layout change 会影响 intersection)。不过……我不推荐。
原因有三:一是性能不如 ResizeObserver;二是 Firefox 对负 margin 支持不稳定;三是语义错乱,别人读代码会懵。我试过,改完后确实能 work,但团队 Code Review 直接否了——技术上可行,但不专业。
结语
IntersectionObserver 不是银弹,但它确实是现代前端滚动交互的基石之一。比起手撸 scroll + debounce + throttle + getBoundingClientRect,它更轻、更稳、更可维护。我现在新项目只要涉及可视区判断,第一反应就是它。
这个技巧的拓展用法还有很多,比如结合 Canvas 动画做视差、监听广告曝光上报、甚至用在表单验证(输入框进入可视区才初始化校验器)……后续会继续分享这类博客。
以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流。

暂无评论