
接到一个还算齐全的个人站改版需求别的都还好就是首页那张 2MB 的产品大图直接把 Lighthouse 性能分摁在地上摩擦。我把图压到 800KB 还是慢后来装上懒加载首屏体积直接砍掉一大半。这几年看不少前端小白最怕碰性能优化一上来就对着 Webpack、CDN、缓存一堆名词发怵其实图片懒加载这关过了网页“秒开”这件事就算入门了。这篇文章不绕弯子直接讲清楚图片懒加载到底在解决什么问题、最省事的现代浏览器原生方案怎么用、什么时候必须上 JavaScript 的 IntersectionObserver以及真正会坑到你的那些细节点。全程用真实可跑的代码和踩坑记录说话适合刚接触前端、被图片加载速度折磨过、以及想在项目里落地性能优化的同学照着抄。1. 懒加载解决的远不止“加载速度”这一个问题1.1 一个反直觉的结论图片加载慢不代表图片必须小很多人以为页面卡是因为图片体积太大于是疯狂压缩。压缩确实有用但有一个更矛盾的情况哪怕你已经把图片压到很小只要页面里一次塞了 50 张图这些请求依然会挤在首屏加载阶段把带宽、连接数、主线程时间全部占满。懒加载的思路是不在首屏的图片先不发请求。等用户滚动到它附近了再真正去加载。这个逻辑有点像去自助餐厅吃饭你不会一次性把所有想吃的菜全端到桌上通常是先拿眼前这一盘吃完了再去拿下一盘。浏览器加载图片也一样——用户此刻看不到的区域凭什么要吃网络资源我这边实际统计过一个 30 张图片的列表页图片总大小约 15MB。启用懒加载之后首屏只请求了 3 张图大约 1.2MB 流量时间从 6.8s 降到 1.9s。这个对比比任何压缩算法都来得猛因为它是直接从“请求数量”这个维度上做减法。1.2 懒加载帮你躲开的三类真实问题除了加载速度懒加载还顺带解决了好几件容易被忽视的事第一移动端流量消耗。用户的流量套餐经不起一次打开页面就悄悄下了几十张图。尤其在一些弱网环境图片请求发出去之后半天不回来用户手指一划页面半天没反应体验直接崩。第二并发请求导致的串行阻塞。浏览器对同一个域名的并发连接数有限制桌面端一般是 68 个。图片请求一口气全发出去会把后面的脚本、样式请求全堵在后面。不夸张地说我见过某个页面因为一张超时图片把整个首屏 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 自带 loadinglazy2.1 一行属性省掉一个库如果你只是想让页面图片变快不需要任何 JavaScript 经验浏览器早就出手了。几乎所有现代浏览器都支持img loadinglazy属性加上它浏览器会自动判断图片是否接近视口再决定何时加载。举个例子img src/images/product.jpg alt产品宣传图 loadinglazy width800 height600 /就这么简单。loading 属性有三个值可以选择eager默认值立即加载不走懒加载lazy浏览器会在图片快要进入视口时加载auto交给浏览器自己决定效果等同于不写。实际项目中我通常把首页首屏以下的所有图片全部设为 lazy首屏内的图片保持默认 eager。因为如果你把首屏图也 lazy 了反而可能出现首屏图迟迟不加载的怪问题后面避坑部分会讲。2.2 原生懒加载的触发时机进入视口前多少距离你可能想知道 loadinglazy 到底什么时候发请求。多浏览器实现和标准里其实有些讲究Chrome 会在图片距离视口还有一段距离一般是视口高度的某个比例时提前加载Firefox 的算法略有差异但都依赖滚动位置、网络状况和设备内存提前预加载的距离是浏览器做平衡的目的就是让用户“摸到时已经加载好了”。所以原生方案的特点是——你不用关心细节浏览器内部帮你在“加载过早”和“加载过晚”之间找平衡。这个方案有一个很实用的使用场景博客、落地页、图片列表页、以及任何“图片多但交互逻辑不复杂”的地方。2.3 生产环境中依然要补的两个缺口原生 loading 虽然省事但它的控制粒度太粗你没法知道某张图片真的加载完成了没有也没法在图片进入视口时加一个淡入动画。所以很多团队还是选择只用它作为“性能底线”再配合 JavaScript 增强视觉效果。另外一个坑是loadinglazy 并不是所有浏览器都支持。遇到完全不支持的旧浏览器图片会直接按照正常方式加载页面不会报错但懒加载自然也就失效了。一般老项目我建议配合一个特性检测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 也设置了 loadinglazy。结果是什么用户在打开页面的瞬间首屏 Banner 并没有立即加载而是要等浏览器“判断”它是不是在视口内。某些浏览器在页面初始布局未稳定时会把首屏图片也视为“待定”导致首屏内容白屏 23 秒。排查的时候思路也很简单打开页面发现顶部图片有延迟加载看 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 面板看浏览器内存占用是否过高最终定位到问题在部分低端设备上浏览器为节省内存会自动取消离屏图片的加载请求。这个现象不是懒加载本身的问题而是浏览器在资源紧张时的自我保护。应对方式是不要在懒加载回调里做太多高开销的事比如同时加载大量大尺寸图片。一个稳妥的做法是给图片加载设置并发控制比如同一时间只让 35 张图进入真实加载流程。我个人的做法是写一个简单的“滑动窗口”控制逻辑每次触发懒加载时把图片放入一个任务队列队列里有 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 错误或资源被删除那重试多少次都白搭不要无限重试。真正的弱网场景重试 12 次最合理。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-lazyproduct.imageUrl width400 height300 alt商品图 /Vue 指令的好处是复用性高直接一行命令搞定。React 的话更常见的是封装一个 LazyImage 组件内部处理 IntersectionObserver 和图片加载状态。但有一点必须强调框架提供的懒加载组件不等于万能灵药。它们内部同样绕不开宽高占位、root 容器、首屏策略这些问题。把本文的避坑思路吃透之后不管用哪个库你都知道问题出在哪一层。6. 我习惯的懒加载上线流程从代码到验证一整套6.1 上线前必须检查的三件事不管用原生方案还是 IntersectionObserver我在真正上线之前都会跑一遍下面的检查第一打开 Network 面板确认首屏只请求了首屏图片。如果首屏就发现一大堆图片请求说明懒加载没生效或者有图片藏在了首屏但没被识别。第二故意把网络切到 Slow 3G滑动页面看图片是否在到达视口前就有加载。如果总是滑到图片位置时才开始加载说明 rootMargin 设置得不够大或者原生的 lazy 和预期的加载策略不一致。第三用 Lighthouse 跑一次性能评分。重点看 LCPLargest Contentful Paint和 CLSCumulative Layout Shift两个指标。LCP 太大说明首屏内容加载慢CLS 太大说明布局偏移严重。CLS 数值超过 0.1 就必须回头检查图片宽高。6.2 怎么判断你的懒加载有没有真的“省”到流量很多小白做完懒加载只是看页面“快了一点”缺少一个量化的判断。最简单的办法是在浏览器无痕模式下打开 DevTools 的 Network 面板做两组实验第一组不启用懒加载清空缓存刷新记录总请求数和总大小第二组启用懒加载记录首屏请求数和总大小再手动滚动页面到底部记录完整请求数。对比两个数据你会得出两个指标“首屏请求数削减率”和“全文加载完整率”。前者体现了首屏提速效果后者则反映了懒加载有没有漏掉图片。我通常要求项目里这两个指标至少记录在文档里方便后续优化和回归。单纯说“加了懒加载感觉变快了”以后要复盘时无从下手。6.3 懒加载不是“加完就走”回归测试要覆盖哪些操作懒加载上线后最怕的是改动影响其他功能。我自己整理的回归测试列表供你参考浏览器直接输入 URL回车首屏图片立即显示从页面顶部快速滚到最底部所有图片最终都能显示点击浏览器“后退”按钮从缓存中返回页面图片不异常断网时刷新页面不出现大面积破图移动端横竖屏切换后图片显示正常如果页面有锚点跳转跳转到长页面中部时图片能正确加载。还有一种情况很容易被忽视浏览器在标签页后台时可能延迟执行图片加载切回前台后图片才批量加载导致页面突然刷出一堆图。这种情况不算 bug但最好在代码里留意一下——如果你的图片库特别大后台切回来瞬间的加载风暴可能会让性能面板拉爆。7. 最后的实践经验不同规模的站点懒加载策略怎么选7.1 小站点或活动页一行 loadinglazy 最省心如果你的项目是个人博客、单页活动、或内容量不大的企业官网我强烈建议你直接用原生 loadinglazy。零依赖、零维护、不会引入新 bug浏览器也已经把它优化得相当成熟。给图片加上 width/height 或 aspect-ratio就已经满足绝大多数需求。7.2 中大型图片站点IntersectionObserver 是主力如果是电商、图库、信息流这类图片密集的站点IntersectionObserver 更好一些。它让你能控制加载时机、加载动画、曝光上报、失败重试等细节。代码量不算大但换来的是一个可以持续扩展的图片加载体系。7.3 集约化平台可以再考虑服务端/边缘侧的图片处理如果你的项目已经有图片处理服务并且图片地址支持动态裁剪和格式转换那么懒加载方案的最后一环可以配合这些服务根据屏幕尺寸返回合适的图片尺寸自动转换成 WebP/AVIF 格式支持图片压缩质量参数调整使用 CDN 缓存图片响应。懒加载解决“何时加载”图片服务解决“加载什么”两者配合才是图片性能优化的完整闭环。如果只做前端懒加载而忽略源图优化就相当于只在超市门口限流货架上的商品质量本身却不做管理推进速度再快也白搭。做项目这么多年我最大的体会是懒加载不是一项孤立的技术它是“性能观念”的第一课。你真正理解了浏览器怎么加载资源、用户怎么看页面、什么时机加载最合理以后后面的字体优化、脚本拆分、组件按需加载都会顺理成章。如果你刚被图片卡顿折磨过可以先从方案一开搞半天就能看到效果等你觉得控制力不够时再慢慢演进到方案二。性能优化这行最怕的不是不会用工具而是不知道问题出在哪一层。懒加载就是帮你建立这种直觉最好的起点。