图像优化链路 AVIF 协商、响应式图片与 LCP 改善
图片通常占页面字节数一半以上,也是 LCP 的主要构成。但多数站点的图片优化停留在"压一下质量"。真正的收益来自一整条链路:格式协商、尺寸协商、加载优先级、以及缓存与 CDN 的配合。本文按请求路径逐段处理。
一、格式协商:AVIF/WebP 的正确做法
不要只发一种新格式——Safari 老版本与部分内嵌浏览器不支持 AVIF,硬发会得到破图。正确做法是协商:
服务端协商(最省,无额外字节):
function pickFormat(accept = "") {
if (accept.includes("image/avif")) return "avif";
if (accept.includes("image/webp")) return "webp";
return "jpeg";
}
app.get("/img/*", (req, res, next) => {
const fmt = pickFormat(req.get("accept"));
req.params[0] = req.params[0].replace(/\.(jpe?g|png|webp|avif)$/i, `.${fmt}`);
res.set("vary", "Accept"); // 必须,否则 CDN 会把 AVIF 发给不支持的客户端
next();
});
vary: Accept 是关键,漏掉会造成 CDN 缓存串味(把 AVIF 缓给不支持的 UA)。若不希望引入 Vary,改用 HTML 的 <picture> 由浏览器自选。
声明式(推荐用于 LCP 图):
<picture>
<source type="image/avif" srcset="/hero-1200.avif 1200w, /hero-800.avif 800w" sizes="(max-width:800px) 100vw, 800px">
<source type="image/webp" srcset="/hero-1200.webp 1200w, /hero-800.webp 800w" sizes="(max-width:800px) 100vw, 800px">
<img src="/hero-1200.jpg" width="1200" height="675" alt="封面" fetchpriority="high" decoding="async">
</picture>
同一 type 下多个 <source> 只会被选第一个匹配的(不看 media),所以 media 条件要写在 <source> 上,格式协商用 type。
二、尺寸协商:srcset 与 DPR
浏览器真正需要的是"显示像素 = CSS 像素 × DPR"。给 375px 宽的容器发 1200px 图是纯浪费。
<img src="/cover-800.avif"
srcset="/cover-400.avif 400w, /cover-800.avif 800w, /cover-1600.avif 1600w"
sizes="(max-width: 640px) 100vw, (max-width: 1080px) 50vw, 400px"
width="800" height="450" alt="">
w 描述符配合 sizes 是主流写法,浏览器自己按 DPR 与布局算出该取哪个。用 x 描述符只处理 DPR,不处理布局宽度——响应式布局下基本不该用。
尺寸档位别贪多。经验上是 3~4 档(400/800/1600 或 320/768/1536)。档数翻倍意味着 CDN 边缘缓存命中率骤降与生成成本翻倍。
三、生成链路:一次构建而非每次请求
实时用 libvips 转码看着优雅,实则 CPU 峰值与冷启动很慢。批量预生成更稳:
// scripts/build-images.mjs
import sharp from "sharp";
const WIDTHS = [400, 800, 1600];
for (const file of inputs) {
const base = sharp(file, { failOn: "none" }).rotate(); // rotate() 处理 EXIF 方向
for (const w of WIDTHS) {
await base.clone().resize({ width: w, withoutEnlargement: true })
.webp({ quality: 78, effort: 6 }).toFile(`${out}/${stem}-${w}.webp`);
await base.clone().resize({ width: w, withoutEnlargement: true })
.avif({ quality: 50, effort: 6 }).toFile(`${out}/${stem}-${w}.avif`);
}
}
经验参数:WebP q=78、AVIF q=50(AVIF 在同等质量下可再省 15~30%);effort 构建期开高、生成期开低。
必须去掉 EXIF 中的敏感信息(GPS、设备序列号):.withMetadata({ redundancy: 1 }) 显式控制,默认 sharp 已不带 metadata,但要确认没走 .toBuffer() 前保留路径。
四、LCP 的四个决定性因素
LCP 元素九成是首屏大图。四个必做项:
fetchpriority="high"+ 不懒加载:loading="lazy"加在 LCP 图上会让指标恶化一个数量级;width/height必写:避免 CLS,同时浏览器能提前算布局;preload带完整协商:
<link rel="preload" as="image" imagesrcset="/hero-1200.avif" imagesizes="100vw" fetchpriority="high">
- CDN 边缘命中 + 长缓存:
Cache-Control: public, max-age=31536000, immutable
文件名要含内容 hash 或版本段,否则改图无法刷新。
验证手段固定用 Lighthouse + RUM 双通道:Lab 数据看趋势与回归,Field 数据(CrUX)看真实 P75。
2xx 缓存命中率的 CDN 指标 + LCP P75 这两个数一起看才有效
五、非首屏与占位
首屏以下正确懒加载,但要避免布局跳动与"白块闪烁":
<img loading="lazy" decoding="async" width="800" height="450"
style="aspect-ratio: 16/9; background: #f1f5f9" src="/cover-800.avif" alt="">
aspect-ratio + 背景色是最省事的占位方案,比 LQIP 内联 base64 更省字节。需要更精致时再上低质量占位(把图缩到 16px 宽、blur、内联 data URI),但注意内联 base64 会挤占 HTML 体积,列表页谨慎使用。
六、别忘了安全与回退
两处必须处理:
- 图片路径穿越:
/img/../../etc/passwd—— 拼路径前必须规范化 + 白名单校验目录,只允许[A-Za-z0-9._-]; - 解码炸弹:超大尺寸 PNG(几十万像素但解压后 GB 级)会打爆内存。上传侧用
sharp().metadata()先校验宽高与字节上限。
const meta = await sharp(buf, { failOn: "none" }).metadata();
if (meta.width * meta.height > 40_000_000) reject("image too large");
回退兜底:<img> 始终留在 <picture> 最后且带原生格式 src,别把所有 <source> 之外的兜底图也写成 AVIF。
小结
图片优化是一条链路而不是一个开关:**Accept 协商(或 <picture>)**决定格式,srcset + sizes决定尺寸,预生成 3~4 档决定成本,fetchpriority + 不懒加载 + width/height + preload决定 LCP,immutable + 文件名 hash决定命中。最后加上路径校验与解码上限保证安全。按这条链路逐项做完,列表页与详情页的 LCP 与总字节通常能降到原来的三分之一左右,而且是可持续维护的收益。
评论(0)
登录 后参与评论。