返回首页

IntersectionObserver 实战:图片懒加载、无限滚动与曝光埋点

监听元素是否进入视口,过去要靠 scroll 事件加 getBoundingClientRect,既卡又难写。IntersectionObserver 把这件事交给浏览器异步处理。这篇用三个真实场景讲透它的用法和参数。

47 约 5 分钟 · 5583 字 前端

"元素有没有进入可视区域"是前端的高频需求:图片懒加载、列表无限滚动、广告曝光统计、滚动到某段触发动画……

过去实现这些,要监听 scroll 事件,然后在回调里疯狂调用 getBoundingClientRect() 计算位置。问题是 scroll 触发极其频繁,而 getBoundingClientRect() 会强制浏览器重排,两者叠加非常容易造成卡顿。

IntersectionObserver 就是为了解决这个问题而生的:把"元素是否与视口相交"的判断交给浏览器,异步、批量、不阻塞主线程

基本用法

三步:创建观察器、指定回调、观察目标元素。

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      console.log('进入视口', entry.target)
    } else {
      console.log('离开视口', entry.target)
    }
  })
})

// 开始观察某个元素
observer.observe(document.querySelector('.target'))

回调接收一个 entries 数组(可能同时有多个元素状态变化)。每个 entry 常用的字段:

  • entry.isIntersecting:布尔值,是否正在与视口相交(最常用)。
  • entry.target:被观察的那个元素。
  • entry.intersectionRatio:相交比例,0 到 1。
  • entry.boundingClientRect:目标元素的位置信息(不用自己调 getBoundingClientRect)。

配置项:rootMargin 与 threshold

创建观察器时可以传第二个参数,这是用好它的关键:

const observer = new IntersectionObserver(callback, {
  root: null,            // 视口容器,null 表示浏览器视口
  rootMargin: '0px 0px 200px 0px', // 把视口"扩大",提前 200px 触发
  threshold: 0,          // 相交多少比例时触发回调
})

rootMargin:给 root 的边界加"外边距",正值是放大检测区域。'0px 0px 200px 0px' 表示视口下方 200px 处就算"进入",常用于懒加载提前预载,让用户滚到之前图片就开始加载,体验上感觉不到延迟。

threshold:可以是单个数字或数组,表示相交比例达到多少时触发回调。

  • 0(默认):元素刚露头(哪怕 1px)就触发。
  • 1:元素完全进入才触发。
  • [0, 0.5, 1]:相交 0%、50%、100% 时各触发一次,适合做"曝光多少算有效"。

root:指定滚动容器。默认 null 是浏览器视口;如果是在一个内部可滚动的 div 里,把它设为那个容器。

场景一:图片懒加载

把真实图片地址放在 data-src,进入视口才赋值给 src,实现懒加载:

<img class="lazy" data-src="/uploads/photo-1.jpg" alt="" />
<img class="lazy" data-src="/uploads/photo-2.jpg" alt="" />
const imgObserver = new IntersectionObserver(
  (entries, observer) => {
    entries.forEach((entry) => {
      if (!entry.isIntersecting) return

      const img = entry.target
      img.src = img.dataset.src // 触发真正的加载
      img.removeAttribute('data-src')
      observer.unobserve(img) // 加载过就停止观察,避免重复
    })
  },
  { rootMargin: '0px 0px 200px 0px' }, // 提前 200px 预载
)

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

两个要点:

  1. 加载后 unobserve:图片已经加载,没必要继续观察,及时释放。
  2. rootMargin 提前量:让图片在进入视口前就开始下载,用户滚到时已经显示好了。

补充:现代浏览器原生支持 <img loading="lazy">,简单场景直接用它即可。需要更精细的控制(占位图、淡入动画、统计加载)时,才用 IntersectionObserver 自己实现。

场景二:无限滚动

在列表底部放一个"哨兵"元素,观察它;哨兵进入视口就加载下一页。

<ul id="list"><!-- 列表项 --></ul>
<div id="sentinel"></div>
let page = 1
let loading = false
let hasMore = true

const loadMore = async () => {
  if (loading || !hasMore) return
  loading = true

  const res = await fetch(`/api/posts?page=${page}`).then((r) => r.json())
  appendItems(res.items)
  hasMore = res.items.length > 0
  page++

  loading = false
}

const sentinelObserver = new IntersectionObserver(
  (entries) => {
    if (entries[0].isIntersecting) loadMore()
  },
  { rootMargin: '0px 0px 300px 0px' }, // 距底部 300px 就开始加载
)

sentinelObserver.observe(document.getElementById('sentinel'))

相比监听 scroll 判断 scrollTop + clientHeight >= scrollHeight,这种"哨兵"写法更简洁,也不用自己防抖。loading 标志位用来防止哨兵停留在视口里时重复触发。

场景三:曝光埋点

统计某个卡片/广告"被用户真正看到"——而且通常要求看到一定比例、停留一定时间才算有效曝光。

const exposureTimers = new WeakMap()

const exposureObserver = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      const el = entry.target
      if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
        // 露出过半,开始计时,停留满 1 秒才算有效曝光
        const timer = setTimeout(() => {
          report('exposure', el.dataset.id)
          exposureObserver.unobserve(el) // 上报一次即可
        }, 1000)
        exposureTimers.set(el, timer)
      } else {
        // 还没满 1 秒就划走了,取消计时
        const timer = exposureTimers.get(el)
        if (timer) clearTimeout(timer)
      }
    })
  },
  { threshold: [0, 0.5, 1] }, // 关注 50% 这个临界点
)

document.querySelectorAll('.ad-card').forEach((el) => exposureObserver.observe(el))

threshold: 0.5 + 停留计时,就实现了"露出过半且停留 1 秒"的有效曝光口径。这是用 scroll 很难优雅实现的。

在框架里封装成 Hook / 指令

Vue 3 里可以封装成一个 composable,自动在卸载时断开:

// useInView.js
import { ref, onMounted, onUnmounted } from 'vue'

export function useInView(options) {
  const target = ref(null)
  const isInView = ref(false)
  let observer = null

  onMounted(() => {
    observer = new IntersectionObserver(([entry]) => {
      isInView.value = entry.isIntersecting
    }, options)
    if (target.value) observer.observe(target.value)
  })

  onUnmounted(() => observer?.disconnect())

  return { target, isInView }
}
<script setup>
const { target, isInView } = useInView({ threshold: 0.2 })
</script>

<template>
  <div ref="target" :class="{ 'animate-in': isInView }">滚动到我时淡入</div>
</template>

常见坑

第一,忘记销毁观察器。 单页应用里组件反复挂载,不调用 unobserve / disconnect 会累积观察器,造成泄漏。在组件卸载时务必 disconnect()

第二,回调是异步且批量的。 它不会在元素一相交的瞬间同步触发,而是浏览器在合适时机批量回调。所以不要依赖它做需要严格同步、像素级精确的逻辑

第三,threshold 与元素比视口还大时。 如果被观察元素本身比视口大,intersectionRatio 永远到不了 1,threshold: 1 会一直不触发。这时该用低一点的阈值。

第四,初始就在视口内的元素。 observe 后会立即回调一次当前状态,所以页面打开时就在视口里的元素也会正常触发,不用额外处理。

总结

IntersectionObserver 把"元素是否进入视口"从一个性能噩梦变成了一个声明式的 API:

  • 懒加载:data-src + rootMargin 提前量 + 加载后 unobserve
  • 无限滚动:底部放哨兵元素 + loading 标志位防重。
  • 曝光埋点:threshold 控制有效比例 + 计时控制停留时长。

记住两条铁律:用完就 unobserve / disconnect,以及它是异步批量的,别用它做同步精确计算。掌握了它,凡是和"滚动 + 可见性"相关的需求,基本都能优雅解决。