用 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——就能覆盖绝大多数滚动相关的交互场景。