图片懒加载实践:首屏图千万别懒
要不要给一张图加懒加载,答案取决于它在页面的哪个位置。屏幕外那几十张列表缩略图,进页面就全下会白白吃掉带宽、拖慢首屏,该懒;而首屏正中那张 banner、文章头图、商品主图,往往就是 LCP 候选,它越早下载越好,一旦被 loading="lazy" 延后,首屏反而更慢。同样一个属性,用对地方是优化,用错地方是自伤——懒加载考验的从来是位置判断,属性本身没什么难度。
我把这条判断标准放在最前面,是因为踩过坑。有个商品详情页上线后 LCP 忽然变差,翻代码才发现主图跟着列表组件一起套了 loading="lazy"。浏览器本来能在解析 HTML 时就发现并抢先下载它,结果被这一句拦下来,非等布局算完、确认它进了视口才请求。属性没写错,位置判断错了。
屏外的图:原生 loading="lazy" 够用很多场景
现代浏览器早就支持原生懒加载:
1<img src="/cover.jpg" loading="lazy" alt="文章封面" />
普通文章列表、商品列表、头像墙,这一句基本就够了。浏览器会根据图片距视口的远近自己决定何时加载,不用写一行 JS。它是 Chrome 76(2019)就落地的老能力,如今各大浏览器都支持,属于可以放心用的那一类。
但它不是精确控制工具:不同浏览器的触发距离和策略不一样,你没法指定"提前多少像素开始加载"。如果需要更细的控制——比如统一提前 300px、顺便统计曝光、切换占位状态——就得上 IntersectionObserver。什么时候用哪个,看一条就够:能交给浏览器的交给浏览器,需要自定义行为时才自己接管。
还有个和它成对的属性 decoding="async",作用是让图片解码不阻塞主线程渲染。原生懒加载配上它,屏外图进入视口后既异步下载、又异步解码,对滚动的流畅度更友好:
1<img src="/cover.jpg" loading="lazy" decoding="async" alt="文章封面" />
这属性也是可以放心用的老能力,我现在列表图基本默认带上。
先搞清楚谁是 LCP,再谈懒不懒
前面一直在说"首屏图别懒、屏外图才懒",但实际做的时候,"首屏"这条线画在哪并不总是显而易见。设备高度不同,同一张图在大屏上进了首屏、在小屏上就在折叠线以下;响应式布局下,图片位置还会随断点变。所以我不靠"感觉它在不在首屏"来定,而是用工具直接看谁是 LCP 元素。
Chrome DevTools 的 Performance 面板录一段加载,时间线上会标出 LCP 那一帧对应的元素;Lighthouse 报告里也会直接点名 LCP 是哪个节点。确认了这个元素,该懒不该懒就清楚了:它以及它上方的图,绝不懒加载;它下方的,尽管懒。判断不靠猜,靠测——这是我给自己定的规矩。
顺带说,LCP 元素不一定是图。有时是一大段标题文字、一个背景图容器。如果是 CSS background-image,它不受 loading 属性管,得靠 preload 或者别把它藏在很深的 CSS 里,免得浏览器发现得太晚。
首屏关键图:不但别 lazy,还可以抢优先级
首屏 banner、文章头图、商品主图,只要它是 LCP 候选,就别写 loading="lazy":
1<img 2 src="/hero.jpg" 3 width="1200" 4 height="600" 5 alt="活动主视觉" 6/>
不加懒加载,浏览器的预加载扫描器就能在解析阶段更早发现它、更早开下载。
如果这张图确实关键,还能主动往上抬优先级:
1<img 2 src="/hero.jpg" 3 fetchpriority="high" 4 width="1200" 5 height="600" 6 alt="活动主视觉" 7/>
fetchpriority="high" 是 Chrome 101(2022 起)才有的属性,Safari 这边我查了下还没跟上,所以我把它当渐进增强用——支持的浏览器吃到加速,不支持的也不受影响。它提示浏览器优先分配带宽给这张图。但这里也有个分寸:别一股脑给页面里一堆图都加 high。优先级是相对概念,大家都高就等于都没高。真正想省事的话,对最关键那张 LCP 图,用 <link rel="preload" as="image"> 在 <head> 里提前声明,效果比属性更早生效,不过维护成本也更高,我一般只对活动首图这种确定的目标用。
IntersectionObserver:需要自定义行为时才上
当原生 lazy 不够用,IntersectionObserver 是标准答案。一个简单实现:
1var observer = new IntersectionObserver(function (entries) { 2 entries.forEach(function (entry) { 3 if (!entry.isIntersecting) return 4 5 var img = entry.target 6 img.src = img.dataset.src 7 observer.unobserve(img) 8 }) 9}, { 10 rootMargin: '300px', 11}) 12 13document.querySelectorAll('img[data-src]').forEach(function (img) { 14 observer.observe(img) 15})
rootMargin: '300px' 表示图片进入视口前 300px 就开始加载,避免用户刚滚到就对着空白等。这个提前量要拿捏——太小了滚动跟不上、太大了又失去懒加载的意义,我一般给一到两屏。
关键是别退回到在 scroll 事件里逐张算位置的老写法。IntersectionObserver 由浏览器在合成线程上优化,既不阻塞主线程、代码也更干净。真要判断"该用 observer 还是原生 lazy",标准就一句话:只需要延后加载,用原生;需要在进入视口这个时机做额外的事(打点、换占位、预热),才用 observer。
实战里我会把加载状态也一并管起来,图片真正 load 完再把占位切掉、加个淡入,避免图"啪"地闪现:
1var observer = new IntersectionObserver(function (entries) { 2 entries.forEach(function (entry) { 3 if (!entry.isIntersecting) return 4 5 var img = entry.target 6 var real = img.dataset.src 7 8 var loader = new Image() 9 loader.onload = function () { 10 img.src = real 11 img.classList.add('is-loaded') // 触发 CSS 里的淡入 12 } 13 loader.onerror = function () { 14 img.src = '/fallback.png' 15 } 16 loader.src = real 17 18 observer.unobserve(img) 19 }) 20}, { 21 rootMargin: '300px', 22})
用一个临时的 Image 对象先在后台把图拉下来,成功了再赋给真正的 <img>,失败了就换上准备好的占位图,顺手把懒加载、占位切换、错误处理串成了一条链。React 项目里可以把这套逻辑封进一个 useLazyImage 之类的 hook 复用,但底层机制就是这个 observer。
一定要预留尺寸
懒加载的图如果不写尺寸,加载完成的一瞬间会把页面撑开,造成布局偏移。
1<img 2 src="/placeholder.png" 3 data-src="/cover.jpg" 4 width="800" 5 height="450" 6 alt="文章封面" 7/>
只要写了 width 和 height,现代浏览器会据此算出宽高比、提前占好位置,图还没来版面就稳了。响应式图片则用比例撑住:
1.cover { 2 width: 100%; 3 height: auto; 4}
或者交给容器:
1.coverWrap { 2 aspect-ratio: 16 / 9; 3 background: #f3f4f6; 4}
aspect-ratio 现在主流浏览器都支持,用它给容器锁死比例,比过去那套 padding-bottom: 56.25% 的百分比 hack 直观太多。CLS 很多时候不是内容复杂,而是图片、广告、字体这些资源没留空间。而 CLS 和 LCP、FID 一样,是 2021 年起 Google 纳入搜索排名的 Core Web Vitals 之一,值不值得较真,这个背景摆在那儿。
这里有个和懒加载直接冲突的隐患要提醒:懒加载的图恰恰最容易造成 CLS。因为它是滚动到才加载的,加载那一刻如果没预留空间,页面正在被用户浏览时突然被撑开,内容往下一跳,体验比首屏的跳动还糟。所以越是懒加载的图,越要老老实实写 width/height 或 aspect-ratio。懒加载和尺寸预留不是两件可选的事,是必须一起做的一对——只做懒加载不留尺寸,等于用性能优化换来了布局抖动。
懒加载省的是"加载时机",尺寸和格式省的是"加载多少"
懒加载解决的是"什么时候下载",但另一个更被低估的问题是"下载多大、下载什么格式"。一张图哪怕懒加载得再准,如果它本身是给桌面端准备的 2000px 大图,被塞到手机上显示成 375px,那也是白白多下了好几倍的字节。这两件事得配套做。
响应式图片靠 srcset 和 sizes 让浏览器自己挑合适的尺寸:
1<img 2 src="/cover-800.jpg" 3 srcset="/cover-400.jpg 400w, /cover-800.jpg 800w, /cover-1200.jpg 1200w" 4 sizes="(max-width: 600px) 100vw, 800px" 5 loading="lazy" 6 width="800" 7 height="450" 8 alt="文章封面" 9/>
浏览器结合屏幕像素密度和 sizes 声明的显示宽度,从 srcset 里挑一档下载。手机不再去拉桌面尺寸的图,这一项在移动端的收益往往比懒加载还直接。
格式上,WebP 现在全面可用,AVIF 也在铺开,两者在同等画质下比 JPEG 小不少。用 <picture> 做优雅降级,让支持的浏览器吃到新格式、不支持的退回 JPEG:
1<picture> 2 <source srcset="/cover.avif" type="image/avif" /> 3 <source srcset="/cover.webp" type="image/webp" /> 4 <img src="/cover.jpg" loading="lazy" width="800" height="450" alt="文章封面" /> 5</picture>
懒加载、响应式尺寸、现代格式,是三件独立又互补的事:一个管什么时候下、一个管下多大、一个管下什么格式。只做懒加载而不管后两者,省下来的常常没你以为的多。
加载失败要有退路
图片加载失败在真实项目里太常见:资源被删、CDN 抖动、URL 为空、权限过期。至少得有个应对:
1function SafeImage({ src, fallback, alt }) { 2 const [currentSrc, setCurrentSrc] = useState(src) 3 4 return ( 5 <img 6 src={currentSrc} 7 alt={alt} 8 onError={() => setCurrentSrc(fallback)} 9 /> 10 ) 11}
要小心的一点是:万一 fallback 本身也失败,onError 会再次触发、又把 src 设回 fallback,形成死循环,浏览器会疯狂重试同一个坏地址。真实项目里得先判断当前是不是已经是 fallback,是就别再换了:
1function SafeImage({ src, fallback, alt }) { 2 const [currentSrc, setCurrentSrc] = useState(src) 3 const [failed, setFailed] = useState(false) 4 5 function handleError() { 6 if (failed) return // 已经在用 fallback 了,别再换,切断死循环 7 setFailed(true) 8 setCurrentSrc(fallback) 9 } 10 11 return <img src={currentSrc} alt={alt} onError={handleError} /> 12}
原生 HTML 里也一样,直接在 onerror 里把它自己解绑掉:this.onerror = null 之后再换 src,就不会二次触发。这个坑几乎每个处理过图片失败态的人都踩过一次,写第一版时想不到,线上一遇到坏图 CDN 就被打爆。
alt 也有它的判断:装饰性图片 alt 留空,让读屏软件跳过;内容性图片的 alt 要写出真正的信息,别一律糊一个 alt="image",那对可访问性等于没写。
懒加载和骨架屏不是一回事
这两个经常被混谈,其实解决的是不同问题:懒加载管"什么时候去请求资源",骨架屏管"资源到位之前屏幕上放什么"。
列表图用灰色占位或主色块就行,别给每张小图都做复杂的 blur 占位——占位本身也有渲染成本,小图上得不偿失。首屏大图可以考虑低质量占位(LQIP),但要注意别让占位图自己成了 LCP,用户真正等的是那张清晰主图什么时候出来。这里的分寸是:占位是为了不闪、不跳,不是为了好看而堆效果。
SPA 和虚拟列表里,懒加载还有额外的坑
单页应用里图片懒加载有个容易被忽略的问题:路由切换是前端接管的,页面不整体刷新。如果你用 IntersectionObserver 手动实现懒加载,切走某个页面时得记得把 observer disconnect 掉,否则监听器和 DOM 引用留着,累积多了就是内存泄漏。原生 loading="lazy" 没这个负担,浏览器自己管生命周期——这也是能用原生就用原生的一个理由。
虚拟列表(只渲染可视区域那几行)和懒加载叠加时更要当心。虚拟列表会频繁地创建、销毁 DOM 节点,图片可能刚 observe 上就被回收了。这种场景下,把懒加载交给原生属性、让浏览器和虚拟列表各管各的,往往比自己写一套 observer 去追那些来来去去的节点省心。判断依据还是那条:自己接管的成本,值不值它带来的控制力。控制力用不上的时候,原生就是最优解。
我的图片懒加载清单
落地时我会挨条核对:
- 首屏 LCP 图没有
lazy,必要时补fetchpriority="high"或preload - 首屏以下的图才懒加载,配
decoding="async" - 每张图有
width/height或aspect-ratio,懒加载图尤其不能漏 - 响应式图片用
srcset/sizes,下载尺寸和显示尺寸匹配 - 尽量供 WebP / AVIF,用
<picture>降级 - 懒加载提前量够(
rootMargin或原生策略) - 加载失败有退路,且切断了死循环
alt文案对得上图片用途,装饰图留空- SPA 里手写的 observer 记得在离开时
disconnect
回到开头那张被误懒加载的商品主图:把 loading="lazy" 摘掉、补上 fetchpriority="high",LCP 当天就回到了正常水位。那次之后我在项目里加了条约定——组件默认给列表图挂懒加载,但主图、头图这类走单独的组件,从源头上就不带 lazy,省得再有人顺手套错。
图片懒加载从来不是无脑加属性——首屏图要快、屏外图要晚、空间要预留、失败要能兜底。判断错一次位置,付出的代价就是一次 LCP 报警。