约 6 分钟读完 · 更新于 2026-10-21 · 文中每条都能在下面的配套工具里动手验证
scroll 事件在滚动期间每秒触发几十次,回调里一旦读布局(offsetTop、getBoundingClientRect)就会强制回流,掉帧肉眼可见。IntersectionObserver 把「元素可见了吗」的判断交给浏览器异步批量计算,主线程几乎零成本,且不依赖滚动事件。
不是替代关系:需要精确滚动位置时仍用 scroll,只关心可见性时用 IO。
| 对比项 | scroll 事件 | IntersectionObserver |
|---|---|---|
| 触发频率 | 滚动期间高频触发 | 可见状态变化时才触发 |
| 性能 | 回调中读布局易造成强制回流 | 浏览器异步计算,几乎零开销 |
| 代码量 | 手写节流 + 位置计算 | 几行配置即可 |
| 能拿到精确滚动位置吗 | 能 | 不能,只报可见性交集 |
| 典型场景 | 视差、进度条等需精确位置 | 懒加载、埋点、吸顶、无限列表 |
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
// isIntersecting 比 intersectionRatio > 0 更直观
if (entry.isIntersecting) {
console.log('进入视口:', entry.target);
}
}
}, {
root: null, // null = 浏览器视口
rootMargin: '0px',
threshold: 0, // 0 = 有 1px 可见就算
});
io.observe(document.querySelector('.card'));进入视口前不设置真实 src,进入后换上并停止观察。原生 loading="lazy" 已覆盖简单场景,但需要占位、淡入或自定义阈值时还是 IO 更灵活。
const lazyIO = new IntersectionObserver((entries, io) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const img = entry.target;
img.src = img.dataset.src; // 换上真实地址
img.onload = () => img.classList.add('loaded');
io.unobserve(img); // 只加载一次,之后不再观察
}
}, { rootMargin: '200px' }); // 提前 200px 预加载,滚动更顺滑
document.querySelectorAll('img[data-src]').forEach(img => lazyIO.observe(img));埋点要求「用户真的看到了」,一般用 threshold 0.5 + 一次性上报 + unobserve,避免同一元素反复触发造成数据虚高。
const trackIO = new IntersectionObserver((entries, io) => {
for (const entry of entries) {
if (entry.intersectionRatio >= 0.5) {
// 上报埋点:元素超过一半进入视口
navigator.sendBeacon('/track', JSON.stringify({
id: entry.target.dataset.trackId,
type: 'expose',
}));
io.unobserve(entry.target);
}
}
}, { threshold: [0.5] });
document.querySelectorAll('[data-track-id]').forEach(el => trackIO.observe(el));吸顶最难的是「怎么知道导航正在吸顶」——sticky 元素吸住后位置不再变化,观察它自己没用。标准做法是在它上方放一个 1px 高的哨兵元素:哨兵离开视口,就等于导航吸顶了。
<ul id="list"></ul> <!-- 哨兵放在列表末尾,被观察到 = 该加载下一页了 --> <div id="sentinel" style="height: 1px"></div>
const list = document.getElementById('list');
const sentinel = document.getElementById('sentinel');
let page = 1, loading = false;
const moreIO = new IntersectionObserver(async ([entry]) => {
if (!entry.isIntersecting || loading) return;
loading = true;
const items = await fetch(`/api/items?page=${page++}`).then(r => r.json());
list.append(...items.map(renderItem));
loading = false;
// 没有下一页时调用 moreIO.disconnect() 停止观察
}, { rootMargin: '400px' }); // 接近底部就提前请求
moreIO.observe(sentinel);大多数「回调不触发」「触发太多次」都出自这里。
把 addEventListener('scroll', fn) 换成 io.observe(el),九成滚动监听需求(懒加载、埋点、吸顶、加载更多)就此解决,节流代码全删。加载更多时的占位骨架用骨架屏生成器直接出,吸顶与淡入样式参考 CSS 生成器。
先确认目标在 DOM 里且不是 display:none;再确认 root 配置与实际滚动容器一致;记住 observe 后会立即回调一次,可用它验证观察是否建立成功。
负值收缩判定区域。例如 '-1px 0 0 0' 把顶部收窄 1px,常用于吸顶判断;'0px 0px -200px 0px' 则要求元素再滚入 200px 才算可见。
不能,IO 只报告可见性交集变化。需要精确位置(视差、进度条)仍用 scroll 加 requestAnimationFrame;方向可对比两次回调的 boundingClientRect.top。
简单图片场景够了。需要自定义阈值、提前量、淡入动画、背景图或批量资源调度时,IntersectionObserver 仍是更灵活的方案。