IntersectionObserver 实战:一个 API 搞定图片懒加载、无限滚动与曝光埋点

举报
yd_246800664 发表于 2026/11/09 01:56:29 2026/11/09
【摘要】 用 IntersectionObserver 替代 scroll 事件做可见性判断,给出图片懒加载、无限滚动、曝光埋点三个场景的完整示例代码,并总结 rootMargin 单位、threshold 数组、回调主线程、组件卸载时 disconnect 等常见坑。改造成本低,能有效减少强制同步布局带来的掉帧。

为什么不再用 scroll 事件算可见性

图片懒加载、滚动到底部自动加载、广告与内容的曝光埋点,这三件事过去通常都挂在 scroll 事件上,回调里反复调用 getBoundingClientRect()。问题在于:滚动事件触发极其频繁,而读取布局属性会强制浏览器同步刷新布局,稍不注意就造成掉帧;再加上节流与去抖逻辑,代码很快变得难维护。

IntersectionObserver 把「元素是否进入视口」这件事交给浏览器在合成阶段异步计算,主线程只在交叉状态真正变化时收到一次回调。它不监听滚动,也就没有滚动监听带来的性能与精度问题。本文用三个真实场景把用法串起来,并列出几个容易踩的坑。

一、最小可用写法

构造函数接收回调与配置对象。root 为参照容器,null 表示视口;rootMargin 可以像 margin 一样扩展或收缩判定区域;threshold 决定在可见比例达到多少时触发回调。

const io = new IntersectionObserver((entries, observer) => {
  for (const entry of entries) {
    if (entry.isIntersecting) {
      console.log('进入视口', entry.target, entry.intersectionRatio);
      observer.unobserve(entry.target); // 只触发一次时记得取消观察
    }
  }
}, {
  root: null,                  // null 表示以浏览器视口为参照
  rootMargin: '0px 0px 200px 0px', // 提前 200px 触发
  threshold: [0, 0.25, 0.5, 1]
});

document.querySelectorAll('.observe-me').forEach(el => io.observe(el));

回调的第一个参数是 IntersectionObserverEntry 数组,常用的字段有 isIntersecting、intersectionRatio、target 和 boundingClientRect。第二个参数就是 observer 本身,方便在回调里直接 unobserve。

二、场景一:图片懒加载

先把真实地址放在 data-src,用一张体积很小的占位图撑住布局,等元素进入预加载区域再赋值 src。给 rootMargin 留出 300px,用户滚动到图片前就已经开始下载,视觉上几乎无感。

// <img class="lazy" data-src="real.jpg" src="placeholder.svg" alt="...">
const lazyIO = new IntersectionObserver((entries, observer) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;
    const img = entry.target;
    img.src = img.dataset.src;      // 真正开始加载图片
    img.addEventListener('load', () => img.classList.add('loaded'), { once: true });
    observer.unobserve(img);        // 加载后不再观察,避免重复触发
  }
}, { rootMargin: '300px 0px' });  // 向下预加载一屏

document.querySelectorAll('img.lazy').forEach(img => lazyIO.observe(img));

两个细节值得注意:一是加载完成后要 unobserve,否则来回滚动会重复触发;二是占位图应与真实图片保持相同的宽高比,否则图片加载瞬间会引起页面跳动,累计布局偏移(CLS)会很难看。

三、场景二:无限滚动

无限滚动不需要监听页面滚动位置,只要在列表末尾放一个不可见的哨兵元素,观察它是否进入视口即可。用 loading 标志位做并发保护,避免快速滚动时发出重复请求。

const sentinel = document.querySelector('#load-more-sentinel');
let page = 1, loading = false;

const io = new IntersectionObserver(async (entries) => {
  if (!entries[0].isIntersecting || loading) return;
  loading = true;
  try {
    const list = await fetch(`/api/articles?page=${++page}`).then(r => r.json());
    renderList(list);
    if (list.length === 0) io.disconnect();   // 没有更多数据,直接断开
  } finally {
    loading = false;
  }
}, { rootMargin: '400px 0px' });

io.observe(sentinel);

当接口返回空数组时调用 disconnect() 彻底关闭观察器,比反复判断更省心。如果列表是虚拟滚动、节点会被回收,记得在节点销毁时 unobserve,避免观察器持有已移除的 DOM。

四、场景三:曝光埋点

「有效曝光」一般要求元素露出一定比例并停留一小段时间。用 threshold: [0.5] 过滤掉只擦过边缘的情况,再用 WeakSet 去重,保证同一元素只上报一次。

const seen = new WeakSet();
const trackIO = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (entry.isIntersecting && !seen.has(entry.target)) {
      seen.add(entry.target);
      navigator.sendBeacon('/api/exposure', JSON.stringify({
        id: entry.target.dataset.trackId,
        ratio: +entry.intersectionRatio.toFixed(2),
        ts: Date.now()
      }));
    }
  }
}, { threshold: [0.5] });  // 露出一半以上才算有效曝光

document.querySelectorAll('[data-track-id]').forEach(el => trackIO.observe(el));

上报用 navigator.sendBeacon 而不是 fetch:页面跳转或关闭时,浏览器仍会尽力把 beacon 发出去,埋点丢失率明显更低。如果业务还要求「停留超过 1 秒才算曝光」,可以在进入视口时记时间戳、离开时用 setTimeout 校验,这里不再展开。

五、几个容易踩的坑

  • rootMargin 只认 px 与 %:写 10rem 或省略单位会直接抛错,这是最常见的配置错误;
  • 回调在主线程:虽然交叉计算是异步的,但回调仍在主线程执行,别在里面做重活,拿到结果后应尽快转交给空闲时段的任务;
  • threshold 是数组:传单个数字也能用,但需要多档位(例如 0、0.5、1)判断「进入 / 半露 / 全露」时必须是数组;
  • 元素本身要有尺寸:高度为 0 或未脱离文档流的隐藏元素不会产生预期的交叉状态;
  • 记得收尾:组件卸载时调用 disconnect(),否则观察器会一直持有 DOM 引用。

小结

把「可见性判断」交给浏览器之后,懒加载、无限滚动、曝光埋点这三段逻辑都可以压到二三十行,且不再需要自己写节流去抖。现代浏览器对 IntersectionObserver 的支持已经很完整,只有在必须兼容老旧内核时才需要引入 polyfill。如果你的项目里还留着基于 scroll + getBoundingClientRect() 的实现,这是一个成本低、收益明确的替换点。

本文为原创实践记录,示例代码均可直接运行,欢迎交流更稳妥的边界处理方式。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。