☰
图片懒加载实战:从原生Lazy到IntersectionObserver避坑指南
2026/10/21 22:07:55 网站建设 项目流程

接到一个还算齐全的个人站改版需求,别的都还好,就是首页那张 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 缓存图片响应。

懒加载解决“何时加载”,图片服务解决“加载什么”,两者配合,才是图片性能优化的完整闭环。如果只做前端懒加载而忽略源图优化,就相当于只在超市门口限流,货架上的商品质量本身却不做管理,推进速度再快也白搭。

做项目这么多年,我最大的体会是:懒加载不是一项孤立的技术,它是“性能观念”的第一课。你真正理解了浏览器怎么加载资源、用户怎么看页面、什么时机加载最合理以后,后面的字体优化、脚本拆分、组件按需加载都会顺理成章。

如果你刚被图片卡顿折磨过,可以先从方案一开搞,半天就能看到效果;等你觉得控制力不够时,再慢慢演进到方案二。性能优化这行,最怕的不是不会用工具,而是不知道问题出在哪一层。懒加载就是帮你建立这种直觉最好的起点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询