site logo

Marico's space

今日部署 JPEG XL:转换、内容协商与安全回退实用指南

前端技术 2026-10-09 17:34:02 6

Chrome 重新支持 JPEG XL 的消息在技术社区引发了不少讨论。对于国内做 Web 的朋友来说,这件事其实挺有实际意义的——电商网站、资讯平台、落地页,图片往往占页面体积的 60-70%,而用户大多用中端机型的 4G 网络在刷。但新格式支持了不等于今天就该把所有图片转一遍直接上线。这篇说说我的做法:按渐进增强的方式接入 JPEG XL(JXL),支持的浏览器走 JXL,不支持的继续走 AVIF/WebP/JPEG,图片不会挂。

为什么 JPEG XL 值得关注

AVIF 和 WebP 早就有了,JXL 强在哪?按我的经验,有三个点值得关注:

  • 无损 JPEG 重压缩:JXL 可以把已有的 JPEG 文件再压小约 20%,而且不丢失任何一位数据。你甚至可以逆向解码回完全相同的原始 JPEG。对于存量几百 GB 的图片库,这个优势很明显:不用担心质量下降,不需要逐图 QA 复核。
  • 渐进式解码:图片先显示模糊版本,随着加载继续逐渐变清晰,网络慢的情况下用户也能早点看到内容。
  • 同质量下编码速度比 AVIF 快,尤其是高分辨率图片。构建流程里处理图片的时间因此能省不少。

短板也很明显——浏览器覆盖度。Safari 从 17 版开始支持,Chrome 正在用 Rust 重写的解码器重新上线,Firefox 还在 flag 后面藏着。所以回退方案是必须的,不是可选项。

第一步:用 libjxl 转换图片

先装 libjxl 工具集(我用 0.11.x 版本):

# macOS
brew install jpeg-xl
# Ubuntu 24.04+
sudo apt install libjxl-tools cjxl --version

下面这个脚本可以转换整个目录。JPEG 图片走无损重压缩,PNG 图片走有损压缩,distance 设为 1.0(肉眼几乎分辨不出和原图的差别):

#!/usr/bin/env bash
set -euo pipefail SRC_DIR=${1:-./public/images} find "$SRC_DIR" -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' \) -print0 |
while IFS= read -r -d '' img; do out="${img%.*}.jxl" [[ -f "$out" && "$out" -nt "$img" ]] && continue # 跳过已转换的文件 case "${img,,}" in *.jpg|*.jpeg) # 无损重压缩,可恢复为原始 JPEG cjxl "$img" "$out" --lossless_jpeg=1 -e 7 --quiet ;; *.png) # 有损压缩,distance 1.0 ~ 视觉无损 cjxl "$img" "$out" -d 1.0 -e 7 --quiet ;; esac before=$(stat -c%s "$img" 2>/dev/null || stat -f%z "$img") after=$(stat -c%s "$out" 2>/dev/null || stat -f%z "$out") echo "$(basename "$img"): $before -> $after bytes ($(( 100 - after * 100 / before ))% 更小)"
done

几个实际注意点:

  • -e(effort)参数范围是 1 到 10。7 是默认值,均衡做得比较好。调到 9-10 能再小 1-3%,但编码时间会翻好几倍,CI 里跑不划算。
  • 想验证无损 JPEG 压缩是否真的无损,运行 djxl out.jxl restored.jpg 然后 cmp restored.jpg original.jpg。没有任何输出说明两个文件完全一致。
  • 不要删原始文件。 JXL 只是新增一个变体,旧格式必须保留用于回退。

第二步:客户端用 <picture> 选择格式

这是最安全、实现最简单的方案。浏览器会按顺序检查每个 <source>,选择第一个自己支持的:

<picture> <source srcset="/images/hero.jxl" type="image/jxl"> <source srcset="/images/hero.avif" type="image/avif"> <source srcset="/images/hero.webp" type="image/webp"> <img src="/images/hero.jpg" alt="Hero banner" width="1600" height="900" loading="lazy" decoding="async">
</picture>
flowchart TD A[Browser parse picture] --> B{支持 image/jxl?} B -- 是 --> C[加载 hero.jxl] B -- 否 --> D{支持 image/avif?} D -- 是 --> E[加载 hero.avif] D -- 否 --> F{支持 image/webp?} F -- 是 --> G[加载 hero.webp] F -- 否 --> H[加载 hero.jpg]

<source> 的顺序很关键:最好的格式放前面。记得在 <img> 上声明 width 和 height,防止布局偏移(CLS)。这个问题我在很多项目里都见过。

这种方式的缺点是 HTML 比较冗长。如果 CMS 在文章正文里存的就是 <img> 标签(比如 WordPress),要全部改一遍挺费劲的。那时候建议走第三种方案。

第三步:Nginx 层做 Content Negotiation

当浏览器请求图片时,会在请求头里带上 Accept,列出自己支持的格式。服务器据此返回对应文件,不需要改 HTML(URL 依然是 hero.jpg):

# 放在 http {} 块里
map $http_accept $img_suffix { default ""; "~*image/jxl" ".jxl"; "~*image/avif" ".avif"; "~*image/webp" ".webp";
} server { listen 443 ssl; server_name example.cn; root /var/www/site; types { image/jxl jxl; } location ~* ^(?<base>/images/.+)\.(jpe?g|png)$ { add_header Vary Accept; add_header Cache-Control "public, max-age=31536000, immutable"; try_files $base$img_suffix $uri =404; }
}
sequenceDiagram participant B as Browser participant CDN as CDN participant N as Nginx B->>CDN: GET /images/hero.jpg (Accept: image/jxl,...) CDN->>N: Cache miss, forward 带上 Accept N->>N: map Accept -> .jxl, try_files N-->>CDN: hero.jxl (Content-Type: image/jxl, Vary: Accept) CDN-->>B: 返回 JXL, 按 Accept 做缓存 key

我踩过的三个坑:

  1. 缺少 Vary: Accept:CDN 缓存了 JXL 版本后直接返回给不支持的浏览器,图片就挂了。这是后果最严重的一个。
  2. CDN 不遵守 Vary: Accept:有些 CDN 会忽略这个头,或者导致缓存 key 碎片化。解决方案是在边缘节点把 Accept header 归一化成几个固定值(jxl、avif、webp、default),然后用这个值作为缓存 key。
  3. 缺少 MIME type:老版本 Nginx 的 mime.types 没有 image/jxl,文件会被以 application/octet-stream 返回。上面的 types {} 块就是用来修这个的。

用 curl 快速验证:

curl -sI -H 'Accept: image/jxl,image/*' https://example.cn/images/hero.jpg | grep -i -E 'content-type|vary'
curl -sI -H 'Accept: image/webp,image/*' https://example.cn/images/hero.jpg | grep -i content-type

上线前先测量,别急着宣布胜利

不要只看文件体积。还需要关注这几个指标:

  • LCP 在真机上测,特别是中端 Android 机型。JXL 解码比 JPEG 消耗更多 CPU,hero 图片这种关键位置必须实测,不要凭感觉。
  • 实际返回 JXL 的请求占比。把 $img_suffix 加到 Nginx 的 log_format 里就能看到。根据用户群体不同,上线初期这个比例可能只有 20-30%。
  • 存储成本:每张图现在有 3-4 个变体。大图片库的话,存储费用增加是实实在在的。

总结

JPEG XL 现在就可以试,但按增量添加的方式做,不要一股脑全换:

  1. 从现有 JPEG 库入手:用 cjxl --lossless_jpeg=1 可以在不引入质量风险的情况下减少约 20% 体积,然后用 djxl + cmp 校验。
  2. 新站点或新组件:用 <picture>,顺序是 JXL、AVIF、WebP、JPEG。
  3. 老站点或 CMS 内的内容:用 Nginx 做 content negotiation,必须带 Vary: Accept,并且 CDN 侧要做好缓存 key 策略。
  4. 保留原始文件,把转换步骤加入 CI,已转换的文件跳过,避免构建越来越慢。
  5. 监测 LCP 和 JXL 返回比例 两到四周,再决定是否推广到全站。

一个新的图片格式用错了部署方式,带不来任何速度提升。但如果回退方案扎实、缓存配置正确,带宽节省几乎是零成本的,4G 用户会明显感觉到页面加载更快了。