用IntersectionObserver实现高性能懒加载和滚动检测

皇甫春光 优化 阅读 2,410
赞 29 收藏
二维码
手机扫码查看
反馈

核心代码就这几行,但得先搞懂它到底在干啥

我第一次用 IntersectionObserver 是为了做图片懒加载,结果写了半天发现滚动时图片根本不动——不是没触发,是触发了但 observer 把元素“看丢”了。折腾了半天才发现:它默认只监听一次,而且初始状态不自动检查。亲测有效的方式是:观察前先手动调用 observer.observe(el),再补一句 observer.takeRecords() 拿到当前所有可见项。

用IntersectionObserver实现高性能懒加载和滚动检测

先甩最精简、能直接跑的代码:

const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const img = entry.target;
        const src = img.dataset.src;
        if (src) {
          img.src = src;
          img.removeAttribute('data-src');
          // 观察完就停,避免反复触发(懒加载场景下推荐)
          observer.unobserve(img);
        }
      }
    });
  },
  {
    threshold: [0, 0.1, 0.5, 1.0], // 多阈值,更精细控制
    rootMargin: '0px 0px -50px 0px' // 向上多看 50px,提前加载
  }
);

document.querySelectorAll('img[data-src]').forEach(img => {
  observer.observe(img);
});

这个场景最好用:列表无限滚动 + 预加载占位符

我们有个商品列表页,每页 20 条,用户滚到底部就 fetch 下一页。以前用 window.onscroll + getBoundingClientRect(),卡顿明显,尤其安卓低端机。换 IntersectionObserver 后,首屏加载快了 300ms,滚动也顺滑了。

关键点在于:别让 observer 去“盯”最后一个 item,而是盯一个专门的 <div class="loader-trigger"></div>,插在列表末尾:

<ul id="product-list">
  <!-- 商品项 -->
  <li>...</li>
  <li>...</li>
</ul>
<div class="loader-trigger"></div>
const trigger = document.querySelector('.loader-trigger');
const observer = new IntersectionObserver(
  ([entry]) => {
    if (entry.isIntersecting && !loading) {
      loading = true;
      fetch('https://jztheme.com/api/products?page=' + (page + 1))
        .then(r => r.json())
        .then(data => {
          appendProducts(data);
          page++;
          loading = false;
        })
        .catch(() => loading = false);
    }
  },
  { threshold: 0.1 }
);

observer.observe(trigger);

注意:loading 是个全局 flag,不然快速滚动可能触发多次请求。这个方案比监听 scroll 更稳,亲测在 iOS Safari 和 Chrome Android 上都 OK。

踩坑提醒:这三点一定注意

  • root 不设默认就是 viewport,但如果你的容器加了 transformoverflow: hidden,observer 就会失效——我踩过两次。解决方案:显式传 { root: document.querySelector('.scroll-container') },并确保该容器有明确 height 和 overflow:auto
  • threshold 是比例,不是像素值,且必须是 0~1 的数组或单个数字。写成 threshold: 50 是无效的,浏览器不报错但永远不触发。我曾误以为它是像素偏移,浪费半小时查文档。
  • observer 实例不能复用监听不同 root。比如你既想监听页面级滚动,又想监听某个弹窗里的滚动,必须新建两个 observer。复用会导致行为不可预测,我试过,Chrome 表现正常,Firefox 直接不触发。

高级技巧:监听“离开视口”+防抖式回调

有些需求要“进入时加载,离开时卸载”,比如视频自动播放/暂停。但 isIntersecting === false 并不等于“完全移出”,它只是“交叉比例低于阈值”。所以不能只靠它判断“彻底离开”。

我的做法是:用 entry.intersectionRatio === 0 作为“完全离开”的信号,再加一层节流(防止快速进出反复触发):

let timeoutId = null;

const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach(entry => {
      clearTimeout(timeoutId);
      if (entry.isIntersecting && entry.intersectionRatio > 0) {
        entry.target.play?.();
      } else if (entry.intersectionRatio === 0) {
        // 真正离开才 pause,且加 100ms 防抖
        timeoutId = setTimeout(() => {
          entry.target.pause?.();
        }, 100);
      }
    });
  },
  { threshold: [0, 0.01, 0.5] }
);

这个逻辑在短视频 Feed 流里跑了半年,没出过问题。虽然不如原生 playbackState 精准,但兼容性好,够用。

还有个骚操作:用它代替 resize 监听元素尺寸变化?

是的,可以。只要把 rootMargin 设成极小的负值(比如 '-1px'),再配合 threshold: [0],就能在元素宽高变化时触发(因为 layout change 会影响 intersection)。不过……我不推荐。

原因有三:一是性能不如 ResizeObserver;二是 Firefox 对负 margin 支持不稳定;三是语义错乱,别人读代码会懵。我试过,改完后确实能 work,但团队 Code Review 直接否了——技术上可行,但不专业。

结语

IntersectionObserver 不是银弹,但它确实是现代前端滚动交互的基石之一。比起手撸 scroll + debounce + throttle + getBoundingClientRect,它更轻、更稳、更可维护。我现在新项目只要涉及可视区判断,第一反应就是它。

这个技巧的拓展用法还有很多,比如结合 Canvas 动画做视差、监听广告曝光上报、甚至用在表单验证(输入框进入可视区才初始化校验器)……后续会继续分享这类博客。

以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论