ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

IntersectionObserver实战:从原理到封装的懒加载性能优化方案

IntersectionObserver实战:从原理到封装的懒加载性能优化方案 1. 为什么我最终选定了 IntersectionObserver 作为懒加载方案做前端开发这些年懒加载是绕不开的一个话题。不管你是做电商页面、资讯流、图片画廊还是做后台管理系统的长列表只要页面上有大量图片、iframe、视频或者复杂组件懒加载基本是标配。以前我们做懒加载最常用的方案是监听 scroll 事件在滚动回调里用getBoundingClientRect()判断元素是否进入视口然后手动去替换 src 或者触发渲染。这个方案在早期确实够用但随着页面复杂度上升它的问题越来越明显。先说一个很多教程不会告诉你的点scroll 监听 getBoundingClientRect 的方案其实每一次滚动触发都会强制浏览器进行一次完整的布局计算。getBoundingClientRect()这个 API 会触发 reflow如果你在滚动回调里同时判断几十个、上百个元素性能消耗是非常大的。即便加了 throttle 或 rAFrequestAnimationFrame优化也只是把触发频率降下来并没有从本质上解决“滚动时反复调用同步布局 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或者宽高为 0observer 回调就永远不触发。还有在部分版本的 Safari 里root元素的overflow值必须是scroll或auto交叉计算才生效。具体来说如果容器是overflow: hiddenIntersectionObserver 在 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然后对整个容器内所有带>
返回列表