☰
IntersectionObserver实战:从原理到封装的懒加载性能优化方案
2026/10/6 3:14:19 网站建设 项目流程

1. 为什么我最终选定了 IntersectionObserver 作为懒加载方案

做前端开发这些年,懒加载是绕不开的一个话题。不管你是做电商页面、资讯流、图片画廊,还是做后台管理系统的长列表,只要页面上有大量图片、iframe、视频或者复杂组件,懒加载基本是标配。以前我们做懒加载,最常用的方案是监听 scroll 事件,在滚动回调里用getBoundingClientRect()判断元素是否进入视口,然后手动去替换 src 或者触发渲染。这个方案在早期确实够用,但随着页面复杂度上升,它的问题越来越明显。

先说一个很多教程不会告诉你的点:scroll 监听 + getBoundingClientRect 的方案,其实每一次滚动触发都会强制浏览器进行一次完整的布局计算。getBoundingClientRect()这个 API 会触发 reflow,如果你在滚动回调里同时判断几十个、上百个元素,性能消耗是非常大的。即便加了 throttle 或 rAF(requestAnimationFrame)优化,也只是把触发频率降下来,并没有从本质上解决“滚动时反复调用同步布局 API”的问题。

后来我接触到了 IntersectionObserver,试用了一段时间之后,基本就把旧方案彻底替换掉了。这个 API 的核心思路非常巧妙,它不再让前端开发者在滚动回调里自己计算元素位置,而是把“元素是否进入视口”的判断交给了浏览器内核。浏览器会在自己的渲染流程里异步检测目标元素与根元素(默认是视口)的交叉状态,状态变化时直接回调通知你。这种机制天然避免了同步布局抖动,也不会被高频滚动事件反复触发导致 CPU 飙高。

关于兼容性,大家比较关心的IntersectionObserver在 Chrome、Firefox、Edge 这些主流浏览器里早就默认支持了。Safari 从 12.1 开始也支持,iOS Safari 同理。这意味着在绝大多数真实业务场景里,我们根本不需要 polyfill 就能直接用。互联网项目面对的用户环境比较复杂,个别老浏览器可能不支持,这个我在后面会专门讲降级策略。

我个人的体会是,懒加载这事看起来简单,但真正落地的时候会出现各种奇奇怪怪的问题:图片加载失败如何处理、刷太快导致用户还没看到图就被加载了、列表 item 被销毁后 observer 有没有释放、弹窗里懒加载怎么判断可视区域、嵌套滚动容器里为什么一直不触发、微前端下多个实例会不会相互干扰……这都是实战里会踩到的。用 IntersectionObserver 之后,大部分“判断类”的问题迎刃而解,剩下的主要是“工程化”层面的问题。这篇文章我想把整个技术方案和实战经验完整地拆一遍,从 API 细节到代码封装、从性能优化到问题排查,以及不同框架下的落地实践,都做一个全面的整理。

2. IntersectionObserver 官方 API 的细节,以及两个最容易忽略的设计

2.1 基本用法与回调机制

先简单看下 IntersectionObserver 最基础的用法,确保没有任何基础的同学也能跟上。

const observer = new IntersectionObserver((entries, observer) => { entries.forEach(entry => { if (entry.isIntersecting) { // 元素进入了视口 loadImage(entry.target); // 加载完成后取消观察 observer.unobserve(entry.target); } }); }, { root: null, rootMargin: '0px', threshold: 0.1 }); const imgList = document.querySelectorAll('img[data-src]'); imgList.forEach(img => observer.observe(img));

这里最核心的是回调里的entries数组。很多人第一次用的时候会困惑:为什么一次回调会返回多个 entry?其实是因为浏览器是批量处理观察目标的,同一帧内发生交叉状态变化的多个元素,会打包在同一个回调队列里统一触发。这种批量机制本身也是一种性能优化,避免了每个元素单独触发回调造成的多次消息循环。

entry.isIntersecting是布尔值,表示当前是否处于交叉状态。回调既会在元素进入视口时触发,也会在元素完全离开视口时触发,所以不要只判断交叉了就加载,还要判断离开时是否需要做回收或暂停操作。不过对懒加载场景来说,我们的目标是把未进入视口的资源延后加载,一旦进入视口并且加载完成,就可以用unobserve解除观察,避免后续无意义的回调计算。

还有entry.intersectionRatio,表示元素有多大比例进入了视口。这个属性在你设置了threshold为一个精确值时很有用。比如你设置threshold: 0.5,那么只有当元素有一半进入视口时回调才会触发。不过对图片懒加载来说,一般没必要等 50% 进入才加载,10% 或者干脆 0 也可以,具体看你业务上对“出现在屏幕里”的定义。

2.2 rootMargin 不只是提前加载,它还承担了“预判”的功能

rootMargin的作用和 CSS 的 margin 类似,但它实际影响的是根元素的判定区域。默认是'0px',也就是根元素自身的大小。如果你设置rootMargin: '100px',相当于把视口的判定范围向外扩展了 100px,也就是说元素距离视口边缘 100px 时,就已经算作“进入”了。

这个特性对懒加载非常实用。我们在移动端经常遇到页面滚得很快的情况,如果等图片真的进入视口才开始加载,网络慢的时候用户会明显看到占位图闪一下、然后图片慢慢刷出来的过程,体验很差。利用 rootMargin 提前几百像素加载,相当于给资源加载留出了“缓冲期”。我实际项目里用在移动端 H5 上的一个标准配置是rootMargin: '200px 0px',也就是上下各提前 200px 开始加载。在电商场景的首屏图片瀑布流里,这个值还可以调大,比如 600px 甚至 800px,具体看页面滚动速度和图片体积。

但注意 rootMargin 不是越大越好。提前太多意味着所有图片几乎同时开始加载,懒加载的“延迟”意义就变弱了,反而可能在页面初始化时产生大量并发请求,占满浏览器连接池。这里有一个经验值区间,一般首屏下方的图片用 200px 到 600px 之间,具体需要结合图片大小、用户滚动速度、网络环境来做权衡。

2.3 threshold 参数:是判断时机精细度,不是负担

threshold可以是 0 到 1 之间的任意值,也可以是一个数组,比如[0, 0.25, 0.5, 1]。它表示元素与根元素交叉面积比例达到指定值时触发回调。threshold 数组传得越多,回调触发频率越高,因为元素进入过程中会有多个比例节点。普通懒加载传0就足够了。为什么是 0?因为浏览器判断交叉比例大于 0 时,只要元素边缘进入视口哪怕 1px,就会触发回调,这对图片懒加载来说完全够用。

有一个特定场景建议把 threshold 调高:如果懒加载的是一个需要完整可见才能开始播的视频或者一个复杂组件,你希望它完整进入视口再加载,那 threshold 可以设成0.5甚至更高。不过大多数时候,尤其是图片懒加载,threshold 保持为 0 或 0.1 都可以,回调里判断isIntersecting为 true 即可。

2.4 root 参数:默认视口之外,嵌套滚动容器也很有效

root默认是null,代表浏览器视口。如果你的页面里有一个独立的滚动容器,比如某个 div 设置了overflow-y: auto,那么懒加载判断应该以这个容器为根,而不是浏览器的视口。举个例子,后台管理系统里常见的左侧导航 + 右侧内容区,右侧内容区内部滚动,这时图片懒加载就要把 root 指向内容区这个 div。

const container = document.querySelector('.content-scroll-area'); const observer = new IntersectionObserver(callback, { root: container, rootMargin: '100px', });

这里有一个我在实际开发中踩过的坑:如果 root 元素没有正确的定位或尺寸,某些浏览器下交叉计算可能会不准确。例如容器本身是display: none,或者宽高为 0,observer 回调就永远不触发。还有,在部分版本的 Safari 里,root元素的overflow值必须是scroll或auto,交叉计算才生效。具体来说,如果容器是overflow: hidden,IntersectionObserver 在 Safari 里可以正常用吗?我在 iOS Safari 上实测下来,这个场景的表现确实有差异,检测区域计算有时不准确,所以如果你要在一个只隐藏溢出、但实际并不滚动的容器里做懒加载,建议还是用视口作为 root,把元素的位置换算到视口维度来考虑。如果你确实要监听 overflow: hidden 的容器,测试时尤其要在真实机型上做验证。

3. 从一个最简单但完整的懒加载封装说起,兼容、性能与工程化

3.1 基础封装:引入占位、data-src、状态防抖和错误处理

先放一个我在实际业务里常用的懒加载封装,代码量不大,但已经把常见的工程问题都考虑进去了。细节上我会逐段解释。

class LazyLoader { constructor(options = {}) { this.options = { root: options.root || null, rootMargin: options.rootMargin || '200px 0px', threshold: options.threshold || 0, placeholder: options.placeholder || '', errorPlaceholder: options.errorPlaceholder || '', once: options.once !== false, beforeLoad: options.beforeLoad || null, afterLoad: options.afterLoad || null, onError: options.onError || null, }; this.observer = null; this.cache = new WeakMap(); this.init(); } init() { if (typeof IntersectionObserver === 'undefined') { // 降级:直接加载所有资源 return; } this.observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (!entry.isIntersecting) return; this.load(entry.target); if (this.options.once) this.observer.unobserve(entry.target); }); }, { root: this.options.root, rootMargin: this.options.rootMargin, threshold: this.options.threshold, }); } add(el) { if (typeof IntersectionObserver === 'undefined') { this.load(el); return; } if (!el || el.nodeType !== 1) return; if (this.cache.has(el)) return; this.observer.observe(el); this.cache.set(el, { loaded: false, loading: false }); } load(el) { const state = this.cache.get(el); if (!state || state.loaded || state.loading) return; state.loading = true; if (this.options.beforeLoad) this.options.beforeLoad(el); let src = el.dataset.src || el.dataset.srcset; if (!src) return; const finish = () => { state.loaded = true; state.loading = false; if (this.options.afterLoad) this.options.afterLoad(el); }; const fail = (e) => { state.loading = false; if (this.options.onError) this.options.onError(e, el); }; if (el.tagName === 'IMG') { let img = new Image(); img.onload = () => { el.src = src; if (el.dataset.srcset) { el.srcset = el.dataset.srcset; } el.classList.add('is-loaded'); finish(); }; img.onerror = fail; img.src = src; } else if (el.tagName === 'IFRAME' || el.tagName === 'VIDEO') { el.src = src; el.addEventListener('load', finish, { once: true }); el.addEventListener('error', fail, { once: true }); } } destroy() { if (this.observer) { this.observer.disconnect(); this.observer = null; } this.cache = undefined; } }

这段代码看着不长,但有几个细节我想单独拿出来说。

第一个细节是WeakMap缓存。用 WeakMap 而不是普通对象来存每个元素的状态,可以避免强引用导致的内存泄漏,尤其是列表数据更新后 DOM 被移除但缓存仍然存在的情况。WeakMap的 key 是元素对象,Value 里放着 loaded 和 loading 两个开关,防止同一个元素被重复加载。为啥这个状态很重要?因为在实际场景里,尤其是组件框架下,元素进视口触发 load,滚动离开再滚回来又触发 load,如果没有一次性解除观察 (once),就会造成重复赋 src 甚至重复发请求。线上一个很常见的 bug 就是图片无限闪烁,原因就在于没有做忽略判断。

第二个细节是图片加载方式。我没有直接给 img 元素的 src 赋值,而是先 new 一个 Image 对象,等 onload 之后再赋给实际 DOM。这么做的好处是避免直接把加载失败的裂图图渲染到页面上。用 Image 对象预加载,onerror 时我们可以决定是替换成占位图还是保持原样。另外现在很多设计系统都用 srcset 做响应式图片,我把对 dataset.srcset 的处理也加进去了。

第三个细节是兼容降级。IntersectionObserver不存在的浏览器环境,直接调用load(el)让所有资源立即加载。这样在功能上不会缺失,只是失去了懒加载的性能优势。这是判断“渐进增强”和“优雅降级”的典型场景。

3.2 可选的“即将懒加载”的占位效果与骨架屏结合

在实际项目中,如果只是把图片 src 留空,用户会看到一大块空白区域,滚动时体验很差。所以我习惯配合占位背景色或骨架屏一起使用。最省事的办法是给 img 元素设置一个 base64 的极小占位图,或者干脆用 CSS background 设置浅灰色背景。懒加载完成后,class 添加is-loaded,通过 CSS 过渡让图片透明度从 0 到 1,避免生硬的闪变效果。

还有一点需要提醒:懒加载千万不能用一张很大的 loading 动画 GIF 作为占位背景。我在项目里见过有人给所有懒加载图配了一个很大的 loading spinner 动图,结果是这几十个加载动画本身的体积加起来比真正图片还大,反而拖慢了页面。占位背景尽量用纯 CSS 或极小体积的 base64 图形来实现。CSS 上实现骨架屏也简单,只要在图片块上加一个渐变动画就好了,这里不多说,重点是别让“懒加载”的辅助资源变成负担。

3.3 单独处理背景图的懒加载,data-src 与 style 的解析

懒加载并不只适用于 img 标签,很多时候我们用 CSS 背景图来做 banner 和按钮图标。背景图存在 style 属性或者 class 样式里,IntersectionObserver 本身是不关心资源类型的,它只负责“告诉我元素什么时候进入视口”,具体怎么加载资源还是我们自己控制。所以背景图懒加载的思路是一样的:观察元素 -> 进入视口 -> 把背景图片地址从>const lazyBgElements = document.querySelectorAll('[data-bg]'); lazyBgElements.forEach(el => { observer.observe(el); }); // 在 observer 回调里调用 loadBackground function loadBackground(el) { if (el.dataset.loaded) return; const url = el.dataset.bg; if (url) { el.style.backgroundImage = `url('${url}')`; el.dataset.loaded = 'true'; el.classList.add('is-loaded'); } }

4. 在 Vue、React 里落地,以及列表场景的常见坑

4.1 React 封装:useRef + 自定义 Hook 的完整思路

React 函数组件里,我比较推荐把懒加载能力封装成一个自定义 Hook,比如useLazyLoad(ref, options)。这个 Hook 接收一个容器 ref,然后对整个容器内所有带>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询