接到一个还算齐全的个人站改版需求,别的都还好,就是首页那张 2MB 的产品大图直接把 Lighthouse 性能分摁在地上摩擦。我把图压到 800KB 还是慢,后来装上懒加载,首屏体积直接砍掉一大半。这几年看不少前端小白最怕碰性能优化,一上来就对着 Webpack、CDN、缓存一堆名词发怵,其实图片懒加载这关过了,网页“秒开”这件事就算入门了。
这篇文章不绕弯子,直接讲清楚图片懒加载到底在解决什么问题、最省事的现代浏览器原生方案怎么用、什么时候必须上 JavaScript 的 IntersectionObserver,以及真正会坑到你的那些细节点。全程用真实可跑的代码和踩坑记录说话,适合刚接触前端、被图片加载速度折磨过、以及想在项目里落地性能优化的同学照着抄。
1. 懒加载解决的远不止“加载速度”这一个问题
1.1 一个反直觉的结论:图片加载慢,不代表图片必须小
很多人以为页面卡是因为图片体积太大,于是疯狂压缩。压缩确实有用,但有一个更矛盾的情况:哪怕你已经把图片压到很小,只要页面里一次塞了 50 张图,这些请求依然会挤在首屏加载阶段,把带宽、连接数、主线程时间全部占满。
懒加载的思路是:不在首屏的图片,先不发请求。等用户滚动到它附近了,再真正去加载。这个逻辑有点像去自助餐厅吃饭,你不会一次性把所有想吃的菜全端到桌上,通常是先拿眼前这一盘,吃完了再去拿下一盘。浏览器加载图片也一样——用户此刻看不到的区域,凭什么要吃网络资源?
我这边实际统计过,一个 30 张图片的列表页,图片总大小约 15MB。启用懒加载之后,首屏只请求了 3 张图,大约 1.2MB 流量,时间从 6.8s 降到 1.9s。这个对比比任何压缩算法都来得猛,因为它是直接从“请求数量”这个维度上做减法。
1.2 懒加载帮你躲开的三类真实问题
除了加载速度,懒加载还顺带解决了好几件容易被忽视的事:
第一,移动端流量消耗。用户的流量套餐经不起一次打开页面就悄悄下了几十张图。尤其在一些弱网环境,图片请求发出去之后半天不回来,用户手指一划,页面半天没反应,体验直接崩。
第二,并发请求导致的串行阻塞。浏览器对同一个域名的并发连接数有限制,桌面端一般是 6~8 个。图片请求一口气全发出去,会把后面的脚本、样式请求全堵在后面。不夸张地说,我见过某个页面因为一张超时图片,把整个首屏 JavaScript 的执行时间拖慢了 2 秒。
第三,页面滚动时的卡顿。大量图片一起解码、绘制,GPU 和内存压力会非常明显,尤其是在中低端手机上。你可以做个简单实验:把一个图片站的所有 img 标签都加上 src,然后用 Chrome DevTools 的 Performance 录一段滚动操作,大概率会看到渲染帧率掉到 20fps 以下。懒加载让浏览器只在需要的时候解码图片,滚动自然顺滑得多。
1.3 给小白的前置知识:一张图的加载过程到底发生了什么
写代码之前,我建议你先理解一下浏览器从“看到 img 标签”到“图上出现像素”之间发生的四件大事:
- 浏览器解析 HTML 时遇到 img 标签;
- 发现有效的 src 属性,开始发起网络请求;
- 请求响应后,对图片数据进行解码,生成位图;
- 进行绘制,把位图贴到指定位置。
懒加载动的就是第二步。如果 src 一开始是空的或者写的是占位图,浏览器就不会发请求。等滚动到可视区域时,我们再把真实图片地址塞进 src,浏览器才会去请求。
这里有个关键概念叫占位地址(placeholder)。最简单的方式是用一张 1×1 的透明 GIF,或者干脆不加 src。但“不加 src”在实际开发中会带来一些问题,等会儿到避坑环节细说。
2. 最先考虑原生方案:HTML 自带 loading="lazy"
2.1 一行属性,省掉一个库
如果你只是想让页面图片变快,不需要任何 JavaScript 经验,浏览器早就出手了。几乎所有现代浏览器都支持<img loading="lazy">属性,加上它,浏览器会自动判断图片是否接近视口,再决定何时加载。
举个例子:
<img src="/images/product.jpg" alt="产品宣传图" loading="lazy" width="800" height="600" />就这么简单。loading 属性有三个值可以选择:
- eager:默认值,立即加载,不走懒加载;
- lazy:浏览器会在图片快要进入视口时加载;
- auto:交给浏览器自己决定,效果等同于不写。
实际项目中,我通常把首页首屏以下的所有图片全部设为 lazy,首屏内的图片保持默认 eager。因为如果你把首屏图也 lazy 了,反而可能出现首屏图迟迟不加载的怪问题,后面避坑部分会讲。
2.2 原生懒加载的触发时机:进入视口前多少距离
你可能想知道 loading="lazy" 到底什么时候发请求。多浏览器实现和标准里其实有些讲究:
- Chrome 会在图片距离视口还有一段距离(一般是视口高度的某个比例)时提前加载;
- Firefox 的算法略有差异,但都依赖滚动位置、网络状况和设备内存;
- 提前预加载的距离是浏览器做平衡的,目的就是让用户“摸到时已经加载好了”。
所以原生方案的特点是——你不用关心细节,浏览器内部帮你在“加载过早”和“加载过晚”之间找平衡。这个方案有一个很实用的使用场景:博客、落地页、图片列表页、以及任何“图片多但交互逻辑不复杂”的地方。
2.3 生产环境中依然要补的两个缺口
原生 loading 虽然省事,但它的控制粒度太粗:你没法知道某张图片真的加载完成了没有,也没法在图片进入视口时加一个淡入动画。所以很多团队还是选择只用它作为“性能底线”,再配合 JavaScript 增强视觉效果。
另外一个坑是:loading="lazy" 并不是所有浏览器都支持。遇到完全不支持的旧浏览器,图片会直接按照正常方式加载,页面不会报错,但懒加载自然也就失效了。一般老项目我建议配合一个特性检测:
if ('loading' in HTMLImageElement.prototype) { // 支持原生懒加载,直接替换 src } else { // 不支持,启动 JavaScript 懒加载方案 }这个特性检测是吸取了教训之后养成的习惯——有一段时间我以为浏览器都支持了,直到某位同事的安卓设备一直白屏,排查了一圈才发现是浏览器版本太老,图片 src 直接被替换成了占位符。功能没问题,但性能优化瞬间失效,页面照样卡。
2.4 原生方案适合谁、不适合谁
如果满足以下条件,原生方案已经足够:
- 页面图片没有复杂的加载动画需求;
- 不需要统计图片曝光数据;
- 不需要控制预加载的距离和灵敏度;
- 图片来源以常规
<img>为主。
但如果你的需求是:图片进入屏幕那一刻要有动画、加载失败要显示重试按钮、还要统计曝光量,那么原生方案就不太够用。这时候就该引入一个真正可控的手段——IntersectionObserver。
3. 需要精细控制时:用 IntersectionObserver 实现懒加载
3.1 为什么不是“监听滚动事件”而是用一个 API
很多初学者听说要控制图片加载时机,第一反应是写 window.addEventListener('scroll', handler),然后计算图片位置。听起来合理,实际坑很多:滚动事件触发频率极高,即使做了节流(throttle),在移动端上也会有卡顿和迟滞感。
IntersectionObserver 是浏览器提供的一个专门监听元素和视口交叉状态的 API。它由浏览器底层优化,能够以极低的频率回调触发,并且不需要你手动计算偏移量。说白了,原生 loading 是浏览器替你干活,IntersectionObserver 是你告诉浏览器“帮我看住这个元素,它进入视口时就通知我”。
配合一张图说清楚流程:
[页面加载] → [observer 观察所有><img >const lazyImages = document.querySelectorAll('.lazy'); const imageObserver = new IntersectionObserver((entries, observer) => { entries.forEach(entry => { if (entry.isIntersecting) { const img = entry.target; if (img.dataset.src) { img.src = img.dataset.src; } if (img.dataset.srcset) { img.srcset = img.dataset.srcset; } img.classList.add('loaded'); observer.unobserve(img); } }); }, { root: null, rootMargin: '200px 0px', threshold: 0.01, }); lazyImages.forEach(img => imageObserver.observe(img));上面这段代码解释一下几个参数。
root设置为 null,表示用浏览器视口作为观察区域;rootMargin设为 '200px 0px',意为在视口底部提前 200px 触发加载,避免滚动到图片位置时才临时加载;threshold设为 0.01,意味着图片只要露出 1% 就触发回调,不需要完整进入。
拿到回调后第一件事是判断entry.isIntersecting,然后替换 src 和 srcset。替换完成后,使用observer.unobserve(img)停止观察。这一步非常重要,不然图片每划过一次视口你就会反复触发回调,浪费性能。
3.3 图片 srcset、懒加载和响应式图像别打架
稍微进阶一点的场景是响应式图片。页面同时接了srcset和sizes,让浏览器在不同屏幕宽度和 DPR 下挑选最合适的图。启动懒加载之后,这个机制依然有效,但要注意写法:
<img >.lazy-image { aspect-ratio: 16 / 9; width: 100%; height: auto; }如果图片尺寸不固定,需要在图片外层套一个固定比例的容器,再让 img 以object-fit: cover的方式填充:
.image-wrapper { aspect-ratio: 4 / 3; overflow: hidden; } .image-wrapper img { width: 100%; height: 100%; object-fit: cover; }这个坑在纯图片列表页特别常见。你以为只是“小抖动”,实际体验就是用户刚准备点击某个链接,页面突然往下一弹,手指直接点到了别的东西。
4.2 首屏图片也被 lazy 化,导致“秒开”变“愣住”
这是另一个高级迷惑行为:产品发现自己把首页顶部的大 Banner 也设置了 loading="lazy"。结果是什么?用户在打开页面的瞬间,首屏 Banner 并没有立即加载,而是要等浏览器“判断”它是不是在视口内。某些浏览器在页面初始布局未稳定时,会把首屏图片也视为“待定”,导致首屏内容白屏 2~3 秒。
排查的时候思路也很简单:
- 打开页面,发现顶部图片有延迟加载;
- 看 Network 面板,发现 Banner 图的请求发出时间比其他元素晚;
- 确定 Banner 设置了懒加载,直接取消 lazy;
- 刷新后,Banner 立即加载,页面首屏恢复了正常。
记住一个原则:首屏内容必须用 eager 或者干脆不写 loading 属性,懒加载只给首屏以下的内容。设置 loading 前,先问自己“这张图用户大概率在第一屏之内能看到吗?”,如果是,那就别懒加载。
4.3 懒加载将图片“藏起来”,导致 SEO 和图片收录出问题
这一条很多人想不到。搜索引擎爬虫在抓取页面时,并不会模拟用户的滚动行为,也不会为了找到图片而滑动整个页面。如果图片的 src 属性从一开始就是空的或占位符,爬虫很可能认为这里没有图片,最终导致图片不被收录。
关于搜索引擎优化(SEO)中的图片收录,我的处理经验是:
- 页面首屏的关键图片,直接用正常
<img>标签,不做懒加载; - 非首屏图片使用懒加载时,确保真实图片地址同时出现在某个可被解析的位置;
- 如果有站点地图(sitemap),可以把重要图片 URL 也提交进去;
- 图片的
alt文本不要为空,不要用“图片”“IMG_001”这类无意义描述。
如果你接手的是一个需要长期获得自然搜索流量的网站,懒加载方案必须把 SEO 考虑进去。百分之百的图片都懒加载,短期看不出问题,长期流量会掉得很隐蔽。
有个相关的小细节:有些团队喜欢把懒加载图片的>const gallery = document.getElementById('gallery'); const observer = new IntersectionObserver(callback, { root: gallery, rootMargin: '100px', threshold: 0.1, }); const galleryImages = gallery.querySelectorAll('.lazy'); galleryImages.forEach(img => observer.observe(img));
需要注意,根元素必须是图片的祖先元素,并且它得确实有滚动能力或 overflow 设置。如果 root 本身没有实际尺寸,回调可能永远不触发,图片永远不加载。这种问题排查起来也很微妙,因为代码看起来完全正确,但页面就是白屏。
4.5 开发环境正常、生产环境图片偶尔不加载:很可能是内存或网络节流
最后一个常见坑比较玄学:懒加载在本地开发时一切正常,发布到线上就偶尔出现图片不加载的情况。排查链路往往要拉开来看:
- 用 Chrome DevTools 的 Network 面板观察,发现部分图片请求状态是 (canceled);
- 继续查,发现 canceled 的图片都是懒加载图片;
- 然后打开 Memory 面板,看浏览器内存占用是否过高;
- 最终定位到问题:在部分低端设备上,浏览器为节省内存会自动取消离屏图片的加载请求。
这个现象不是懒加载本身的问题,而是浏览器在资源紧张时的自我保护。应对方式是:不要在懒加载回调里做太多高开销的事,比如同时加载大量大尺寸图片。一个稳妥的做法是给图片加载设置并发控制,比如同一时间只让 3~5 张图进入真实加载流程。
我个人的做法是写一个简单的“滑动窗口”控制逻辑:每次触发懒加载时,把图片放入一个任务队列,队列里有 2 个加载槽位,一个加载完才加载下一个。这样既保证了滚动时图片能及时出现,又不会因为一次性加载过多导致浏览器吃不消。
5. 进阶优化:把“加载体验”从“不卡顿”提升到“无感知”
5.1 占位骨架图:懒加载不一定非要“白屏等待”
如果只想解决“卡顿”,上面的懒加载方案已经够用。但如果你想让体验再上一个台阶,可以给图片加一个占位骨架。这不是炫技,而是真实感受的问题:用户看到图片空白区域时,心里是有预期“要出图”的,但视觉上没有任何反馈。
最轻量的方案是纯 CSS 骨架。比如用背景渐变模拟一个占位区域:
.lazy-image-wrapper { position: relative; background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 37%, #f0f0f0 63%); background-size: 400% 100%; animation: shimmer 1.4s ease infinite; } @keyframes shimmer { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; } }等图片加载完成后,用 JS 给图片加上 loaded 类,把骨架遮罩或背景色去掉即可。操作上可以先展示骨架,再让图片淡入:
.lazy-image { opacity: 0; transition: opacity 0.3s ease; } .lazy-image.loaded { opacity: 1; }这样用户在图片加载前看到一个轻量动画,加载完成后再平滑显示。缺点是多了一点 CSS 和 JS 的配合,但效果立竿见影。我给某个产品列表页加了这套后,用户反馈明显觉得“页面活了”。
5.2 失败重试与占位错误态
图片总有加载失败的时候,尤其是用户处于弱网环境。懒加载方案里最好加上错误处理。最简单的是监听 img 的 error 事件,加载失败时换成一个本地占位图,或者显示一个重试按钮。
我的代码习惯是这样:
img.addEventListener('error', () => { img.classList.add('error'); img.src = '/images/placeholder-error.svg'; });同时给图片外层容器写一个“点击重试”的交互。这个设计的初衷是:网络波动时用户重新点击图片,拉一下网络,通常就能恢复成功。
如果不想做 UI,也可以在 error 后把这张图重新放回观察队列,等用户再次滚动时重新触发:
if (entry.isIntersecting && !img.dataset.loaded) { // 正常加载逻辑 // 失败时不要 unobserve }这里有个细节值得注意:.error类要区别对待。如果图片本身加载失败是因为 URL 错误,或资源被删除,那重试多少次都白搭,不要无限重试。真正的弱网场景,重试 1~2 次最合理。
5.3 记录曝光数据时别把“进入视口”和“图片真的显示”混为一谈
统计场景里,图片曝光(impression)往往是运营和产品都比较看重的指标。很多人在懒加载回调里直接上报曝光,其实不太准确。
原因:回调触发代表图片进入了视口范围,但此时真实图片可能尚未加载完成,用户也没有看到它。更合理的做法是在img.onload事件触发后才上报曝光。
img.addEventListener('load', () => { if (!img.dataset.reported) { img.dataset.reported = '1'; requestAnimationFrame(() => sendExposureLog(img.dataset.id)); } });而且要注意load事件可能会触发多次(比如 srcset 切换、缓存策略不同),用>// 全局注册懒加载指令 app.directive('lazy', { mounted(el, binding) { const io = new IntersectionObserver((entries) => { const entry = entries[0]; if (entry.isIntersecting) { el.src = binding.value; io.unobserve(el); } }, { rootMargin: '200px 0px', }); io.observe(el); }, });
HTML 里这样使用:
<img v-lazy="product.imageUrl" width="400" height="300" alt="商品图" />Vue 指令的好处是复用性高,直接一行命令搞定。React 的话,更常见的是封装一个 LazyImage 组件,内部处理 IntersectionObserver 和图片加载状态。
但有一点必须强调:框架提供的懒加载组件不等于万能灵药。它们内部同样绕不开宽高占位、root 容器、首屏策略这些问题。把本文的避坑思路吃透之后,不管用哪个库,你都知道问题出在哪一层。
6. 我习惯的懒加载上线流程:从代码到验证一整套
6.1 上线前必须检查的三件事
不管用原生方案还是 IntersectionObserver,我在真正上线之前都会跑一遍下面的检查:
第一,打开 Network 面板,确认首屏只请求了首屏图片。如果首屏就发现一大堆图片请求,说明懒加载没生效,或者有图片藏在了首屏但没被识别。
第二,故意把网络切到 Slow 3G,滑动页面看图片是否在到达视口前就有加载。如果总是滑到图片位置时才开始加载,说明 rootMargin 设置得不够大,或者原生的 lazy 和预期的加载策略不一致。
第三,用 Lighthouse 跑一次性能评分。重点看 LCP(Largest Contentful Paint)和 CLS(Cumulative Layout Shift)两个指标。LCP 太大说明首屏内容加载慢,CLS 太大说明布局偏移严重。CLS 数值超过 0.1 就必须回头检查图片宽高。
6.2 怎么判断你的懒加载有没有真的“省”到流量
很多小白做完懒加载,只是看页面“快了一点”,缺少一个量化的判断。最简单的办法是在浏览器无痕模式下,打开 DevTools 的 Network 面板,做两组实验:
- 第一组:不启用懒加载,清空缓存刷新,记录总请求数和总大小;
- 第二组:启用懒加载,记录首屏请求数和总大小;再手动滚动页面到底部,记录完整请求数。
对比两个数据,你会得出两个指标:“首屏请求数削减率”和“全文加载完整率”。前者体现了首屏提速效果,后者则反映了懒加载有没有漏掉图片。
我通常要求项目里这两个指标至少记录在文档里,方便后续优化和回归。单纯说“加了懒加载感觉变快了”,以后要复盘时无从下手。
6.3 懒加载不是“加完就走”:回归测试要覆盖哪些操作
懒加载上线后,最怕的是改动影响其他功能。我自己整理的回归测试列表,供你参考:
- 浏览器直接输入 URL,回车,首屏图片立即显示;
- 从页面顶部快速滚到最底部,所有图片最终都能显示;
- 点击浏览器“后退”按钮,从缓存中返回页面,图片不异常;
- 断网时刷新页面,不出现大面积破图;
- 移动端横竖屏切换后,图片显示正常;
- 如果页面有锚点跳转,跳转到长页面中部时,图片能正确加载。
还有一种情况很容易被忽视:浏览器在标签页后台时可能延迟执行图片加载,切回前台后图片才批量加载,导致页面突然刷出一堆图。这种情况不算 bug,但最好在代码里留意一下——如果你的图片库特别大,后台切回来瞬间的加载风暴可能会让性能面板拉爆。
7. 最后的实践经验:不同规模的站点,懒加载策略怎么选
7.1 小站点或活动页:一行 loading="lazy" 最省心
如果你的项目是个人博客、单页活动、或内容量不大的企业官网,我强烈建议你直接用原生 loading="lazy"。零依赖、零维护、不会引入新 bug,浏览器也已经把它优化得相当成熟。给图片加上 width/height 或 aspect-ratio,就已经满足绝大多数需求。
7.2 中大型图片站点:IntersectionObserver 是主力
如果是电商、图库、信息流这类图片密集的站点,IntersectionObserver 更好一些。它让你能控制加载时机、加载动画、曝光上报、失败重试等细节。代码量不算大,但换来的是一个可以持续扩展的图片加载体系。
7.3 集约化平台:可以再考虑服务端/边缘侧的图片处理
如果你的项目已经有图片处理服务,并且图片地址支持动态裁剪和格式转换,那么懒加载方案的最后一环可以配合这些服务:
- 根据屏幕尺寸返回合适的图片尺寸;
- 自动转换成 WebP/AVIF 格式;
- 支持图片压缩质量参数调整;
- 使用 CDN 缓存图片响应。
懒加载解决“何时加载”,图片服务解决“加载什么”,两者配合,才是图片性能优化的完整闭环。如果只做前端懒加载而忽略源图优化,就相当于只在超市门口限流,货架上的商品质量本身却不做管理,推进速度再快也白搭。
做项目这么多年,我最大的体会是:懒加载不是一项孤立的技术,它是“性能观念”的第一课。你真正理解了浏览器怎么加载资源、用户怎么看页面、什么时机加载最合理以后,后面的字体优化、脚本拆分、组件按需加载都会顺理成章。
如果你刚被图片卡顿折磨过,可以先从方案一开搞,半天就能看到效果;等你觉得控制力不够时,再慢慢演进到方案二。性能优化这行,最怕的不是不会用工具,而是不知道问题出在哪一层。懒加载就是帮你建立这种直觉最好的起点。