1. 从一次首屏性能问题说起为什么图片懒加载值得单独写一篇做前端时间久了你会发现真正影响用户体验的往往不是那些花哨的交互而是最基础的资源加载策略。前阵子我接手一个内容型站点的优化工作首屏加载时间在低端安卓机上直接飙到8秒往上几十张高清大图全部一股脑往下灌光图片就占了页面总资源体积的七成还多。当时第一反应就是先上图片懒加载但真正做起来才明白这个看似简单的优化手段里藏着不少细节写一篇完整的实操笔记很有必要。懒加载的核心思路说白了就一句话图片进入视口附近才开始加载不进入视口就先不加载。它解决的根本问题是网络带宽和渲染线程的浪费。页面顶部的内容用户能看到图片必须立即加载但首屏下方两三个屏幕之外的图用户大概率根本来不及看与其让它们抢占带宽、拖慢关键资源的下载不如先放着等用户滚动到附近再触发。这个思路反过来也成立如果一个页面只有少量图片或者图片本身就是首屏的核心内容那懒加载未必是最优解这是我后面要重点说的一个边界问题。这篇文章适合谁看前端开发、性能优化工程师、以及负责内容站和电商页面的同学。我会把四种主流实现方式逐一拆开讲清楚分析每种方案的原理和适用场景给出一个可以直接抄作业的组件实现然后花一整节讲我在实际项目中踩过的坑和排查过程。这些坑都是我真实遇到过的常规文档里不会写但对读者来说价值可能比代码本身还大。2. 先理解机制图片加载的默认行为与懒加载的三种主流思路2.1 浏览器默认的激进加载策略问题出在哪在没有做任何干预的情况下浏览器对图片的处理非常激进。HTML解析器遇到img标签时会立刻向服务器发起图片请求哪怕这张图位于页面的最底部。页面一次性渲染几十张图就意味着几十个并发请求同时打出去。很多人以为浏览器对同域名的并发连接数有限制就没事了但HTTP/1.1下同一个域名通常只有6个左右的并发连接大量图片请求会让其他关键资源——比如CSS、JS、接口数据——排在后面排队这是首屏变慢的一个重要原因。更隐蔽的问题是流量浪费。我做过一次统计在一个图文列表页里用户平均滚动深度只有40%左右也就是说页面底部超过一半的图片超过六成的用户压根没看到。这些图片仍然全部加载了消耗了用户的移动流量也消耗了服务端的带宽成本。对一个月UV几十万的站点来说这部分的浪费是可以量化成真金白银的。这里说一个容易忽略的点即使图片已经加载完成浏览器依然需要对这些图片进行解码和渲染。大量图片同时进入渲染管线会让低端设备掉帧、卡顿甚至出现滚动时白屏闪烁。懒加载不仅仅是省流量它实际上是让浏览器的工作量跟随用户的真实浏览行为来分布注意力在哪就加载哪这才是性能优化的本质。2.2 方案对比加载属性、滚动监听与观察器目前主流的懒加载方案有三类我整理了一个表格方便对照方案核心机制兼容性推荐场景局限原生 loadinglazy浏览器内核实现Chrome 77、Firefox、Safari 15.4常规图文站点、后台系统低版本浏览器无效果滚动监听 元素位置计算监听scroll事件计算元素与视口的距离全兼容需要精确控制加载时机的场景性能损耗、代码繁琐IntersectionObserver浏览器异步观察元素与视口的交叉状态Chrome 51、Firefox、Safari 12.1大多数现代前端项目老旧浏览器需polyfill先看原生的 loadinglazy。这是最省事的方式给 img 标签加一个属性就能生效浏览器内核自己做判断不消耗JavaScript的运行时性能。它的判定逻辑是图片距离视口还有一定距离时就开始加载具体的提前量由浏览器自己决定开发者无法干预。这个方案最适合对加载时机要求不严格的场景比如内容站的配图、列表页的非核心图片。我在实际项目中通常把它作为基础保底方案。滚动监听方案是前几年最流行的做法。它的逻辑很直观给window绑定scroll事件每次滚动时遍历所有带>// lazy-load.js const DEFAULT_CONFIG { rootMargin: 200px 0px, threshold: 0, placeholderClass: lazy-placeholder }; class LazyLoader { constructor(config {}) { this.config Object.assign({}, DEFAULT_CONFIG, config); this.images []; this.observer null; this.inited false; } init() { if (this.inited) return; this.inited true; if (IntersectionObserver in window) { this.observer new IntersectionObserver((entries) { entries.forEach((entry) { if (entry.isIntersecting) { const img entry.target; this.loadImage(img); this.observer.unobserve(img); } }); }, { rootMargin: this.config.rootMargin, threshold: this.config.threshold }); this.images Array.from(document.querySelectorAll(img.lazy[data-src])); this.images.forEach((img) this.observer.observe(img)); } else { // 低版本浏览器兜底直接全部加载 this.images Array.from(document.querySelectorAll(img.lazy[data-src])); this.images.forEach((img) this.loadImage(img)); } } loadImage(img) { const src img.getAttribute(data-src); if (!src) return; if (img.dataset.loaded) return; img.addEventListener(load, () { img.classList.remove(this.config.placeholderClass); img.dataset.loaded 1; img.removeAttribute(data-src); }); img.addEventListener(error, () { // 加载失败时去掉占位类避免一直显示灰块 img.classList.remove(this.config.placeholderClass); img.dataset.loaded 1; img.dataset.error 1; img.removeAttribute(data-src); }); img.src src; } // SPA场景下动态插入的图片需要手动触发重新扫描 refresh() { if (!this.inited) this.init(); if (!this.observer) return; const newImages Array.from(document.querySelectorAll(img.lazy[data-src])); newImages.forEach((img) { if (!this.observer || !this._observed.has(img)) { this.observer.observe(img); } }); } } const lazyLoader new LazyLoader(); export default lazyLoader;3.2 逐块拆解为什么每个细节都要这么写上面这段代码有几个关键点需要单独说明不解释清楚你可能抄过去了也不知道为什么。第一启动前必须检查 IntersectionObserver 是否存在。虽然现代浏览器已经普遍支持但在某些企业内部的旧浏览器环境里仍然没有这个API。我在代码里做了降级处理不支持就直接把>img classlazy srcdata:image/svgxml,%3Csvg xmlnshttp://www.w3.org/2000/svg viewBox0 0 400 300%3E%3C/svg%3E >img.lazy { background-color: #f0f0f0; opacity: 0; transition: opacity 0.3s ease; } img.lazy.loaded { opacity: 1; }注意这里我用了 opacity 渐变但上面的 JavaScript 里加的是移除 placeholderClass并没有加 loaded 类。实际项目中可以在 load 事件里同时加上 loaded 类实现图片加载完成后的淡入效果。这种视觉上的平滑过渡虽然看起来是小事但对图文类站点的观感提升是很明显的。3.4 防抖与节流滚动方案的遗留问题 vs 观察器的天然优势如果你还是在用旧的滚动监听方案防抖和节流是绕不开的话题。核心矛盾在于scroll 事件触发频率极高而懒加载判断逻辑通常只关心滚动停下来附近的状态。节流throttle保证每100ms只执行一次判断逻辑防抖debounce则等滚动停止后150ms再执行最后一次判断。这两种写法在技术社区里都有大量示例但我这里不给代码了原因很简单有了IntersectionObserver之后这些复杂度全部消失了。观察器方案里浏览器内核自己决定何时检测交叉状态页面滚动时JavaScript完全没有额外开销。你不需要关心节流的时间窗口应该设多少也不用担心用户在快速滚动时判断逻辑来不及执行导致图片迟迟不加载。从工程角度看使用观察器相当于把性能优化的实现复杂度交给了浏览器我们只负责配置策略。这不仅仅是代码量减少的问题更是心智负担的减轻代码里少了一些状态就少了一些出错的可能。4. 踩过的坑与完整排查链路从图片不触发到重复加载4.1 坑一SPA路由切换后新图片没有绑定观察器在我负责的一个基于前端路由框架的项目里页面切换时容器内的DOM会被整体替换。最开始接入懒加载组件时只在页面初始化时调用了一次 init()。结果发现一个奇怪的现象首页的图片懒加载正常但跳转到列表页后再回来新渲染出来的图片全部直接显示了懒加载失效。排查过程是这样的。先看DOM结构发现新图片仍然带有 classlazy 和>img classlazy srcdata:image/svgxml,... >