"元素有没有进入可视区域"是前端的高频需求:图片懒加载、列表无限滚动、广告曝光统计、滚动到某段触发动画……
过去实现这些,要监听 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))两个要点:
- 加载后
unobserve:图片已经加载,没必要继续观察,及时释放。 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,以及它是异步批量的,别用它做同步精确计算。掌握了它,凡是和"滚动 + 可见性"相关的需求,基本都能优雅解决。