资讯动态

IntersectionObserver实战:图片懒加载、无限滚动与性能优化

发布时间:2026/10/6 16:28:36 来源:尧图企业网站定制
1. 先聊聊懒加载为什么值得做以及滚动监听方案的坑1.1 懒加载解决的不只是首屏速度前阵子接了一个活动页180 多张商品图一次性铺进来首屏白屏时间直接崩到三秒以上。图片是页面里最重的资源之一一张高清图动辄几百 KB如果用户只看了第一屏浏览器却把后面所有图片都下载了浪费带宽不说慢网络下整个页面都被拖垮。懒加载的核心思路就是先不设置真实的图片地址等元素快要进入视口时再赋值并触发下载。它解决的问题不只是视觉上的首屏变快还直接影响 LCP、CLS 这类性能指标。首屏请求数少了服务器压力也小长列表页滚动时不会一次性加载几十张图网络和内存都更稳。适用场景很广图片瀑布流、长文档插图、视频封面、弹窗里的富媒体、无限滚动列表甚至某个重量级组件的按需加载都能用同一套机制解决。你如果正在做前端项目里比较重的页面或者准备前端面试被问到性能优化懒加载都是绕不开的一环。而用 IntersectionObserver 来做懒加载正在成为近几年的主流选择因为它比传统滚动监听更省心、更流畅代码也更少。1.2 传统实现方案与痛点早期的懒加载方案基本都是监听 scroll 事件然后在回调里判断元素位置。最常见的写法大概是这个样子window.addEventListener(scroll, () { const rect img.getBoundingClientRect(); if (rect.top window.innerHeight) { img.src img.dataset.src; } });这段代码逻辑很简单但坑也不少。scroll 事件在滚动过程中会高频触发而且getBoundingClientRect()每次都会触发浏览器重新计算布局也就是强制同步布局。你一边滚动一边在 JS 线程里反复读取布局信息主线程很容易被占满表现出来就是掉帧、卡顿尤其低端安卓机上更明显。这还只是第一个问题。第二个问题是时机不准如果没有在页面加载完成后手动触发一次 scroll 回调首屏内已经可见的图片反而不会被加载必须等用户滚动一下才出现。第三是资源浪费图片一旦加载完成scroll 监听器还挂在window上每次滚动仍然会执行判断等于开着水龙头不关。后来大家开始给 scroll 回调加节流、做高度缓存、加 loaded 标记能缓解一部分问题但代码越来越复杂性能瓶颈仍然在。浏览器也看到了这种高频计算需求于是提供了原生的loadinglazy属性但它只能用在 img 和 iframe 上而且触发时机不可控所以业务里复杂的懒加载需求还是需要更通用的方案。1.3 “更流畅”的本质差异IntersectionObserver 和滚动监听最根本的区别在于相交判断这件事到底由谁来做。滚动监听方案是你自己去问浏览器“这个元素现在在不在视口里”而且每来一次 scroll 事件就问一次问完还得等结果把计算压力全压在主线程上。IntersectionObserver 的做法则是把目标元素交给浏览器由浏览器在内部计算相交状态然后在合适的时机异步通知你。打个比方传统方案像是在热门景区门口每隔几秒派人去数人流量人流一大统计员就被挤得喘不过气IntersectionObserver 则是在门口装了个感应器人一进入感应区自动触发计数景区不会再派一个气喘吁吁的统计员。“更流畅”就是从这个机制里来的。IO 回调是异步派发的不会阻塞滚动处理逻辑它也不需要在每次滚动时强制读取布局避免了前面说的强制同步布局问题。对用户来说最直观的感受就是页面滚动时不再一卡一顿尤其当你一个页面上监听了几百个图片节点时差距会非常明显。2. IntersectionObserver 核心机制API 与参数怎么用2.1 从一次最简单的 observe 开始IntersectionObserver 的用法可以用三句话概括创建一个观察器把目标元素交给它在回调里处理相交状态。先看一个最基础的例子const observer new IntersectionObserver((entries) { entries.forEach((entry) { if (entry.isIntersecting) { console.log(元素进入视口, entry.target); } }); }); const target document.querySelector(.lazy-image); observer.observe(target);回调函数接收一个entries数组每个entry包含几个关键字段entry.isIntersecting当前是否和目标区域相交布尔值懒加载时最常用。entry.intersectionRatio相交面积占目标元素总面积的百分比0 到 1 之间。entry.target被观察的元素本身。entry.boundingClientRect目标元素的矩形信息。entry.intersectionRect相交区域的矩形信息。有一点要特别记住调用 observe 之后回调会立刻执行一次。换句话说元素如果已经在视口内你什么都不用做就能拿到一次isIntersecting: true。这让首屏图片加载变得很简单不需要像旧方案那样额外手动触发一次。当你不再需要观察某个元素时调用observer.unobserve(entry.target)如果整个观察器都不需要了就调用observer.disconnect()否则观察器会一直持有元素引用容易造成内存泄漏。2.2 threshold、rootMargin、root 到底怎么调创建观察器的时候第二个参数可以传三个配置项root、rootMargin、threshold。很多人在这一块容易糊我拆开讲。root 表示“用哪个区域来判断相交”。默认是null也就是浏览器视口。你还可以指定一个容器元素比如一个可滚动的 div让观察器只判断目标元素是否进入这个容器。需要注意目标元素必须是 root 的后代元素否则判断结果没有意义。rootMargin 用来扩大或缩小 root 的判定区域。它和 CSS margin 的写法类似比如200px 0px表示上下各扩大 200 像素左右不变。想要提前加载图片时我一般会设一个偏大的 rootMargin让图片在用户还没看到它之前就开始加载这样滚过去的时候已经是完整图片了。threshold 是相交比例的阈值。默认是 0表示只要有一丁点进入区域就触发回调设为 1 表示整个元素完全进入才触发还可以传数组比如[0, 0.5, 1]表示每跨越一个比例档位都触发一次。图片懒加载一般不追求精确比例用默认 0 就够了设成 1 反而会让大图加载太晚用户看到空白的时间更长。参数作用懒加载常用值root决定视口基准null浏览器视口rootMargin扩/缩判定范围200px 0pxthreshold触发回调的相交比例0rootMargin 的经验值是我每次都要强调的。不要一味求大比如直接设1000px 0px那样几乎相当于把整个页面的图都提前加载了和不用懒加载没区别。我个人的习惯是 100px 到 300px网络条件好可以再保守一点。你可以根据图片大小和用户滚动速度微调。2.3 为什么它能让滚动更流畅从浏览器内部机制来看IntersectionObserver 的相交计算是在渲染流程中统一处理的而不是绑定在 scroll 事件上。浏览器在每一帧里会自己判断目标元素的可见性变化再通过异步任务把结果通知给 JS。所以你的业务代码不需要在滚动回调里高频率执行读取和判断操作主线程能腾出来处理点击、渲染、动画这些更重要的事情。另一个层面是“没有强制同步布局”。getBoundingClientRect()这类 API 一旦被频繁调用浏览器为了给你准确值必须先完成布局计算这就是强制同步布局。而 IO 计算相交区域是浏览器内部完成的你的 JS 只会收到最终结果不会因为调用一个方法就打断正常的渲染管线。所以你会发现真正用上 IO 之后代码里的节流函数、手动触发首屏加载、滚动监听这些兜底逻辑大多都可以删掉。观察器会告诉你谁进入了视口你只需要针对性地处理目标元素即可这也是它被叫做“更流畅实现”的底气所在。3. 完整落地从图片懒加载到无限滚动和曝光监听3.1 图片懒加载的完整实现先把最常用的图片懒加载完整代码写出来。HTML 结构上我用>img classlazy-img >const lazyImages document.querySelectorAll(img.lazy-img); const observer new IntersectionObserver((entries, obs) { entries.forEach((entry) { if (!entry.isIntersecting) return; const img entry.target; img.src img.dataset.src; img.addEventListener(load, () { img.classList.add(loaded); }, { once: true }); // 重要加载完成后解除观察避免重复触发 obs.unobserve(img); }); }, { rootMargin: 200px 0px, threshold: 0, }); lazyImages.forEach((img) observer.observe(img));这里有几个细节值得说。img.src img.dataset.src是整个懒加载的关键动作它会在元素进入 rootMargin 扩大后的区域时才触发。obs.unobserve(img)一定要在加载后执行否则元素反复进出视口回调会反复跑。为了避免加载失败就一直占着坑最好再加一个 error 处理img.addEventListener(error, () { img.classList.add(error); obs.unobserve(img); }, { once: true });图片可以配一个淡入效果视觉上会自然很多。直接在 CSS 里控制透明度.lazy-img { opacity: 0; transition: opacity 0.3s ease; } .lazy-img.loaded { opacity: 1; }需要注意的是如果 JS 执行失败或者用户禁用了 JS图片会因为没有 src 而永远不显示。实际工程里我会把这个 CSS 类和渐进增强方案配合使用确保最坏情况下图片仍然可见。3.2 预加载与并发控制很多人把懒加载理解成“看不到就不加载”但“更流畅”的体验其实还包含“快到达时就加载”。这正是rootMargin的用武之地。假设一个图片在视口下方 100 像素处你设置了rootMargin: 200px 0px那么它还没真正出现在屏幕上时观察器就已经判定相交并开始加载了。等用户滚过去图片基本已经就绪几乎不需要等待。这就是所谓“预加载”它不会破坏懒加载的意义因为只有靠近视口的元素才会“提前”进入判定区域。我可以给你一个判断标准如果页面滚动速度较快可以把 rootMargin 调到 300px如果图片体积很大则不要设太大否则用户快速滑过整个列表时会一次性触发大量图片下载。IO 本身不负责并发控制如果一屏之外有 50 张图同时进入预加载范围浏览器会同时发起 50 个请求这在慢网络下反而更糟糕。我的做法是维护一个待加载队列每批只处理 N 张比如同时最多加载 6 张加载完成后再从队列里取下一批。这个控制逻辑和 IO 不冲突IO 只负责告诉你“哪些元素进入视口”是否立即加载由你决定。对于绝大多数页面把 rootMargin 调小一点就够用了不用一上来就上队列。3.3 组件懒加载动态 import 的场景图片需要懒加载体积很大的组件、弹窗、图表库同样需要。用 IO 监听一个容器是否进入视口进入后再执行动态 import可以让首屏不需要加载的 JS 代码分出去这是现代前端性能优化里非常有效的三步走。const section document.querySelector(#heavy-section); const observer new IntersectionObserver((entries) { if (entries[0].isIntersecting) { import(./HeavyComponent).then((module) { module.mount(section); }); observer.disconnect(); } }, { rootMargin: 200px 0px, }); observer.observe(section);组件懒加载的关键点是disconnect()。这种场景下我们只关心“第一次进入”一旦模块开始加载就不需要继续观察了所以直接在回调里断开观察器避免后续反复触发。如果你用 React 或 Vue这类逻辑通常会被封装进组件库的懒加载容器里后面第 4 部分我会给封装示例。3.4 无限滚动与曝光埋点无限滚动是 IO 的另一个经典用法。思路很简单在列表底部放一个高度很小的哨兵元素observe 它一旦哨兵进入视口就加载下一页数据。比监听滚动位置更精确而且不需要计算“距离底部还剩多少像素”。div idsentinel/divlet page 1; const sentinel document.querySelector(#sentinel); const observer new IntersectionObserver((entries) { if (entries[0].isIntersecting !isLoading) { isLoading true; loadMore(page 1).then(() { page 1; isLoading false; }); } }, { rootMargin: 100px 0px, }); observer.observe(sentinel);哨兵方案可以配合 rootMargin 提前触发让用户还没滚到底部就开始加载下一批数据体验上几乎感知不到“加载中”的过程。曝光埋点也是 IO 的隐藏优势。因为回调会在元素进入和离开时各触发一次你只需要记录第一次进入的时间戳等isIntersecting变成 false 时计算差值就能得到粗略的曝光时长。这比在 scroll 回调里反复判断元素位置要省太多代码而且数据准确性也不错。不过记住曝光统计中如果只需要进入不要忘记在统计完后unobserve。4. 工程化封装React Hook 和 Vue 指令实战4.1 封装一个 useInView Hook在 React 里我不会在每个组件里手动 new IntersectionObserver而是封装成一个 Hook。先看最简版本import { useEffect, useRef, useState } from react; function useInView(options {}) { const ref useRef(null); const [inView, setInView] useState(false); const { root null, rootMargin 0px, threshold 0 } options; useEffect(() { const el ref.current; if (!el) return; const observer new IntersectionObserver((entries) { entries.forEach((entry) { if (entry.isIntersecting) { setInView(true); observer.unobserve(entry.target); } }); }, { root, rootMargin, threshold }); observer.observe(el); return () observer.disconnect(); }, [root, rootMargin, threshold]); return { ref, inView }; }这个 Hook 返回一个 ref 和 inView 状态。图片组件里可以直接这样用function LazyImage({ src, alt }) { const { ref, inView } useInView({ rootMargin: 100px 0px }); return ( img ref{ref} src{inView ? src : undefined} alt{alt} width400 height300 / ); }有一个容易被忽略的细节useEffect 的依赖数组不能直接写options因为对象每次渲染都是新引用会导致 effect 反复执行。我把root、rootMargin、threshold解构出来分别作为依赖才是准确的做法。如果你确实需要每次渲染都更新配置那也要保证 observer 的创建逻辑是按需更新的而不是无脑重建。4.2 Vue 自定义指令 v-lazyVue 里更符合直觉的做法是写成自定义指令模板里一行就能搞定img v-lazyhttps://example.com/product-1.jpg alt商品图 width400 height300 /注册一个全局指令const vLazy { mounted(el, binding) { if (!(IntersectionObserver in window)) { el.src binding.value; return; } const observer new IntersectionObserver((entries) { if (entries[0].isIntersecting) { el.src binding.value; observer.unobserve(el); } }, { rootMargin: 100px 0px, }); observer.observe(el); el._lazyObserver observer; }, unmounted(el) { el._lazyObserver?.disconnect(); }, }; app.directive(lazy, vLazy);这段代码我把不支持 IO 的情况做了最简单的降级直接赋 src。工程上你还可以在这里加 loading 样式、error 处理等。注意 Vue 3 的指令生命周期是mounted和unmountedVue 2 则需要用inserted和unbind别搞混了。4.3 动态列表中的观察者生命周期页面里如果是动态渲染的列表会有不少新元素不断插入 DOM。每个元素在组件挂载时各自创建 observer 其实开销不大因为 IO 观察的是相交状态不是每个元素一个事件监听器。在 React 里useInView 的 effect 会在每个组件 mount 时创建 observer组件卸载时 disconnect生命周期是安全的。真正要小心的是从列表中删除某个元素后如果没有显式 unobserveobserver 仍然会持有它的引用。在长列表里不断删除、插入累积的引用会导致内存只增不减。所以在自定义指令或 Hook 的清理函数里除了 disconnect最好也把已经加载完毕的元素从各自 observer 里移除。更简单的方式是每个元素只使用一个独立 observer并且确定不会再复用后就调用 disconnect。如果你用虚拟列表那 IO 就不太合适了。虚拟滚动本身已经控制了渲染数量再叠加懒加载意义不大还会因为元素不断被销毁重建而频繁触发 observe/unobserve反而不划算。5. 兼容性、降级方案与实测效果5.1 现在的浏览器支持情况IntersectionObserver 已经是一个成熟的原生 API。主流的 Chrome、Edge、Firefox、Safari 现代版本都支持移动端主流浏览器也基本没问题。具体来说Chrome 51 和 Edge 15 就支持了Firefox 55 开始支持Safari 在 12.1 之后的版本也能用。截至现在做业务除非你维护的是老旧的系统内嵌 WebView否则不需要太担心兼容性。不过“支持”和“表现一致”是两回事。部分旧版本浏览器对rootMargin的支持有一些边界情况而且 iframe 场景下判断逻辑也有差异。我的建议是线上项目先做一次特性检测不支持就走降级路径而不是默认所有用户都能用 IO。特性检测一行代码就行if (IntersectionObserver in window) { // 使用 IO } else { // 降级 }5.2 降级与 Polyfill 方案降级方案要分场景看。如果只是图片懒加载可以先尝试原生loadinglazy。检测方式if (loading in HTMLImageElement.prototype) { // 原生 loadinglazy直接把真实 src 还回去 document.querySelectorAll(img[data-src]).forEach((img) { img.src img.dataset.src; }); }原生 loading 支持 Chrome 和 FirefoxSafari 从 15.4 起也支持了。它虽然不提供精细控制但当 IO 不可用时作为兜底足够。如果项目里大量使用 IO 做组件懒加载、曝光埋点那就需要更完整的降级。可以在不支持 IO 的环境里引入 polyfill把 API 补齐。polyfill 内部会用自己的逻辑模拟相交判断通常还是基于 scroll 节流所以性能不如原生但至少功能可用。还有最后一种是完全自己写回落逻辑本质就是第 1 部分提到的 scroll 判断方案。对我而言能用原生 loading 就用原生不能用就上 polyfill只有对性能和兼容性要求极高的老项目才会手写回落。5.3 我实际跑出来的前后对比我拿一个图片列表页面做了一次简单对比。页面大概 120 张商品图每张图片 200KB 左右。旧方案用 scroll getBoundingClientRect 加节流滚动时主线程经常出现 80ms 到 120ms 的长任务快速滑动时能明显感觉到动画不跟手。改成 IO 方案之后长任务基本降到 50ms 以下其实体感最直观的是页面响应变快滚动时不会再突然卡一下。更值得说的是代码量的变化。旧方案里我需要维护 scroll 监听、首次加载状态、节流函数、已加载标记加起来上百行。换 IO 后核心逻辑只有十几行剩下的交给了浏览器。愿意写 polyfill 的团队还可以把核心业务代码完全统一兼容层放在工具库里业务代码永远写同一套 IO 调用。这组数据不绝对设备、浏览器、图片体积都会影响结果但结论是稳定的在大量节点需要做相交判断的场景里IO 方案对主线程的占用明显更小代码维护成本也更低。6. 常见问题与排查技巧实录6.1 回调不触发先检查这四件事很多人第一次写 IO最常遇到的问题是“observe 了但回调就是不执行”。我总结经验按优先级排查以下四件事第一root和目标元素关系不对。如果指定了 root目标元素必须在 root 的 DOM 子树里而且 root 本身不能隐藏。第二目标元素没有实际渲染盒子比如设置了display: none或者width/height为 0IO 无法计算相交。第三rootMargin或threshold配置让判定条件过于严格比如 threshold 是 1而目标元素永远不可能完全出现在视口里就一直不触发。第四在创建 observer 之前元素已经被移出 DOM或者观察器对象被意外销毁了。还有一个容易忽略的点observe后回调会立即执行一次。如果你在动态创建的组件里 observe但组件还没插入 DOM那这次立即回调可能拿不到预期结果。所以要先确保元素已挂载再 observe这也是 useEffect 和 Vue 的 mounted 钩子里进行 observe 的原因。6.2 内存泄漏观察者忘记解绑IO 造成的性能问题通常不是回调太频繁而是观察器一直持有已经不需要的元素引用。比如在 SPA 中切换页面旧页面的 observer 没有 disconnect那么旧页面里的图片 DOM 仍然被 observer 引用着垃圾回收无法释放。长时间累积下来页面越来越卡。解决办法说简单也简单懒加载完成后对单个元素unobserve组件卸载时对整个 observerdisconnect页面进入后台或销毁时如果有必要也要在pagehide事件里清理。特别是在长列表不断加载更多数据的页面至少要做到“每个元素加载完立即 unobserve”这样 observer 持有的活跃节点数不会无限增长。我踩过最深的坑是在 React 里忘记在 useEffect 的清理函数里 disconnect结果列表页来回切换几次内存占用肉眼可见地涨。加了一行return () observer.disconnect();后问题消失。6.3 threshold 设置不当引起的闪烁与多余回调threshold 的语义是“相交面积比例达到多少时触发回调”。很多人一上来就写threshold: [0, 0.5, 1]本意是想更精确地感知进度但对懒加载来说这是纯浪费。每一次 threshold 跨越都会触发一次回调比如图片从 0.1 进入视口到完全可见可能连续触发好几次而每次回调里都在做同样的事赋值 src、unobserve。多余的回调不会影响最终效果但在短时间内观察大量元素时额外开销会被放大。另外threshold: 1会导致图片必须完整露出才开始加载。如果视口高度较小而图片高度较大用户会看到一大块空白区域好一会儿体验非常糟糕。懒加载场景下threshold 用默认 0 就好顶多 0.1配合一个合适的 rootMargin 足够。6.4 快速滚动时图片请求风暴还有一类问题是 rootMargin 引起的高并发。假设你设了rootMargin: 500px 0px快速滑动一个长页面时可能一瞬间有几十张图片同时进入判定区域浏览器立刻发起几十个请求带宽跑满每一张图反而加载得更慢。解决思路有两种。一种是调小 rootMargin让预加载范围更贴近视口另一种是引入一个简单的并发队列每次只处理一定数量的图片。我之前做过一个简化版的懒加载队列IO 回调里只把进入视口的图片放到待加载数组然后通过递归或计数器控制同时最多加载 4 张加载完再取下一张。复杂页面用队列确实更稳但大部分中小项目不需要rootMargin 控制在 200px 以内就够。最后再分享一个小技巧懒加载图片最好都设置width和height属性或者在 CSS 里固定宽高。这样即使图片还没加载出来占位尺寸也是确定的滚动条高度不会有明显跳动用户不会觉得页面在不停地抖。就算你用 IO 做得再丝滑这一条没做好体感也是假的流畅。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑