我做过不少前端性能优化图片懒加载算是最常见、也最容易出效果的一项改动。很多项目首屏图片十几张全部加载完要等好几秒用户早滑走了。把图片懒加载落地之后页面首屏耗时能降下来一大截尤其是长列表、电商商品页、内容社区这类图片密集的场景效果明显得让人有点意外。这篇文章不打算讲那种“调用一个库就完事”的流程而是把图片懒加载从原理到生产级的实现细节拆开讲一遍。包括我为什么最终选了 Intersection Observer 方案、原生 loading 属性到底够不够用、怎么处理布局抖动、怎么跟 CDN 和图片裁剪服务搭配还有线上踩过的几个坑和排查套路。适合正在做前端性能优化、或者想搞懂懒加载背后原理的开发者参考。大佬可以重点看后两章的细节取舍新手建议从头到尾顺着走一遍。1. 图片懒加载到底在解决什么问题1.1 从一次页面卡顿说起之前接手过一个内容资讯站点的改版列表页单页有接近 40 张配图每张图都是 1200px 宽的 JPEG大小在 200KB 到 600KB 之间。首屏加载的时候浏览器一股脑把全部图片都请求了光图片传输就差不多 10MB。弱网环境下页面要等很久才能稳定响应滚动的时候还会出现图片逐个加载导致的明显卡顿。当时盯着监控面板发现首屏吞吐量高得离谱而用户真正看到的只有前两三张图的区域后面三十多张图全部是白白浪费掉的流量。这就是图片懒加载要解决的第一个问题没进入视口的图片本来就不该被请求。用户想看哪一块内容浏览器再动态去加载那部分的图片资源。它不是一个炫技功能而是对网络资源和用户流量的基本尊重。1.2 懒加载的核心矛盾与收益懒加载的本质是把“加载时机”从页面初始化转移到“元素接近视口时”。这里有一个提前量的取舍加载太早会白白占用网络带宽加载太晚用户滚动到图片位置时会出现白屏等待体验变差。所以判断“什么时候开始加载”就成了所有方案的核心矛盾。实操后收益可以从几个维度量化首屏图片请求数减少页面 load 事件和 LCP 指标都有改善。总传输体积下降对弱网用户特别友好也能省 CDN 流量费用。滚动过程中如果配合占位比例能消除图片加载引起的布局偏移CLS 会更稳。如果站点本身图片不多、都是首屏必须展示的核心图懒加载收益不大还可能因为判断逻辑增加额外复杂度。所以在给项目做优化前我会先看一眼页面结构图片分布远离首屏、数量大于 10 张的懒加载基本稳赚。1.3 懒加载与响应式、占位图之间的边界懒加载经常和响应式图片、占位图混在一起说但它们解决的是不同问题。响应式图片解决的是“在不同屏幕尺寸下选哪张图”的问题懒加载解决的是“什么时候加载”的问题占位图解决的是“加载过程中给用户看什么”的问题。一个完整方案通常三件事都做先用srcset和sizes让浏览器按视口宽度选图再通过懒加载把真正选图的时机挪到即将进入视口时同时用 CSS 比例占位避免内容跳动。我在项目里见过不少只做了懒加载、却忽略了占位比例的页面。图片加载完成那一刻高度从 0 撑开下面的内容被挤下去用户鼠标点到的位置也跟着变了。这种情况在手机端尤其明显。所以做懒加载不能只盯着“省流量”还得把“加载后的形态稳定”一并解决掉。2. 实现方案选型与原理拆解2.1 原生 loadinglazy首选但不万能HTML 标准里直接提供了图片懒加载属性img loadinglazy。浏览器看到这个属性后会自动判断图片距离视口的远近在适当时候加载。好处是零依赖、零 JavaScript浏览器内部实现通常还做了预加载距离优化不会出现图片快进入视口时才紧急请求的问题。但原生属性也有一些限制。一是控制力度很弱你没法自定义提前加载的阈值也没法在图片即将进入视口前做自定义占位动画二是部分旧版本浏览器不识别这个属性会直接忽略图片照常加载这在兼容性要求高的对内系统里需要谨慎三是它不太方便接“加载失败重试”这类业务逻辑毕竟浏览器不会给你暴露详细的加载状态事件。所以我现在的基本选型规则是如果项目是纯内容展示页、兼容性要求不高直接用loadinglazy最省事如果项目需要统一体验、埋点统计、失败重试或者要兼容到较老的内核浏览器就自己用 Intersection Observer 实现。2.2 Intersection Observer现代主流方案IntersectionObserver让“元素是否进入视口”的监听变得非常高效。老方案是用 scroll 事件配合getBoundingClientRect()计算距离但滚动事件触发频率极高每次都做几何计算会有不必要的性能开销。Intersection Observer 由浏览器原生实现采用异步回调机制在元素可见性变化时再通知开发者性能表现明显更好。核心思路是先观察所有带有>div classlazy-image styleaspect-ratio: 16 / 9; img classlazy-image__img >const lazyImages document.querySelectorAll(img[data-src]); const imageObserver new IntersectionObserver((entries, observer) { entries.forEach((entry) { if (entry.isIntersecting) { const img entry.target; img.src img.dataset.src; img.classList.add(is-loaded); observer.unobserve(img); } }); }, { rootMargin: 200px 0px, threshold: 0.01 }); lazyImages.forEach((img) imageObserver.observe(img));rootMargin: 200px 0px表示在视口上下各扩展 200px 作为触发区域相当于提前预测用户滚动方向让图片在即将进入视口前就开始加载用户实际看到时已经加载完成或接近完成。阈值0.01表示元素只要有 1% 进入这个扩展区域就触发这样不会要求图片完整露出来才加载体验更顺滑。这里有一个容易忽略的点threshold和rootMargin搭配时如果threshold设置过大比如0.5图片必须一半区域可见才触发明显太晚设置成0.01基本等效于“开始进入即触发”。3.3 响应式图片data-srcset 与>img classlazy-image__img >if (entry.isIntersecting) { const img entry.target; img.src img.dataset.src; if (img.dataset.srcset) { img.srcset img.dataset.srcset; } if (img.dataset.sizes) { img.sizes img.dataset.sizes; } img.classList.add(is-loaded); observer.unobserve(img); }这样浏览器会在图片真正开始加载时结合当前视口宽度和sizes规则自主选择合适的候选图既不额外增加请求也不会选错分辨率。需要注意>function loadImage(img, retryCount 0) { return new Promise((resolve, reject) { const handleLoad () { img.removeEventListener(load, handleLoad); resolve(); }; const handleError (e) { img.removeEventListener(error, handleError); reject(e); }; img.addEventListener(load, handleLoad); img.addEventListener(error, handleError); img.src img.dataset.src; }); } async function handleLoad(img, observer, retryCount 0) { try { await loadImage(img); img.classList.add(is-loaded); observer.unobserve(img); } catch (err) { if (retryCount 2) { setTimeout(() handleLoad(img, observer, retryCount 1), 1500); } else { img.classList.add(is-error); // 这里可以替换为自定义错误占位图 } } }重试间隔 1.5 秒最多重试 2 次避免无限重试对 CDN 造成额外压力。在错误占位图上可以直接把src替换成一个内置的 SVG 错误提示图或者显示一个相近的底色图标不要让用户看到一片空白。这个细节虽然小但对内容站点的观感影响很大。还有性能细节懒加载的 Observer 实例最好全局只创建一个不要每个图片创建一个实例。图片数量多时可以分组观察但全局单例完全够用。也别忘了在所有图片加载完成后主动disconnect()释放监听资源。3.5 封装成通用组件的扩展设计组件化时我会把懒加载能力封装成一个工厂函数或类支持配置项方便多个页面复用class LazyLoader { constructor(options {}) { this.options { root: null, rootMargin: 200px 0px, threshold: 0.01, selector: img[data-src], placeholderClass: is-loading, loadedClass: is-loaded, errorClass: is-error, ...options }; this.observer null; this.images []; } init() { this.images Array.from(document.querySelectorAll(this.options.selector)); if (IntersectionObserver in window) { this.observer new IntersectionObserver(this.onIntersect.bind(this), { root: this.options.root, rootMargin: this.options.rootMargin, threshold: this.options.threshold }); this.images.forEach((img) this.observer.observe(img)); } else { this.fallbackBindScroll(); } } onIntersect(entries, observer) { entries.forEach((entry) { if (entry.isIntersecting) { this.handleLoad(entry.target, observer); } }); } handleLoad(img, observer) { // 实际加载逻辑见上一小节 } fallbackBindScroll() { // 降级为滚动监听 } } const loader new LazyLoader(); loader.init();高内聚、低耦合的组件看起来简单但换页复用、埋点接入都会方便很多。比如我常在handleLoad里触发一个自定义事件让业务方自己决定要不要上报图片加载数据。这样组件本身不关心业务只关心加载行为后续维护成本显著降低。4. 加载占位、骨架屏与体验细节4.1 用 CSS 比例占位防止布局抖动前面提到用aspect-ratio或padding-bottom占位但真正的细节在于如何让占位和图片加载状态平滑衔接。最直接的做法是给图片加opacity过渡加载完成后再渐变显示.lazy-image { position: relative; background: #f0f0f0; overflow: hidden; } .lazy-image__img { opacity: 0; transition: opacity 0.3s ease; } .lazy-image__img.is-loaded { opacity: 1; }图片没加载前容器背景是浅灰高度按比例占住页面不会跳动加载完成后图片淡入视觉上很顺滑。这个方案比直接display: none到block的切换体验好得多。要注意background最好也用一个和图片主色调接近的颜色这样占位灰不会显得很突兀。对于不规则的瀑布流布局比例占位更复杂一些需要提前知道图片宽高比或者在服务端生成图片元数据并注入到模板里。我的经验是优先保证容器宽度确定高度用aspect-ratio直接由 CSS 计算避免 JS 参与测量减少布局抖动来源。4.2 渐进式加载与模糊预览比纯灰占位更高阶一点的方案是“模糊预览”。思路是先加载一张极小的压缩图通常几 KB铺在容器上并打上模糊滤镜等原图加载完成后再让高清图浮在它的上面。实现起来并不复杂img classlazy-image__thumb>const container document.querySelector(.scroll-list); const observer new IntersectionObserver(callback, { root: container, rootMargin: 200px 0px, threshold: 0.01 });这里有个关键前提滚动容器必须真的存在独立的滚动上下文并且容器尺寸变化时 observer 能正确计算交叉区域。若容器内的图片高度未定导致观察时机异常需要先保证占位高度设置完毕再初始化 observer。CLSCumulative Layout Shift是懒加载改造中必须关注的指标。很多改造后图片加载都正常但 CLS 反而变差原因就是占位缺失。在改造前后对比监控时我会同时对比 CLS 和 LCP。这两个指标一个管视觉稳定性一个管首屏加载速度懒加载做得好两者应该一起变好或至少不变差。4.4 量化收益怎么证明这次优化有效做性能优化最怕“感觉变快了”要有数据支撑。我在项目里会在同一个页面测试三种状态全部图片直接加载、原生loadinglazy、自研 Intersection Observer 懒加载。分别记录首屏网络请求数总传输体积LCP 时间滚动流畅度通过长任务统计CLS 数值一个具体案例是某资讯页改造后首屏请求数从 42 降到 16总体积从 9.8MB 降到 3.2MB弱网下的 LCP 从 3.8 秒降到 1.9 秒。这个数据让我有信心继续推广到更多页面。建议在博客或述职里展示这种对比数据比空谈“性能更好”更有说服力。5. 常见问题与排查技巧实录5.1 图片不触发加载定位思路最典型的故障是页面滚动到图片位置但图片始终不加载控制台里看不到请求。第一反应先确认图片元素是否真的被 observer 观察到了。可以在回调里临时打印entry.isIntersecting和图片的边界信息。常见原因有几个图片在初始化时display: noneobserver 无法正确计算交叉区域等它变为可见时也不会自动触发。图片元素被某个overflow: hidden的不可见容器遮挡或者祖先元素高度为 0。图片已经加载过一次但被unobserve后续重新操作 DOM 导致元素副本没有被正确观察。rootMargin设置不当比如设成了-200px导致图片必须完全进入视口后才触发。排查时我会用开发者工具的 Elements 面板选中图片查看其src和>