图片格式选型没有银弹:WebP、AVIF、JPEG XL 我踩过的坑和最后的取舍
去年接手一个图片量很大的前端项目,首页光是首屏就有二十来张商品图,Lighthouse 天天在 LCP 上给我打红叉。老板不懂技术但会看数字,PSI 分数低于 90 就来问我"是不是又出问题了"。那阵子我把 WebP、AVIF、JPEG XL 挨个折腾了一遍,也在生产上翻过车。这篇就把当时的判断过程、命令、代码和踩的坑记下来,希望能省掉后来人一些时间。
先说结论,免得你没耐心看完:到 2024 年这个时间点,WebP 是安全底盘,AVIF 值得上但要处理好降级,JPEG XL 我个人很喜欢但暂时只能在特定场景玩票。下面展开。
为什么不能只用 JPEG 了
JPEG 三十多年的老格式,编解码器满地都是,兼容性无懈可击。问题在于它的压缩效率放到今天确实吃亏——同样的主观画质,WebP 大概能再省 25%~30%,AVIF 在低码率下能省到一半甚至更多。对于图片密集型页面,这个体积差直接翻译成首屏时间和带宽账单。
我做过一次很粗糙的对比,拿同一张 1600×1067 的风景照,Q 值都调到肉眼几乎看不出差别的程度:
原始 JPEG (quality 82): 412 KB
cwebp -q 80: 268 KB
avifenc --min 24 --max 28: 191 KB
数字很诱人,但"肉眼几乎看不出差别"这句话本身就是坑,后面细说。
WebP:先把这块地基打牢
WebP 是我建议任何项目都先接入的格式。Can I Use 上它的覆盖率已经到 96% 以上,除了几个上古浏览器和特定企业环境,基本不用担心。命令行工具 cwebp 是 Google 的 libwebp 自带的:
# 有损,photo 类照片
cwebp -q 80 input.jpg -o output.webp
# 无损,适合 UI 图标、纯色块、需要保边的图
cwebp -lossless input.png -o output.webp
# 带 alpha 的 PNG 转有损 WebP,alpha 单独控制质量
cwebp -q 80 -alpha_q 100 logo.png -o logo.webp
有个细节很多人忽略:-q 对有损 WebP 控制的是主体压缩质量,而 alpha 通道默认是无损的,你可以用 -alpha_q 单独降。我遇到过一个半透明遮罩图,整体转出来还是偏大,最后发现是 alpha 通道太"实诚",降到 90 之后体积掉了一大截,视觉零感知。
WebP 的短板是它在极低码率下会糊得比较难看,块效应和色带(banding)比 AVIF 明显。渐变天空、人像皮肤这类平滑过渡区域尤其容易翻车。所以 WebP 我一般不敢把 q 压到 70 以下。
AVIF:省得多,但脾气也大
AVIF 基于 AV1 的帧内编码,压缩效率是这三个里最狠的一个,低码率下的表现明显好过 WebP,色带控制也更好。浏览器支持这两年补得很快,Chrome、Firefox、Safari 新版本都认了。
但我要泼两盆冷水。
第一盆:编码慢,而且慢得离谱。avifenc 默认速度下编一张几 MB 的图能让你等到怀疑人生。构建时一定要调 speed 参数:
# speed 0 最慢但画质最高,10 最快。构建流水线里我一般用 6
avifenc --min 20 --max 28 --speed 6 input.jpg output.avif
# 想要更细控制,直接给量化区间,值越小质量越高(0-63)
avifenc --min 24 --max 30 --speed 6 -j all input.png output.avif
-j all 是吃满所有 CPU 核心,批量转码时能救命。我第一次没加这参数,几百张图跑了快一个小时,加上之后降到十几分钟。
第二盆:小图上 AVIF 未必划算。AVIF 的容器和头部有固定开销,对于那种几 KB 的小图标、小缩略图,转成 AVIF 之后可能反而比 WebP 甚至 PNG 还大。我踩过这个坑——一批 48×48 的分类小图批量转 AVIF,结果总体积不降反升。后来我给转码脚本加了一条规则:小于某个像素阈值的图直接走 WebP,不碰 AVIF。
还有一点是解码 CPU 开销。AVIF 解码比 JPEG/WebP 重,在低端安卓机上,如果一屏塞几十张 AVIF,滚动时能感觉到掉帧。图片特别多的长列表页要拿真机测一下,别只看体积。
JPEG XL:技术上我最看好,但现实很骨感
JPEG XL(jxl)是我个人觉得设计得很聪明的格式。它支持从现有 JPEG 无损转码(cjxl 能把 JPEG 重新打包,体积再降 20% 左右且完全可逆),渐进式解码做得好,画质和压缩比都在线。理论上它是想同时干掉 JPEG 和 WebP 的活。
# 普通图片编码,-q 是质量 0-100
cjxl input.png output.jxl -q 90
# 从已有 JPEG 无损转码,能还原成原始 JPEG 字节,适合归档
cjxl photo.jpg photo.jxl --lossless_jpeg=1
那个 --lossless_jpeg=1 我觉得是它的杀手锏——你可以把海量存量 JPEG 转 jxl 存起来省空间,需要时又能一字节不差地还原回 JPEG。对做图片归档的场景很有价值。
问题出在浏览器。Chrome 一度在实验标志后面支持过 jxl,后来又把它撤了,理由是生态收益不够。这一撤基本判了它在 Web 前端的"缓刑"。现在 Safari 新版本倒是原生支持了,但你没法只靠一个浏览器就在生产环境铺开。所以目前 jxl 我只在两类场景用:一是后端图片归档存储,二是明确知道客户端是 Safari 或自有 App(能控制解码器)的场景。纯 Web 前端,我暂时不敢把它放进降级链的主路径。
降级策略:<picture> 是正道
浏览器支持参差不齐,靠 UA 嗅探去发不同格式又脏又不可靠。正确姿势是用 <picture> 元素让浏览器自己挑,它会按 source 顺序取第一个 type 认识的:
<picture>
<source srcset="hero.avif" type="image/avif" />
<source srcset="hero.webp" type="image/webp" />
<img
src="hero.jpg"
alt="产品主图"
width="1600"
height="1067"
loading="lazy"
decoding="async"
/>
</picture>
几个容易被忽略的点:
<img>上的width/height一定要写,否则布局会跳(CLS 直接爆)。写了浏览器才能提前预留出宽高比的位置。loading="lazy"对首屏图不要加,首屏图反而应该尽早加载,甚至考虑fetchpriority="high"。lazy 只给折叠下方的图。- source 顺序就是优先级,把体积最优的 AVIF 放最前,WebP 兜底,
<img>里的 JPEG 是最终保底。
如果你还要响应式尺寸,把 srcset + sizes 叠上去:
<picture>
<source
type="image/avif"
srcset="hero-800.avif 800w, hero-1600.avif 1600w"
sizes="(max-width: 768px) 100vw, 800px"
/>
<source
type="image/webp"
srcset="hero-800.webp 800w, hero-1600.webp 1600w"
sizes="(max-width: 768px) 100vw, 800px"
/>
<img src="hero-800.jpg" alt="产品主图" width="800" height="533" />
</picture>
这块手写 HTML 很快就会膨胀到没法维护,所以真正落地一定要靠构建时自动生成,别人肉写。
用 sharp 把转码自动化
Node 生态里 sharp(底层是 libvips)是我用下来最省心的转码库,速度快、内存占用低,AVIF/WebP 都支持。批量出多格式多尺寸大概长这样:
const sharp = require("sharp");
const path = require("path");
const SIZES = [800, 1600];
// 小图不转 avif 的阈值,踩过坑加的
const AVIF_MIN_WIDTH = 200;
async function transcode(input, outDir) {
const base = path.basename(input, path.extname(input));
const meta = await sharp(input).metadata();
for (const w of SIZES) {
if (w > meta.width) continue; // 别放大
const pipeline = sharp(input).resize({ width: w });
await pipeline
.clone()
.webp({ quality: 80 })
.toFile(path.join(outDir, `${base}-${w}.webp`));
if (w >= AVIF_MIN_WIDTH) {
await pipeline
.clone()
.avif({ quality: 55, effort: 4 })
.toFile(path.join(outDir, `${base}-${w}.avif`));
}
}
}
几个实战经验:
- sharp 的 avif
quality和 avifenc 的量化区间不是一个刻度,我一般从 50~60 起调,拿几张代表性图片对照着眼睛定。 effort(对应编码速度)在 CI 里别开太高,4 是我觉得画质和构建时长比较平衡的值。追极致体积的静态资源可以开到 6~7,但构建会明显变慢。clone()很关键,同一个 pipeline 复用会导致状态污染,每种输出前先 clone。
服务端内容协商:另一条路
除了 <picture> 在客户端选,还有一条路是服务端根据请求头 Accept 来决定回哪个格式。浏览器请求图片时会带上它能吃的格式:
Accept: image/avif,image/webp,image/apng,*/*
服务端解析这个头,同一个 URL 回不同格式的图。Nginx 里可以用 map 做个简易版:
map $http_accept $img_ext {
default "jpg";
"~*image/avif" "avif";
"~*image/webp" "webp";
}
location ~* ^/img/(.+)\.(jpg|jpeg|png)$ {
set $base $1;
try_files /img/$base.$img_ext /img/$base.$2 =404;
add_header Vary Accept; # 这行不能漏
}
Vary: Accept 这个响应头一定要加,否则 CDN 和浏览器缓存会把 AVIF 的响应喂给不支持 AVIF 的客户端,直接给你整出一堆裂图。我见过因为漏了 Vary 头导致部分用户全站图片显示不出来的事故,排查起来还特别费劲,因为它只在特定浏览器 + 命中缓存时复现。
内容协商的好处是 HTML 干净、URL 稳定、对 SEO 友好;坏处是服务端要维护多份图,逻辑分散在网关层,出问题不好查。图片量不大、能全静态预生成的项目,我更倾向 <picture>;有图片服务或走 CDN 图片处理的,服务端协商更顺。像一些图片处理平台(自建的或用 tudingai.cn 这类在线工具批处理)能直接吐出多格式产物,能省掉不少手工转码的活。
一个容易被数据骗到的地方
回到前面那句"肉眼几乎看不出差别"。体积对比表很好看,但纯看 PSNR 或者体积去调质量参数,很容易调出一张"数字很棒、看着难受"的图。人眼对不同内容的敏感度差异极大——文字边缘、人脸、平滑渐变,这些区域压狠了立刻就露馅,而复杂纹理(草地、树叶)压很多都看不出来。
所以我最后的做法是:不同类型的图走不同 quality 档位。人像和带文字的图守住高质量线,风景纹理类可以压狠一点。真要上线前,一定拿几张最有代表性的图,在真机上、在实际显示尺寸下用眼睛过一遍,别信任何单一指标。SSIM、butteraugli 这些感知指标可以做参考和批量筛查,但拍板还得靠眼睛。
诚实说说这套方案的边界
写到这我得把话说全,免得误导人。
我上面这套(WebP 打底 + AVIF 增强 + <picture> 降级 + sharp 构建时转码)适合的是图片相对可控、能在构建时预处理的站点,比如营销页、商品页、内容站。如果你的图片是用户实时上传、需要动态裁剪水印的,那构建时转码这条路根本走不通,得上图片处理服务或边缘计算,是另一套完全不同的工程。
AVIF 的解码开销我前面提了,超长图片流的场景要额外权衡,别无脑全 AVIF。JPEG XL 我很看好但它在 Web 上的前途现在还悬着,我没把握说它一年后是什么局面,所以生产环境请谨慎,别把它放进关键降级链。
还有我没展开的:动图(WebP 和 AVIF 都支持动图,但工具链成熟度和 GIF 替换的坑够再写一篇)、HDR 广色域图片的色彩管理、以及渐进式加载体验。这些每一块都能单独踩很久的坑。
图片格式这事说到底没有标准答案,全看你的图片类型、用户设备分布和团队能维护的复杂度。工具和数字只是参考,最后拍板的永远是你对自己业务的判断。

浙公网安备 33010602011771号