1. 从一次首屏性能问题说起:为什么图片懒加载值得单独写一篇
做前端时间久了你会发现,真正影响用户体验的往往不是那些花哨的交互,而是最基础的资源加载策略。前阵子我接手一个内容型站点的优化工作,首屏加载时间在低端安卓机上直接飙到8秒往上,几十张高清大图全部一股脑往下灌,光图片就占了页面总资源体积的七成还多。当时第一反应就是先上图片懒加载,但真正做起来才明白,这个看似简单的优化手段里藏着不少细节,写一篇完整的实操笔记很有必要。
懒加载的核心思路说白了就一句话:图片进入视口附近才开始加载,不进入视口就先不加载。它解决的根本问题是网络带宽和渲染线程的浪费。页面顶部的内容用户能看到,图片必须立即加载;但首屏下方两三个屏幕之外的图,用户大概率根本来不及看,与其让它们抢占带宽、拖慢关键资源的下载,不如先放着,等用户滚动到附近再触发。这个思路反过来也成立:如果一个页面只有少量图片,或者图片本身就是首屏的核心内容,那懒加载未必是最优解,这是我后面要重点说的一个边界问题。
这篇文章适合谁看?前端开发、性能优化工程师、以及负责内容站和电商页面的同学。我会把四种主流实现方式逐一拆开讲清楚,分析每种方案的原理和适用场景,给出一个可以直接抄作业的组件实现,然后花一整节讲我在实际项目中踩过的坑和排查过程。这些坑都是我真实遇到过的,常规文档里不会写,但对读者来说价值可能比代码本身还大。
2. 先理解机制:图片加载的默认行为与懒加载的三种主流思路
2.1 浏览器默认的激进加载策略,问题出在哪
在没有做任何干预的情况下,浏览器对图片的处理非常"激进"。HTML解析器遇到<img>标签时,会立刻向服务器发起图片请求,哪怕这张图位于页面的最底部。页面一次性渲染几十张图,就意味着几十个并发请求同时打出去。很多人以为浏览器对同域名的并发连接数有限制就没事了,但HTTP/1.1下同一个域名通常只有6个左右的并发连接,大量图片请求会让其他关键资源——比如CSS、JS、接口数据——排在后面排队,这是首屏变慢的一个重要原因。
更隐蔽的问题是流量浪费。我做过一次统计,在一个图文列表页里,用户平均滚动深度只有40%左右,也就是说页面底部超过一半的图片,超过六成的用户压根没看到。这些图片仍然全部加载了,消耗了用户的移动流量,也消耗了服务端的带宽成本。对一个月UV几十万的站点来说,这部分的浪费是可以量化成真金白银的。
这里说一个容易忽略的点:即使图片已经加载完成,浏览器依然需要对这些图片进行解码和渲染。大量图片同时进入渲染管线,会让低端设备掉帧、卡顿,甚至出现滚动时白屏闪烁。懒加载不仅仅是省流量,它实际上是让浏览器的工作量跟随用户的真实浏览行为来分布,注意力在哪就加载哪,这才是性能优化的本质。
2.2 方案对比:加载属性、滚动监听与观察器
目前主流的懒加载方案有三类,我整理了一个表格方便对照:
| 方案 | 核心机制 | 兼容性 | 推荐场景 | 局限 |
|---|---|---|---|---|
| 原生 loading="lazy" | 浏览器内核实现 | Chrome 77+、Firefox、Safari 15.4+ | 常规图文站点、后台系统 | 低版本浏览器无效果 |
| 滚动监听 + 元素位置计算 | 监听scroll事件,计算元素与视口的距离 | 全兼容 | 需要精确控制加载时机的场景 | 性能损耗、代码繁琐 |
| IntersectionObserver | 浏览器异步观察元素与视口的交叉状态 | Chrome 51+、Firefox、Safari 12.1+ | 大多数现代前端项目 | 老旧浏览器需polyfill |
先看原生的 loading="lazy"。这是最省事的方式,给 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 class="lazy" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 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结构,发现新图片仍然带有 class="lazy" 和><img class="lazy" src="data:image/svg+xml,..." >