别再用 scroll 事件监听滚动了:IntersectionObserver 四大高频场景

scroll 事件每秒触发几十次,回调里读布局还会强制回流。IntersectionObserver 把可见性判断交给浏览器异步处理,主线程几乎零开销。本文覆盖图片懒加载、曝光埋点、吸顶导航(sentinel 哨兵法)、无限列表四个场景的完整代码与五个常见坑。

约 6 分钟读完 · 更新于 2026-10-21 · 文中每条都能在下面的配套工具里动手验证

结论:scroll 事件是性能税,IntersectionObserver 是浏览器原生的解法

scroll 事件在滚动期间每秒触发几十次,回调里一旦读布局(offsetTop、getBoundingClientRect)就会强制回流,掉帧肉眼可见。IntersectionObserver 把「元素可见了吗」的判断交给浏览器异步批量计算,主线程几乎零成本,且不依赖滚动事件。

scroll 事件 vs 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));

场景三:吸顶导航(sentinel 哨兵法)

吸顶最难的是「怎么知道导航正在吸顶」——sticky 元素吸住后位置不再变化,观察它自己没用。标准做法是在它上方放一个 1px 高的哨兵元素:哨兵离开视口,就等于导航吸顶了。

  1. 在导航元素正上方插入一个高 1px 的 sentinel div
  2. 创建 IntersectionObserver 观察这个 sentinel,而不是导航本身
  3. 回调里判断 entry.isIntersecting:false 表示哨兵滚出视口,即吸顶状态
  4. 据此给导航加 class(如 stuck),控制阴影、缩小、背景色等样式
  5. 用 rootMargin: '-1px 0 0 0' 微调,避免临界值抖动

场景四:无限列表加载更多

HTML 结构
<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);

五个最容易踩的坑

大多数「回调不触发」「触发太多次」都出自这里。

  • 初始状态必回调一次:observe 后哪怕元素不可见也会立即回调,注意别重复初始化
  • 目标元素 display: none 时不会触发(没有布局就没有交集),弹窗内容要先插入 DOM 再 observe
  • rootMargin 只在 root 为同一文档时有效,iframe / 跨域滚动容器里不生效
  • threshold 是数组,写 [0, 0.5] 能同时收到「刚进入」和「过半」两个回调
  • IO 不提供滚动方向,需要方向判断时自己对比上次 entry.boundingClientRect.top

一行修复:把 scroll 监听换成一行 observe

把 addEventListener('scroll', fn) 换成 io.observe(el),九成滚动监听需求(懒加载、埋点、吸顶、加载更多)就此解决,节流代码全删。加载更多时的占位骨架用骨架屏生成器直接出,吸顶与淡入样式参考 CSS 生成器。

常见问题

IntersectionObserver 回调不触发怎么办?

先确认目标在 DOM 里且不是 display:none;再确认 root 配置与实际滚动容器一致;记住 observe 后会立即回调一次,可用它验证观察是否建立成功。

rootMargin 负值有什么用?

负值收缩判定区域。例如 '-1px 0 0 0' 把顶部收窄 1px,常用于吸顶判断;'0px 0px -200px 0px' 则要求元素再滚入 200px 才算可见。

能否用它获取滚动位置或方向?

不能,IO 只报告可见性交集变化。需要精确位置(视差、进度条)仍用 scroll 加 requestAnimationFrame;方向可对比两次回调的 boundingClientRect.top。

懒加载用原生 loading="lazy" 不够吗?

简单图片场景够了。需要自定义阈值、提前量、淡入动画、背景图或批量资源调度时,IntersectionObserver 仍是更灵活的方案。

📂

松开以自动格式化

支持 .json / .sql / .csv / .html / .css / .xml / .yaml 文件