1. 用了十年 scroll / getBoundingClientRect,为什么大家还是不想换
1.1 面试里最常听到的“标准答案”
“你们项目里的懒加载是怎么做的?”我把这个问题拿去问了不少前端候选人,十个人里至少有七个会这样回答:监听 scroll 事件,然后用 getBoundingClientRect() 判断元素是否进入了视口,再替换 src 或渲染数据。如果你再追问一句“有没有用过 IntersectionObserver”,不少人会迟疑一下,然后说“知道,但项目里还没用过”。
这个答案不能算错,甚至在最简单的图片懒加载场景里,它完全可以跑得很好。但让我真正意外的是,IntersectionObserver 已经不是新 API 了,Chrome 51、Firefox 55、Safari 12.1 都早已支持,怎么到今天,绝大多数前端团队的代码里依然是 scroll + getBoundingClientRect 那一套?
先看一个最典型的旧实现:
js复制window.addEventListener('scroll', throttle(() => {
const rect = target.getBoundingClientRect()
if (rect.top < window.innerHeight) {
target.src = target.dataset.src
}
}, 200))
看起来没毛病,但如果你在页面上放一个长列表,里面有几十张图片,这个监听函数会在用户滚动过程中反复执行,即使图片早就加载完了,或者根本没有新图片进入视口,它仍然在计算位置。数据量小的时候无所谓,一旦列表变长、页面交互变复杂,这套逻辑会和各种业务滚动逻辑挤在一起,最后的结果就是滚动掉帧、卡顿,或者出现还没滚到位置图片就提前加载的“误触发”。
1.2 为什么大家宁可继续“笨办法”
我从自己的经验出发,总结了几个原因,不一定排优先级,但都很真实。
第一,老教程的影响太大了。很多前端初学者接触懒加载时,网上的文章基本都是 scroll + getBoundingClientRect,这套方案在 jQuery 时代就是标准做法,写成博客、录成课程,一代传一代,自然成了很多人心里的默认方案。
第二,项目代码有惯性。团队里某个懒加载模块是三年前写的,当时兼容性还不够乐观,也没出过明显事故,后面的人一看“能跑”,就不会主动动它。有句话叫“不坏不修”,在工程里真的是常态。
第三,对 IntersectionObserver 的能力边界不清晰。很多人知道它可以判断元素是否进入视口,但不知道它也能判断元素是不是完全可见、是不是正在离开视口,更不清楚 threshold、rootMargin 和 root 这三个参数到底能组合出什么效果。既然没有深入理解,自然不敢贸然替换线上代码。
第四,是真有兼容性顾虑。虽然现代浏览器都支持了,但如果你需要兼容到很老的安卓 WebView,或者用户群体里还有大量旧系统浏览器,那旧方案确实还能兜底。
1.3 别急着全盘否定旧方案
我并不是说所有 scroll + getBoundingClientRect 都该被立刻淘汰。如果你的项目需要兼容十年前的浏览器,或者整个页面只有三张图片要懒加载,那引入 IntersectionObserver 的收益确实不大。
还有一类场景,旧方案反而更合适:你的懒加载逻辑并不是单纯的“进视口触发”,而是要和滚动位置、吸顶导航、加载更多、下拉刷新等多重滚动状态一起处理。这时候原本就存在一个全局滚动事件处理器,在里面顺手判断几个元素的位置也很自然。
我的建议是,可以把 IntersectionObserver 当作更准确的“可见性触发器”,而不是覆盖所有滚动场景的银弹。理解了它的底层原理之后,你就知道什么时候可以干净利落地替换旧代码,什么时候保留旧方案也不丢人。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先搞懂“交叉”的本质:IO 不是滚动事件的替代品
2.1 API 的本来面目
IntersectionObserver 这个名字起得很直白,它观察的是“目标元素”和“根元素容器”之间的交叉情况。默认情况下根元素是浏览器视口,但也可以指定为某个带滚动条的祖先元素。
创建一个观察器只需要这样:
js复制const observer = new IntersectionObserver((entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// 目标元素进入了根元素的可视区域
}
})
}, {
threshold: 0,
root: null,
rootMargin: '0px 0px 0px 0px'
})
// 开始观察某个元素
observer.observe(target)
// 停止观察
observer.unobserve(target)
// 关闭观察器
observer.disconnect()
回调里拿到的 entries 是一次相交状态变化的批量记录,不是单个元素一条。IntersectionObserver 不会像 scroll 事件那样每一帧都触发,它会在元素可见性发生实际变化时,集中地异步通知你一次。
2.2 一条 entry 里到底有什么
假设只观察一个元素,回调里常见的字段我先列一下:
target:被观察的元素本身isIntersecting:布尔值,是否和根元素相交intersectionRatio:目标元素可见部分占自身面积的比例boundingClientRect:目标元素的边界矩形,类似getBoundingClientRect()的结果intersectionRect:目标元素和根元素相交的那块矩形rootBounds:根元素的边界矩形
很多初学者只看到 isIntersecting,其实 intersectionRatio 才是判断“看清楚了没”的关键。比如一张图片只显示了一角,isIntersecting 可能已经是 true,但 intersectionRatio 只有 0.1,这对埋点统计、视频自动播放这类场景来说完全不够。
2.3 为什么它比滚动监听更聪明
用生活化一点的方式来理解,以前的做法是:窗口一滚动,你就派一个人不停地去检查每个图片的位置,这个人每次都要测量距离、比对宽度、做判断,哪怕页面根本没变化,只要滚动事件被触发,他就要跑一趟。
IntersectionObserver 的做法是:让浏览器自己内部维护一份“交集区域图”,它能感知页面渲染、滚动、元素尺寸变化的时机,在这些节点上统一计算一次相交状态。这种方式更接近“我关心某个东西是否被看到了”,而不是“我关心滚动到底走了多少像素”。
这带来的直接好处是:你不再需要自己写节流、防抖,不再需要测量每一帧的滚动位置,也不容易出现多张图同时进入视口时计算错乱的问题。
3. 实战图片懒加载:一个能直接放进项目的 IO 封装
3.1 先搭一个最基础的版本
为什么从图片懒加载开始?因为它的目标最明确:让图片进入视口附近时才加载,减少初始请求数,再把占位、错误处理、卸载这些环节补齐。
HTML 部分建议这样写:
html复制<img
src="data:image/svg+xml,..."
data-src="/uploads/real-image.jpg"
width="600"
height="400"
alt="示例图片"
/>
占位 src 可以是极小的 base64 或本地占位图,重点是不要直接写成真实图片地址。图片的 width 和 height 建议显式给出来,否则懒加载完成后图片撑开布局,页面会出现明显跳动,也就是常说的 CLS(Cumulative Layout Shift)问题。
然后建一个最小封装:
js复制function createLazyImageObserver(root = null, rootMargin = '0px 0px 200px 0px') {
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return
const img = entry.target
const realSrc = img.dataset.src
if (!realSrc) return
// 用 Image 对象提前加载,加载完成后再替换,避免闪烁
const downloader = new Image()
downloader.onload = () => {
img.src = realSrc
img.removeAttribute('data-src')
img.classList.add('loaded')
}
downloader.onerror = () => {
img.src = '/images/fallback.jpg'
img.removeAttribute('data-src')
}
downloader.src = realSrc
// 加载过的图片就不用继续观察了
obs.unobserve(img)
})
}, {
root,
rootMargin,
threshold: 0
})
return observer
}
const lazyObserver = createLazyImageObserver()
document.querySelectorAll('img[data-src]').forEach((img) => {
lazyObserver.observe(img)
})
3.2 为什么加载完要立刻 unobserve
这是很多 DIY 实现容易忽略的点。IntersectionObserver 会在目标元素每次相交状态变化时触发回调,如果你不主动 unobserve,那图片加载完成后,它继续被观察,后续可能还会触发 onload、替换等问题。
虽然通过判断 img.dataset.src 是否为空可以避免重复操作,但保留不必要的观察目标会占用资源。特别是长列表里几十张图片,全部持续观察没意义,而是应该在完成使命后立刻移除。
3.3 用 Image 对象提前下载的原因
有些简化版实现是直接写 img.src = img.dataset.src,这样也能生效,但我在实际项目里更喜欢用 new Image() 先做一次预下载,成功后再赋值回目标元素。原因很简单:如果替换后因为网络差加载失败,用户看到的可能是一个裂图,而用 downloader.onerror 可以在替换前就知道这张图有问题,提前换成兜底图,体验更稳。
有同事问我为什么不能直接监听原元素的 error 事件,其实也可以。用 new Image() 的好处是,整个过程不会给真正的 img 元素带来一次注定失败的请求,也不会有短暂的资源状态变化。
3.4 在 Vue 或 React 里怎么接
框架里使用 IO 时,最需要注意的是生命周期管理。如果整个页面只需要一个观察器,可以放在顶层 onMounted 或 useEffect 里创建,然后对所有图片执行 observe,页面卸载或组件销毁时 disconnect。
Vue 自定义指令是一个很顺手的封装方式:
js复制const lazyDirective = {
mounted(el, binding) {
el.dataset.src = binding.value
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
el.src = binding.value
obs.unobserve(el)
}
})
}, { rootMargin: '200px 0px' })
observer.observe(el)
el._lazyObserver = observer
} else {
el.src = binding.value
}
},
unmounted(el) {
el._lazyObserver?.disconnect()
}
}
这里有一个很容易踩的坑:如果一个页面有多个组件各自创建自己的 observer,成本其实不高,但如果你在组件卸载时直接 disconnect,而组件只是切换了显隐,并不代表图片不需要懒加载了,可能会影响其他观察目标。稳妥的做法是每个组件只观察自己的元素,或者在组件卸载时只 unobserve 自己观察过的节点。
4. 从懒加载图片到无限滚动:IO 在列表和树表里的正确姿势
4.1 哨兵元素模式
图片懒加载的直接触发源是图片本身,但在无限滚动列表里,我们通常不会给将要加载的每个数据项都设一个观察目标,而是用“哨兵元素”来做触发点。
哨兵元素就是一个占位节点,放在列表末尾:
html复制<div id="load-more-sentinel" style="height: 1px;"></div>
用 IO 观察这个哨兵,当它进入视口时,说明用户已经滚动到了底部附近,这时候发起“加载下一页”请求:
js复制const sentinel = document.querySelector('#load-more-sentinel')
const observer = new IntersectionObserver((entries) => {
if (!entries[0].isIntersecting) return
if (loading) return
loading = true
fetchNextPage().then((data) => {
list.append(...data)
loading = false
})
}, {
root: null,
rootMargin: '200px 0px',
threshold: 0
})
observer.observe(sentinel)
关键细节是 loading 锁。如果回调触发时正处于请求中,直接忽略,否则用户快速拖动滚动条,会一次性触发好几次请求,接口和渲染都会出问题。
另一种做法是在发起请求前先 observer.unobserve(sentinel),请求成功并重新渲染列表后,再把哨兵挪到最底部,重新 observe。这两种方式可以结合,同一个团队里只要形成统一习惯就行。
4.2 “逐层新增下一节点”:树表懒加载的真正场景
最近有一个热词让我印象很深:elements plus 实现懒加载表格树,逐层新增下一节点。这其实就是 Element Plus 里的树表懒加载场景:父节点展开时才去请求子节点,然后把数据逐层挂到树节点下。
如果树比较小,默认方式完全够用。但一旦根节点有几百行,每个父节点点开都要实时等接口,体验就会变差。用户滚动到下方,看到一个还没加载过的展开箭头,点击后要转圈等待,这种交互就不太像“现代前端”了。
这个时候 IO 可以派上用场:我们可以观察树表中的“行元素”,当某一行滚动进入视口时,如果它的展开箭头存在,但子节点还没有加载过,就提前把接口请求发出去,把数据缓存到本地。用户真正点击展开时,数据已经准备好,不需要等待。
我强调一下,这不是让 IO 去替代树表本身的数据加载机制,而是用它做“预取加速”。实践中可以这样做一个简单封装:
js复制// 伪代码,示意结构
function prefetchTreeNodeOnVisible(rowEl, nodeRef, resolveFetch) {
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return
if (nodeRef.loaded) {
obs.unobserve(rowEl)
return
}
// 提前请求下一层数据,并写入缓存
resolveFetch(nodeRef)
// 如果只是想预取而保留默认行为,可以不在这里标记 loaded
obs.unobserve(rowEl)
})
}, {
root: null,
rootMargin: '0px 0px 100px 0px',
threshold: 0
})
observer.observe(rowEl)
}
但这里必须克制。过度预取会让所有子节点接口同时被请求,失去了懒加载的意义。实际项目里可以设置一个开关,只在节点“即将进入视口”时预取,而不是所有节点都预取。比如 rootMargin 设成 0px,让预取行为尽量贴近“真正要被看到”的时机。
4.3 为什么 IO 模式会比“滚动判断每行位置”更清晰
假如你还是用老办法来给树表做预取,你得在 scroll 事件里遍历当前可视区域的行,判断哪一行节点没有加载,然后触发展开请求。每次滚动都要重新算所有行的 getBoundingClientRect(),行数一多,性能立刻下滑。
IO 的写法更符合直觉:让浏览器告诉我哪些节点进入视口了,我再针对性处理。这比自己在滚动事件里用位置计算去“猜”要准确得多,也少写很多优化代码。
5. threshold、rootMargin 和 root:这才是 IO 的调优核心
5.1 rootMargin:提前加载和消除误触发都靠它
rootMargin 相当于给根元素四周加了一圈“扩大范围”。默认是 0px 0px 0px 0px,比如你想在图片距离视口还有 200px 时就开始加载,可以设置 rootMargin: '0px 0px 200px 0px'。
但千万注意,rootMargin 会同时影响“进入”和“离开”的判断。如果你给四个方向都加了 200px,那么元素在视口外 200px 内时,也会被认为和根元素相交。这在图片懒加载里是合适的,因为它给了加载提前量;但在曝光埋点里就很容易出问题:用户可能只是划到这个区域边缘,元素还没完全显示,就被统计为“曝光”了。
我在做曝光归因时一般会把 rootMargin 设置成 0px,同时配合高一点的 threshold,宁可从源头收窄条件,也不要事后从数据里过滤噪声。
5.2 threshold:仅 0 和 1 不够用
threshold 可以是一个数值,也可以是一个数组。0 表示只要目标有任何一像素和 root 相交,就会触发回调;1 表示必须完全进入根元素范围才会触发。
但真实业务里,这两个极端往往都不合适。广告曝光统计通常要求元素至少露出 50% 才算有效曝光;视频自动播放可能希望显示 70% 以上才开始播放;而埋点又希望在元素开始露出一部分时就上报“开始曝光”。
设置成数组可以同时观察多个阈值:
js复制const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.intersectionRatio >= 0.5) {
// 元素可见面积超过一半
}
if (entry.intersectionRatio === 0) {
// 元素完全离开视口
}
})
}, {
threshold: [0, 0.5, 1]
})
为什么是“穿过阈值”而不是“到达某个比例”?因为浏览器会在变化过程中触发一次回调,你拿到的 intersectionRatio 是当前状态。如果希望只在某一方向变化时处理,需要在外部自己维护状态。比如“上一帧是 0.6,这一帧变成 0.3”,说明它正在离开视口,这时候可以暂停视频或停止上报。
5.3 案例:一个能自动播放暂停的视频组件
视频是最能体现 IO 优势的场景之一。用 scroll 方案实现自动播放,要计算视频元素和视口的重叠面积;用 threshold 就简单多了:
js复制const video = document.querySelector('video')
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && entry.intersectionRatio >= 0.6) {
video.play()
} else {
video.pause()
}
})
}, {
threshold: [0.2, 0.6]
})
observer.observe(video)
这里的效果是:当视频可见面积超过 60% 时开始播放,一旦低于 60%,立刻暂停。对于信息流里的视频,这个体验已经足够自然,而且不用写任何滚动位置计算。
还有一点容易忽略:如果视频原本就一进页面自动播放,但被另一个弹层挡住了,IO 的 isIntersecting 依然可能返回 true。它只能判断元素和根元素是否相交,并不能判断是否被其他元素遮挡。真要处理遮挡场景,还需要结合 z-index 和点击区域判断,但那是另一个话题了。
6. 浏览器原生 loading="lazy" 和 IO 怎么分工,以及兼容性兜底
6.1 原生 lazy 到底可以帮你做什么
html复制<img src="real.jpg" loading="lazy" decoding="async" alt="" />
就这么一个属性,浏览器会自己决定图片什么时候加载。对于只想减少请求数量的项目来说,原生 loading="lazy" 是成本最低的方案,甚至不需要写 JS。
iframe 也支持 loading="lazy",这是一个很容易被忽略的点。如果页面里嵌了大量第三方播放器或地图 iframe,加这个属性可以明显减少首屏资源压力。
但原生 lazy 的缺点也很明显:它不能控制“提前多少像素触发”,不能触发自定义的曝光上报,不能和框架的数据流对接,也不能让图片加载完成后执行某个动画。更麻烦的是,不同浏览器对它的实现策略不完全一致,在容器内滚动时,部分浏览器表现也不稳定。
6.2 使用 IO 时还要不要加 loading="lazy"
我的建议是,两者可以共存,但要看场景。
如果只是为了“图片在视口附近才请求”,完全可以只用原生 loading="lazy",省掉 IO 代码。如果需要做图片加载完成后的渐显动画、上报曝光数据、或者结合组件状态,那就用 IO 来观察 img 元素,同时保留原生的 loading="lazy" 也没关系,它们是两条独立的链路。
需要注意的是,如果你在 IO 回调里把 img.src 从占位图替换成真实地址,而图片又带有 loading="lazy",部分浏览器可能会把“替换 src”当成一次新的懒加载请求,导致图片比预期更晚出现。我通常在“自定义 IO 懒加载”场景里不给图片加 loading="lazy",二者只用其一。
6.3 渐进增强和降级策略
即使 IntersectionObserver 支持度已经很高,一个工程化的懒加载模块还是应该考虑降级。
最朴素的降级方案就是:
js复制if ('IntersectionObserver' in window) {
initIntersectionObserverLazy()
} else {
initScrollFallbackLazy()
// 或者直接展示所有图片
}
旧版本浏览器数量很少时,直接展示所有图片不是丢人的方案,相反它最稳。如果产品数据统计显示旧浏览器用户占比高,再写一个 scroll + throttle + getBoundingClientRect 的 fallback 也不难。
这里我没有刻意推荐 polyfill,因为有些 polyfill 本身重量不低。对于图片懒加载这种“受影响用户很少”的增强功能,渐进增强是更稳妥的工程选择。
7. 什么时候 IO 也不能用?低版本环境、嵌套滚动和性能边界
7.1 低版本环境里别硬上
虽然现代浏览器基本全覆盖,但在一些嵌入式 WebView、老版本安卓系统浏览器里,IntersectionObserver 还是可能缺席。如果你的产品大量运行在老旧系统上,优先考虑原生 loading="lazy" 或者直接不懒加载,可能比引入庞大 polyfill 更合理。
还有一种情况是,页面里既有旧代码维护的滚动逻辑,又有新加的 IO 观察器,两边同时计算元素位置,可能出现短暂的不一致。比如旧代码在滚动事件里做吸顶,IO 回调异步判断吸顶元素是否可见,导致吸顶样式晚一帧生效。遇到这种问题,先别甩锅给 IO,而是检查是否真的有必要同时用两套“可见性判定”逻辑。
7.2 嵌套滚动容器:root 选不对,一切都白搭
默认 root: null 指的是浏览器视口。如果页面里有一个 overflow: auto 的滚动容器,图片是在这个容器内滚动的,但你用默认 root 去观察,图片会一直处于“视口之外”的状态,触发时机完全错乱。
这种情况下必须把 root 指向那个滚动容器:
js复制const container = document.querySelector('.scroll-container')
const observer = new IntersectionObserver(callback, {
root: container,
rootMargin: '0px 0px 100px 0px',
threshold: 0
})
有一个很隐蔽的问题:root 元素只要被目标元素的祖先元素承载,但 root 本身如果被其他元素裁剪,那相交计算的基准还是以 root 元素的盒子为准。如果你发现 IO 在复杂嵌套布局里时灵时不灵,可以用 entry.rootBounds 在回调里打印一下根元素的边界,对比看看是不是 root 选错对象了。
7.3 IO 解决不了“虚拟滚动”
很多人以为用了 IO 就不用做虚拟滚动,其实这是个误解。IO 只负责告诉你哪些元素可见,但虚拟滚动还需要计算每个列表项的高度、偏移量、总滚动高度,以及只渲染可见区间内的元素。这项任务主要靠 scroll / getBoundingClientRect 或 ResizeObserver 来完成,IO 顶多作为“是否进入视口”的额外信号。
如果列表有几千行,你直接把所有行 DOM 都渲染出来,然后用 IO 判断“哪些行可见”再动态加类,这并不能解决渲染几千个 DOM 节点的开销,只是在几千个节点里再筛选一层。真正的虚拟列表还是要自己管理渲染区间。
7.4 一些我踩过的实操坑
最后分享几个实操中容易忽略的细节。
第一个是观察目标从 DOM 里移除后,如果没有手动 unobserve,观察器还保存着这个节点的引用。持续插入、删除节点的长列表,累计起来可能造成内存增长。所以组件卸载、列表清空时,记得把不再需要观察的元素 unobserve 掉。
第二个是不要在 IO 回调里做强制重绘的操作。虽然 IO 相比 scroll 事件已经省去了大量重复计算,但如果回调里读 offsetTop、getBoundingClientRect,再同步修改样式,依然可能破坏浏览器的渲染优化。
第三个是观察器数量。尽量让一个页面复用一个观察器,而不是每个组件 new 一个。如果观察的目标分散在不同的滚动容器里,可以按 root 维度分几个观察器,但没必要一个图片一个观察器。
我在自己负责的项目里,通常会把 IO 封装成一个很小的模块,暴露 observe、unobserve、disconnect 三个方法,内部维护注册表。图片懒加载、树表预取、曝光上报都复用它。真正把一个页面里所有懒加载逻辑收敛成一个小工具后,你会发现那些“笨办法”确实可以退居二线了,但最重要的是,你会更清楚什么时候该用哪个方案,而不是哪个火就无脑上哪个。
