前端图片性能优化:LCP 慢很多时候不是 JS 的锅

一张图片该不该懒加载,答案不是"越懒越好",而是"看它在不在首屏"。同一行 loading="lazy",加在页面第三屏的商品缩略图上是优化,加在首屏那张最大的 banner 上就是自残——它会让浏览器故意晚一点去请求本该最先加载的资源,LCP 直接被拖慢。这个判断错位,是我这两年在活动页和中后台里见得最多的图片性能坑,比包体积、比 hydration 都常见。

大部分前端排查首屏慢,第一反应是盯 JavaScript:包是不是太大、组件是不是重复渲染、接口是不是慢。这些当然都要看,但真实项目里,首屏慢有很大一块来自图片本身——主图太大、尺寸不对、懒加载误用、没写宽高、CDN 没做压缩。这些问题的共同点是:它们不在 JS 里,你把打包链路优化到极致也动不了它们。

上个月我接手一个活动页,Lighthouse 上 LCP 一直卡在两秒多。组里最开始都怀疑是 React hydration 拖的,改了半天 Suspense 边界收效甚微。打开 Performance 面板一录才发现,LCP 元素是首屏那张 banner,设计稿导出足足 3000 多像素宽,线上移动端也在原样加载这张原图。手机屏幕实际显示宽度不到 400px,却下载了七八倍分辨率的图。JS 优化了一圈,不如把这一张图处理对来得直接。

先确认 LCP 到底是谁

图片优化最忌讳泛泛而谈"把图压小一点"。压哪张、压到多小,得先知道 LCP 元素是谁。

如果 LCP 是首屏那张大图,优化重点就锁定这一张;如果 LCP 其实是一段标题文字或者一块纯色 hero 区域,那把时间花在压缩几个小图标上就是白费力气。判断 LCP 元素本身有现成手段:Chrome DevTools 的 Performance 面板录一段,时间轴上会标出 LCP 那一帧对应的是哪个节点;线上则可以用 PerformanceObserver 监听 largest-contentful-paint,把元素信息一起上报:

1new PerformanceObserver((list) => {
2  const entries = list.getEntries();
3  const lcp = entries[entries.length - 1];
4  report('lcp', { time: lcp.startTime, element: lcp.element?.tagName, url: lcp.url });
5}).observe({ type: 'largest-contentful-paint', buffered: true });

拿到具体元素之后,再逐个对答案:这张图实际显示多宽、下载下来的资源多宽、文件多大、有没有被懒加载延迟、有没有写宽高、走没走 CDN 压缩和缓存。这几个问题里,出问题概率最高的永远是第一个和第二个的错位——显示 360,下载 2000。这不是"高清",就是纯粹的浪费,多下来的一兆多字节对屏幕上那几百个像素毫无贡献。

lcp.element 拿到的是 DOM 节点,lcp.url 是它加载的资源地址,这两个信息合在一起就能定位到具体是哪张图。有一点容易踩:LCP 会随页面内容变化被多次上报,largest-contentful-paint 这个 entry 会在更大的元素出现时不断刷新,所以要取列表里最后一条,而不是第一条。而且这个观测会在用户第一次交互(点击、按键、滚动)之后就停止更新——这也是为什么线上采集 LCP 时,得留意有没有把交互前的最终值稳稳地拿到。我上报时会额外带上 lcp.size(元素渲染面积)和 lcp.loadTime/lcp.renderTime 这两个字段,前者帮我确认 LCP 是不是真的是那张大图,后者能把"资源什么时候下完"和"什么时候画到屏幕上"这两段时间分开看——有时候图早就下完了,卡在渲染那一下,那就不是网络问题而是主线程被占住了,排查方向完全不同。

真正把显示宽度和下载宽度摊开来看,DevTools 的 Network 面板有个不起眼但好用的列:把 Dimensions 那一列打开,能直接看到每张图的解码尺寸;Elements 面板里 hover 到 <img> 上,Chrome 会提示 intrinsic size 和 rendered size 的对比,差得离谱的一眼就能认出来。我一般会先在真机或者移动端模拟下量一遍,因为桌面宽屏上"显示 360、下载 2000"这个矛盾常常被掩盖——桌面确实显示得大,问题只在窄屏上暴露。

让浏览器自己选对分辨率

既然不同设备需要的分辨率不一样,就不该拿一张大图打天下。srcsetsizes 这套原生机制就是干这个的:

1<img
2  src="/hero-800.jpg"
3  srcset="/hero-400.jpg 400w, /hero-800.jpg 800w, /hero-1200.jpg 1200w"
4  sizes="(max-width: 600px) 100vw, 800px"
5  alt="产品控制台界面"
6/>

srcset 告诉浏览器手上有哪几种分辨率的资源,每个后面的 400w 是这张图的真实像素宽度;sizes 则告诉浏览器"这张图在不同视口下大概会显示多宽"。浏览器拿到这两个信息,结合当前的设备像素比(DPR)和视口宽度,自己算出该下哪一张。一台 2 倍屏的手机,视口 375px、图片全屏显示,它会去选接近 750 物理像素的那档,而不是无脑挑最大的。

这里最容易写错的是 sizes,它错了会直接导致选图错误,而且不报任何错。如果移动端图片其实是全屏宽,sizes 却写死成 800px,浏览器会以为这张图只需要 800 逻辑像素,在高 DPR 下反而可能挑一张偏大的;反过来桌面端图片占了大半个屏,sizes 写得太保守,选出来的图就糊了。我的习惯是先量准这张图在各个断点下的 CSS 显示宽度,再照着写 sizes,不要凭感觉填。

sizes 的匹配规则也有个反直觉点:它是从左往右取第一个命中的媒体条件,最后一个不带条件的值是兜底。写 (max-width: 600px) 100vw, (max-width: 1024px) 50vw, 800px 的时候,顺序不能乱,把宽的断点写在窄的前面,窄屏永远命中不到。还有一点是 sizes 里的长度不必和 CSS 完全精确对齐——浏览器需要在 CSS 真正算出布局之前就决定下哪张图,所以它只能拿 sizes 当估算,稍微往上取整、留一点余量是可以接受的,但方向不能反。我验证 sizes 写得对不对,通常是打开 DevTools 在不同视口宽度下看 <img>currentSrc——它告诉你浏览器最终真正选了哪一档,比对着 srcset 脑内推演可靠得多。

一个和 srcset 常一起出问题的是 DPR 上限。高端安卓机的 DPR 能到 3 甚至 4,如果你只给到 800w 这一档,3 倍屏上一张显示 300px 的图理论上想要 900 物理像素,srcset 里最大只有 800,就只能拿这档凑合,边缘会略糊。但反过来给每张图都备到 4 倍分辨率也不现实——超过 2 倍之后,屏幕像素密度带来的清晰度提升人眼已经很难分辨,字节却在成倍涨。实际项目里我一般备到 2 倍封顶,对确实要展示细节的关键图(商品大图、证件预览)才额外补一档更高的,这也是分辨率和体积之间的一个具体取舍。

next/image 的项目也别以为组件包办了一切。它确实帮你生成了多档尺寸、自动补 srcset,但 sizes 这个信息只有你自己知道,组件猜不出来。尤其是 fill 模式(图片撑满一个定位容器),不写准确的 sizesnext/image 会默认按 100vw 处理,很容易让浏览器下载比实际需要大得多的资源。我在一个用了 fill 的卡片列表里就吃过这个亏,加上一句 sizes="(max-width: 768px) 50vw, 25vw" 之后,移动端 LCP 图的字节数直接掉了一半。

srcset 还有一套用密度描述符的写法,1x/2x 那种,和 w 描述符是两条路,别混用。2x 这种适合尺寸固定、只是想给高清屏补一张更清晰图的场景,比如固定 40px 的头像;而列表、banner 这种显示宽度会随视口变的,就得用 wsizes 让浏览器自己算。我早期图省事在一个响应式 banner 上写了 srcset="a.jpg 1x, [email protected] 2x",结果无论视口多窄,2 倍屏手机永远挑那张最大的 @2x,白白多下不少字节——密度描述符里没有视口这个变量,浏览器根本没机会往小了选。

还有个 App Router 项目里反复出现的坑:LCP 那张图如果是首屏关键图,next/image 上要显式加 priority,它内部会帮你去掉懒加载、补上 fetchpriority="high" 和 preload。忘了加 prioritynext/image 默认是懒加载的,等于亲手把 LCP 图往后拖。这个默认值对首屏以下的图是对的,对首屏主图就是反的,得自己判断这张图在不在首屏——又绕回开头那句话。

首屏图不能懒加载,反而要抢优先级

回到开头那个判断。原生懒加载 loading="lazy" 是个好东西,但它的适用位置是首屏以下的图——用户滚下去才可能看到的那些,晚点请求没关系,还能省首屏带宽。

1<img src="/thumb.jpg" loading="lazy" alt="列表缩略图" />

原生懒加载还有个和滚动体验相关的细节:浏览器不是等图片完全滚进视口才开始加载,而是提前一段距离(不同浏览器、不同网络下这个提前量不一样)就触发请求,尽量让用户滚到时图已经在了。所以别指望 loading="lazy" 能精确到"只加载屏幕内的",它是带提前量的近似。这也意味着首屏往下第一屏、第二屏的图,实际请求得比你想象的早——如果这几屏的图也很重,光靠懒加载省不下多少首屏带宽,还得配合前面说的分辨率控制一起做。

一旦把它加到 LCP 那张图上,语义就完全反了:浏览器会认为这张图不急,把它排到较低优先级、甚至等布局稳定后才发请求,LCP 只会更慢。所以首屏关键图不但不能懒加载,还要主动往上抢优先级。fetchpriority="high" 是最直接的信号:

1<img
2  src="/hero.jpg"
3  fetchpriority="high"
4  width="1200"
5  height="600"
6  alt="首页主图"
7/>

如果这张图不是写在初始 HTML 里,而是要等 JS 渲染后才出现(比如轮播组件动态插入),浏览器发现它的时机会偏晚,这时可以在文档头部用 preload 提前告诉它:

1<link rel="preload" as="image" href="/hero.jpg" fetchpriority="high" />

preload 是把双刃剑,不能滥用。预加载的资源会和其他关键资源抢带宽,你 preload 了五张图,真正的 LCP 图反而可能因为带宽被分薄而变慢。我的原则是:一个页面只给明确的那一个 LCP 元素 preload,别的都不加。判断依据还是回到前面——先确认 LCP 是谁,再决定给谁开小灶。

preload 一张响应式图还有个细节:如果这张图是靠 srcset/sizes 选分辨率的,<link rel="preload"> 上得把 imagesrcsetimagesizes 也带上,否则 preload 拉的是 href 那张固定图,浏览器随后按 srcset 又挑了另一张,等于下了两遍。正确写法是让 preload 和 <img> 用同一套描述符:

1<link
2  rel="preload"
3  as="image"
4  imagesrcset="/hero-400.jpg 400w, /hero-800.jpg 800w, /hero-1200.jpg 1200w"
5  imagesizes="(max-width: 600px) 100vw, 800px"
6  fetchpriority="high"
7/>

顺带说一句浏览器自己的优先级判断。Chrome 会根据图片在视口里的位置动态调整优先级:初始渲染时它并不知道哪张图在首屏,会先都按 Low 排,等布局出来发现某张图落在可视区,再把它提到 High。这套自动机制大多数时候够用,fetchpriority 和 preload 是在它判断得不够快、或者判断错了的时候,人工纠偏用的。所以别一上来给所有图都标 fetchpriority="high"——那等于把优先级信号稀释掉,浏览器又回到"都一样急"的状态,等于没标。优先级这东西是相对的,全高就是全不高。

宽高不是可选项,是防 CLS 的地基

图片不写宽高,几乎必然会带来 CLS(累积布局偏移):图片下载回来之前它占 0 高度,回来之后猛地把下面的内容顶下去,用户正读着的段落突然跳走。写上宽高,浏览器就能在图片到达之前按比例预留出空间:

1<img src="/cover.jpg" width="800" height="450" alt="文章封面" />

这里的 width/height 不必等于图片实际显示尺寸,它的作用是提供宽高比。响应式布局下,真实显示尺寸交给 CSS 控制,属性上的宽高只负责让浏览器算出这个坑该留多大:

1.cover {
2  width: 100%;
3  height: auto;
4}

现代浏览器会读 width/height 属性推导出 aspect-ratio,所以哪怕最终宽度是 100%,只要属性写了,比例就锁住了,不会塌。这里有个容易被 CSS reset 破坏的地方:很多老 reset 里写了 img { height: auto; width: auto; } 或者只写了 width: 100% 却漏了 height: auto。前者会让属性推导出的 aspect-ratio 失效——一旦你在 CSS 里把 height 显式设成 auto 之外的值或者压根覆盖了它,浏览器就不再拿属性宽高去算比例了。所以想吃到"属性宽高防 CLS"这个红利,CSS 侧最好就是干净的 width: 100%; height: auto,别在别处又把 height 写死。这个组合看着平平无奇,实际是整套防 CLS 机制生效的前提。

背景图是这条规则的盲区——CSS background-image 没有天然宽高,浏览器无从预留。如果一张背景图承载的是主要内容(比如 hero 区),容器就得自己用 aspect-ratio 或者固定比例撑开:

1.hero {
2  aspect-ratio: 16 / 9;
3  background-image: url('/hero.jpg');
4  background-size: cover;
5}

背景图想做响应式,<img> 那套 srcset 用不上,但 CSS 有对应的 image-set(),能让浏览器按 DPR 选图,到 2024 年主流浏览器已经不用前缀了:

1.hero {
2  aspect-ratio: 16 / 9;
3  background-image: image-set(
4    url('/hero-1x.jpg') 1x,
5    url('/hero-2x.jpg') 2x
6  );
7  background-size: cover;
8}

不过 image-set() 只能按 DPR 选,没有 sizes 那种按视口宽度选的能力,能力比 <img srcset> 弱一截。所以但凡这张图是页面的主要内容、又需要按视口精细选分辨率,我还是倾向把它从背景图改回 <img>,让完整的 srcset/sizes/fetchpriority/宽高这套机制都能用上。背景图更适合纯装饰性、内容语义不重要的场景——装饰图选糊一点、晚一点都无所谓,不值得为它折腾一整套响应式。

aspect-ratio 到 2024 年主流浏览器早就稳了,用它比过去那种 padding-top: 56.25% 的百分比撑高 hack 干净太多,语义也直白。

CLS 这块还有个比单张图更隐蔽的来源:字体和图混在一起的卡片。图占位留对了,但旁边的文字因为 web font 晚加载先用系统字体撑开、字体到了又回流,同样会把整块卡片顶动,看起来像图片没占好位,其实是字体在抖。排查 CLS 不能只盯图,DevTools 的 Performance 面板录一段之后,Experience 轨道里每一次 Layout Shift 都能点开看是哪个节点在动,很多时候点开发现动的根本不是图。这条提醒我自己:CLS 是"页面在用户眼皮底下跳"这个现象的统称,图片只是最常见的一个源头,不是唯一的。

监控线上真实 CLS 也有现成手段,和前面监听 LCP 是一套思路,PerformanceObserver 换个 type 就行:

1let clsValue = 0;
2new PerformanceObserver((list) => {
3  for (const entry of list.getEntries()) {
4    // 忽略用户交互(点击、输入)之后 500ms 内的位移,那类通常是预期内的
5    if (!entry.hadRecentInput) {
6      clsValue += entry.value;
7    }
8  }
9}).observe({ type: 'layout-shift', buffered: true });

hadRecentInput 这个判断很关键——用户点了"展开更多"导致内容变长,这种位移是预期内的,不该算进 CLS,浏览器已经帮你标好了,直接跳过就行。真正要治的是用户没做任何操作、页面自己跳的那些。

格式选择看内容,不是看新

图片格式不是越新越好。AVIF 压缩率确实亮眼,但它不是万能替代品,选错了反而添麻烦。我大致按内容分:图标、logo、简单矢量图用 SVG,能无损缩放还小;大多数照片和插图用 WebP,压缩率和兼容性平衡得最好;追求极致体积、且能接受编码慢一点的用 AVIF;需要透明且边缘锐利的用 PNG;实在拿不准的照片场景,JPEG 仍然是最稳的那个退路。

值得单独说的是 WebP 的质量参数别照抄 JPEG 的经验。同样标 quality=80,WebP 和 JPEG 出来的观感和体积不完全对得上,WebP 在同等感知质量下通常能再小一截,所以从 JPEG 迁 WebP 时可以把质量档往下探一探、实测对比,往往还能再省。而对纯色块多、边缘锐利的图(截图、UI 界面图),WebP 的无损模式反而比有损更合适,压得比 PNG 小又不糊。所以"照片用 WebP、界面图用 PNG"这个粗分并不总对,WebP 无损这条路容易被忽略。

这里要分清 SVG 和位图的账不是一回事。SVG 小、能缩放,但它不是所有场景都合适——一张复杂插画导出成 SVG,路径节点多到解析和渲染都慢,还不如一张 WebP。SVG 的优势场景是简单几何图形、图标、logo 这种路径少、需要任意缩放清晰的;一旦是照片级细节,位图格式反而更省。而且 SVG 直接内联进 HTML 会撑大文档、外链又多一次请求,图标多的项目更适合合成 SVG sprite 或者用图标字体统一管理,这也是个具体取舍,不能一句"矢量图就用 SVG"了事。

理想情况是让图片服务端根据请求头里的 Accept 自动返回最合适的格式——支持 AVIF 的浏览器给 AVIF,只支持 WebP 的给 WebP,都不支持的回退 JPEG,前端一张 URL 都不用改。如果没有这样的服务,就在前端用 <picture> 手动排优先级:

1<picture>
2  <source srcset="/cover.avif" type="image/avif" />
3  <source srcset="/cover.webp" type="image/webp" />
4  <img src="/cover.jpg" width="800" height="450" alt="文章封面" />
5</picture>

浏览器从上往下挑第一个它认识的 type,都不认就落到最后的 <img>。这里我踩过一个坑:<picture> 里真正决定加载的是那个 <img>width/heightloadingalt 这些属性要写在 <img> 上而不是 <source> 上,写错了宽高会不生效,CLS 又回来了。

<picture> 还有一个 srcset 做不到的能力叫美术方向(art direction):不同视口用构图完全不同的图,而不只是同一张图的不同分辨率。比如首屏 banner,宽屏用横构图带大片留白放文案,窄屏改成竖构图、主体居中,靠 <source media> 切:

1<picture>
2  <source media="(max-width: 600px)" srcset="/hero-portrait.jpg" />
3  <source media="(min-width: 601px)" srcset="/hero-landscape.jpg" />
4  <img src="/hero-landscape.jpg" width="1200" height="600" alt="活动主视觉" />
5</picture>

这和前面 srcset/sizes 那套"同一张图选分辨率"是两码事:srcset 解决"下多大",<picture media> 解决"下哪张构图"。分清这两个用途,就不会拿 <picture> 去干本该 srcset 干的活——单纯换分辨率用 srcset 更省事,只有确实要换构图时才上多个 <source media>

追新格式之前还得想清楚线上的浏览器分布。企业内网的老 WebView、某些内嵌客户端环境,对 AVIF 的支持可能还不到位,只用一个 <img src="x.avif"> 而不给回退,这部分用户就是一片裂图。稳定性优先于压缩率的场景,宁可保守。

格式之外还有个常被忽略的杠杆:图片的解码。图片下载完不等于能画到屏幕上,浏览器还要把压缩数据解码成位图,大图的解码是笔不小的主线程开销,尤其在低端手机上。默认的 decoding 是浏览器自己看着办,但你可以给非关键图显式标 decoding="async",让解码不阻塞其他渲染工作;反过来,如果一张图后面紧跟着一个依赖它尺寸的布局,decoding="sync" 反而能避免二次布局。我一般给列表里的图统一挂 loading="lazy" decoding="async",让它们既晚点请求、又不抢主线程解码时机:

1<img src="/thumb.jpg" loading="lazy" decoding="async" width="200" height="200" alt="缩略图" />

另一个和 AVIF 相关的取舍是编码成本。AVIF 压得小,代价是编码慢——同一张图转 AVIF 可能比转 WebP 慢好几倍。如果图片是构建时静态生成,这点慢无所谓,反正只编一次;但如果是用户上传、服务端实时转码的场景,AVIF 的编码延迟会直接压到接口响应时间上,这时候 WebP 往往是更平衡的选择。选格式不能只盯压缩率那一个数字,还得看这张图是"编一次用很多次"还是"每次请求现编"。

占位是为了少闪,不是为了炫技

图片到达之前的那段空白,可以用占位来降低突兀感——固定背景色、低质量模糊图(LQIP)、骨架屏、主色块,都是常见做法。但占位的目标始终是"让加载过程别那么跳",不是制造更多视觉噪声。

我见过有页面给每一张小缩略图都上复杂的 blur placeholder,一张图先加载一个内联 base64 的模糊小图,再淡入真图。结果是 HTML 体积和 CSS 都变重了,视觉上多了一层模糊到清晰的闪烁,收益还不如直接给个背景色。这里也是个具体的取舍:对首屏那张大图,用户盯着它加载、时间又相对长,LQIP 或者主色块占位是有意义的,能显著缓解"白屏等图"的焦虑;对列表里成片的小缩略图,固定宽高比加一个背景色就够了,再复杂反而拖累整体。

内联 base64 占位图还有个要留意的量级问题:base64 会把体积撑大约三分之一,而且它是直接嵌在 HTML 或者首屏 JS 里的,等于把这部分字节算进了首屏关键路径。一张 LQIP 控制在十几个像素宽、几百字节,才是"占位"该有的分量;我见过有人图方便把一张两三百像素的图 base64 进去当占位,那已经不是占位了,是又多下了一张图。next/imageplaceholder="blur" 内部也是这个思路,它生成的 blurDataURL 就是一张极小的模糊图,能用它的自动生成就别自己手搓,手搓很容易把尺寸搞大。

占位色也可以更聪明一点。纯灰底之外,可以在图片上传时顺手算出主色(dominant color)存进接口,前端拿这个主色当背景,真图淡入时的跳变会比灰底自然得多。这算不上什么新技术,但收益/成本比很高——服务端多存一个色值,前端一行 background-color,就把"等图时那块空白"从生硬的灰色变成和内容协调的色块。这条对图墙、封面流这类图片密集的页面尤其值。真图淡入也别做得太重,一个几百毫秒的 opacity 过渡足够,动画时间拉太长反而让人觉得图加载得慢,占位是为了遮掩等待,不是为了给等待再加一层戏。

排查顺序:从关键图往外扩,而不是一把梭

真正在项目里排图片性能,我不会一上来把所有图都优化一遍,而是顺着影响面从大到小走。先确认 LCP 元素到底是不是图片,不是就先别在图上纠缠;确认是图之后,第一件事永远是核对显示尺寸和下载尺寸是否匹配,这一步的收益通常最大。接着把首屏关键图的 loading="lazy" 去掉、补上 fetchpriority="high",让它尽早出发;再给所有图补齐 width/heightaspect-ratio,把 CLS 摁住。

批量核查显示尺寸和下载尺寸的错位,纯靠肉眼一张张点太慢,我会在控制台跑一小段脚本把所有超标的图列出来:

1[...document.images]
2  .filter((img) => img.naturalWidth > img.clientWidth * (window.devicePixelRatio + 0.5))
3  .forEach((img) => {
4    console.log(img.currentSrc, `下载 ${img.naturalWidth}px / 显示 ${img.clientWidth}px`);
5  });

naturalWidth 是图片解码后的真实像素宽,clientWidth 是它在页面上的 CSS 显示宽,乘上 DPR 再留半档余量作为阈值——超过这个就是明显下大了。这段脚本在任何页面控制台里贴一下就能跑,不依赖任何工具,排查存量页面时很省事。跑出来一长串的话,基本能确定这个页面的图片资源根本没做响应式,是块能立刻见效的地方。

做完这几步,首屏体验一般已经好一大半。剩下的属于收尾:把主要图片切到 WebP/AVIF 并保留兜底,首屏以下的图再挂懒加载省带宽,CDN 侧确认压缩和缓存策略配对了,最后拉上不同设备、不同网络的真实 LCP 数据做回归——毕竟本地一台高配 Mac 上量出来的数字,跟用户在弱网手机上的体感是两回事。

CDN 上的那几个参数,抵得过前端改一堆代码

前端把 srcsetfetchpriority、宽高都写对之后,还有一层容易被当成"运维的事"忽略掉,就是图片走的那条 CDN 链路。很多首屏慢,根子是同一张原图不管什么设备都原样发出去,前端的 srcset 想选小图,源站却压根没提供多档尺寸。理想的图床应该支持在 URL 上带尺寸参数,?w=400&format=auto&quality=75 这种,由 CDN 边缘按需裁剪、转码、压缩,前端只管拼 URL,多档分辨率就由服务侧现切。这样 srcset 里那几个 400w/800w 才有对应的真实资源,而不是前端一厢情愿写了描述符、背后其实只有一张大图。

缓存头也值得盯一眼。图片这类内容基本是不变的,配上 Cache-Control: public, max-age=31536000, immutable、URL 带内容哈希,浏览器和 CDN 都能长期缓存,回访直接命中不再走网络。我排查过一个"每次刷新首屏图都要重下"的问题,最后发现图片响应头里 max-age 只给了几分钟,改长之后回访 LCP 直接归零。这类问题在前端代码里一点痕迹都没有,只能去看 Network 面板里那张图的 Response Headers,或者直接 curl 一下看 cache-controlcontent-type 对不对。

还有个和 CDN 相关的隐形浪费是没开内容协商。前面说过理想情况是服务端读 Accept 头自动返回最合适的格式,但这依赖 CDN 的 Vary: Accept 配置正确——如果 Vary 没配好,CDN 可能把给 A 用户转的 AVIF 缓存下来,又原样发给一个不支持 AVIF 的 B 用户,B 就看到裂图。所以内容协商这套东西,前端拼 URL 之外,得和运维确认边缘缓存是按 Accept 分桶的,不然自动协商反而制造出难复现的兼容问题。

实验室数字和现场数字是两条线

本地量、Lighthouse 量,都是实验室数据(lab data)——固定的设备、固定的网络、冷加载一次。它能帮你在开发阶段发现问题,但它不等于用户真实体感。真实用户的 LCP、CLS 受设备档次、网络波动、缓存状态、甚至用户滚动速度影响,波动很大。所以关键页面除了本地测,还得把前面那几段监听 largest-contentful-paintlayout-shift 的代码接进真实用户监控(RUM),按 p75 这类分位数看,而不是只看自己那台 Mac 上的一次数字。

INP 于三月正式取代 FID 成为 Core Web Vitals 之后,性能监控的重心不再只是首屏那一下,交互响应也进来了,但图片这块的账基本还是 LCP 和 CLS 在算。有一点值得留意:LCP 图如果特别大,解码那一下会占住主线程,可能顺带把附近的一次交互也拖慢,这时候 LCP 和 INP 会一起变差。所以把 LCP 图控制在合理体积,不只是首屏快,连带交互都能顺一点——这几个指标背后共用的还是那根主线程。

看现场数据时我会特别关注分位数之间的差距。如果 p50 好看、p75 和 p90 很差,说明多数用户没事,但有相当一部分人在弱网或低端机上体验很糟,这种"平均值骗人"的情况在图片性能里特别常见——因为图片体积对网络最敏感,好网络下大图也很快,差网络下就原形毕露。只看均值容易漏掉这批人。

本地想模拟这批人,DevTools 的 Network 面板里把节流档调到 Slow 4G、再配合 CPU 降速,就能大致还原弱网低端机的加载体感。开发时顺手切一下,很多在快网上根本感觉不到的图片问题会立刻显形——那张原本"秒开"的大图,在节流档下会明晃晃地卡上一两秒,比看数字更有说服力。

顺着影响面排,别想着一次搞定

图片优化最重要的一条其实很朴素:别猜。先用工具找到那张真正拖慢首屏的关键图,把它的分辨率压到匹配显示尺寸,控制好加载优先级,再给页面预留稳定的空间。这几件事做对,大半的图片性能问题都不用等到上线才发现。开头那个 LCP 卡在两秒多的活动页,最后就是靠"把那张 3000 像素的 banner 换成按视口选档"这一步解决的,JS 那边动的所有东西加起来都没这一下管用。