用 IntersectionObserver 实现高性能滚动动画
「元素进入视口时触发动画」是前端最常见的需求之一。十年前我们在 scroll 事件里算坐标, 现在浏览器提供了更省心的答案。
一、为什么不该继续监听 scroll
scroll 事件触发极其频繁,在惯性滚动下一个手势可能触发上百次。即便加了防抖,
回调里读取 getBoundingClientRect() 仍然会强制浏览器同步计算布局,
把原本可以合并的渲染工作拆散,导致滚动掉帧。
IntersectionObserver 把「元素是否可见」的判断交给了浏览器的渲染引擎。
它在合成线程完成计算,只在交叉状态跨越阈值时回调一次,主线程几乎没有负担。
二、基本用法
const observer = new IntersectionObserver(
(entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
console.log('进入视口', entry.target);
observer.unobserve(entry.target); // 只关心第一次
}
});
},
{
root: null, // null 表示视口
rootMargin: '0px', // 扩展或收缩判定边界
threshold: 0, // 交叉比例阈值
}
);
observer.observe(document.querySelector('.card'));
回调里的 entry 提供了几个关键属性:
isIntersecting:当前是否与根元素相交,比看intersectionRatio更直观。intersectionRatio:目标可见部分占自身的比例,取值 0 到 1。boundingClientRect:目标元素的边界矩形,等价于调用getBoundingClientRect()的结果。target:被观察的元素,方便在同一个回调里区分多个目标。
三、三个配置项怎么用
root
默认是视口。如果你在做「列表内部滚动加载」,需要把 root 设为可滚动的父容器,
此时目标是否可见只相对于这个容器判断。
rootMargin
用类似 CSS margin 的语法扩展根元素的判定区域,最典型的用法是提前加载:
// 距离视口底部还有 300px 时就开始加载
new IntersectionObserver(callback, {
rootMargin: '0px 0px 300px 0px',
});
懒加载图片时,这个值通常给 200 到 400px,让图片在用户真正看到之前完成解码,避免出现空白。
threshold
可以是数字或数组。threshold: 0.5 表示元素可见一半时触发;
[0, 0.5, 1] 则在进入、半可见、完全可见三个时刻各触发一次。
数组越长回调越频繁,做动画时通常只需要 0 或一个较小的值。
四、场景一:图片懒加载
原生 loading="lazy" 已经能覆盖大部分情况,但如果需要更精细的控制
(例如自定义占位、统计曝光),手写一个观察器依然很实用:
const io = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const img = entry.target;
img.src = img.dataset.src;
img.removeAttribute('data-src');
io.unobserve(img);
});
},
{ rootMargin: '0px 0px 300px 0px' }
);
document.querySelectorAll('img[data-src]').forEach((img) => io.observe(img));
配合固定宽高比可以避免布局偏移(CLS):
.lazy-img {
aspect-ratio: 16 / 9;
width: 100%;
background: #eef1f7;
object-fit: cover;
}
五、场景二:滚动入场动画
动画本身交给 CSS,JS 只负责切换类名,这是性能最好的组合。
.reveal {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
.reveal.is-visible {
opacity: 1;
transform: none;
}
const io = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
io.unobserve(entry.target);
}
});
},
{ threshold: 0.15 }
);
document.querySelectorAll('.reveal').forEach((el) => io.observe(el));
两个细节值得注意:一是 unobserve 必须调用,否则元素反复进出视口会不断触发回调;
二是要尊重用户的系统设置,对开启「减少动态效果」的用户直接跳过动画:
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduceMotion) {
document.querySelectorAll('.reveal').forEach((el) => el.classList.add('is-visible'));
} else {
// 正常初始化观察器
}
六、场景三:无限滚动
在列表底部放一个哨兵元素,它进入视口就代表「用户快到底了」。
相比监听 scroll 并计算 scrollHeight - scrollTop - clientHeight,这种方式不会造成布局抖动。
const sentinel = document.querySelector('#load-more');
let loading = false;
const io = new IntersectionObserver(async (entries) => {
if (!entries[0].isIntersecting || loading) return;
loading = true;
try {
await loadNextPage();
} finally {
loading = false;
}
}, { rootMargin: '200px' });
io.observe(sentinel);
loading 这个标志位不能省。回调可能在请求返回前被多次触发,
没有互斥保护就会出现重复请求、数据错乱。请求结束后如果哨兵仍在视口内,
浏览器会再次回调,从而形成连续加载。
七、几个实践建议
- 一个观察器观察多个元素。回调里通过
entry.target区分,避免为每个节点都创建实例。 - 回调里不要做重活。DOM 读取、样式计算尽量少,批量更新放到
requestAnimationFrame中。 - 组件卸载时调用
disconnect()。单页应用里忘记清理是内存泄漏的常见来源。 - 注意环境判断。服务端渲染时不存在
IntersectionObserver,初始化代码要放在客户端生命周期里。
八、小结
IntersectionObserver 的价值不只是「写起来短」,而是把可见性判断从主线程搬到了浏览器内部。
只要记住三点——按需提前量设置 rootMargin、触发后及时 unobserve、
动画交给 CSS——就能覆盖绝大多数滚动相关的交互场景。