本节不做什么
本节不讨论服务端转码与图片 CDN 的实现,也不重复 3.3 的懒加载、占位与 CLS。这里只回答一个问题:端上已经拿到一组候选资源(不同格式、不同宽度)之后,如何选择、失败时如何降级、URL 如何版本化,才能既清晰又不串图。
一张图在三种设备上的三种结果
同一张 2400×1600 的封面,在三台设备上会遇到完全不同的约束:
| 设备 | 解码能力 | 屏幕 | 期望结果 |
|---|---|---|---|
| 新款手机 | AVIF + WebP + JPEG | DPR 3 | 640–960px 的 AVIF,清晰且流量小 |
| 中端 Android WebView | WebP + JPEG | DPR 2.75 | 640px 的 WebP |
| 老平板 | 只有 JPEG | DPR 2 | 640px 的 JPEG |
如果只按 UA 判断格式,第二台设备可能拿到 AVIF 白图;如果只发一种大图,第三台设备会下载几倍于屏幕所需的字节;如果只按屏幕宽度选图而忽略 DPR,新款手机上会糊。格式、尺寸、降级三件事必须一起设计。
能力探测:能解码才算支持
最常见的错误是读 Accept 头:
Accept: image/avif,image/webp,image/apng,image/*,*/*;q=0.8
它只说明客户端声明支持,不保证当前 WebView 的解码器真的可用。定制 ROM、裁剪过的 WebView、某些小程序容器都会出现「头里有 AVIF,解码返回失败」的组合。Safari 直到 16.4 才支持 AVIF,之前版本即使 UA 看起来很近也只能走 JPEG。
可靠的探测是实际解码一张 1×1 的目标格式图:
- 用
new Image()加载一张极小的 AVIF,监听load/error; - 或用
createImageBitmap(blob),await成功才算通过; - 探测结果写进会话级缓存,key 里带上 App 版本与 WebView 版本,升级后重测。
探测是异步的,首屏不能等它。实践做法是:首屏用最保守的 JPEG,探测完成后把结果写入缓存,后续图片再走完整策略;或者让服务端按 Accept 返回首屏图,端上只在后续请求里做格式优化。无论哪种,降级链必须保留,因为探测通过也不等于每一张图都能解码(损坏文件、超大尺寸、内存不足都会失败)。
格式顺序与降级链
顺序是固定的:AVIF → WebP → JPEG。
- AVIF 在同视觉质量下通常比 WebP 更小,经验区间是 20%–30%,具体依内容而定,这是行业常见区间而非本机实测。
- WebP 相对 JPEG 的经验区间是 25%–35%, photographic 内容收益更明显,纯色 UI 图收益很小。
- JPEG 是兜底:所有目标环境都能解,代价是体积。
降级链不是一个静态列表,而是一个运行时状态:当前格式解码失败时,取链上的下一档;如果下一档也失败,继续;到 JPEG 仍失败,说明资源本身有问题,应该显示占位并提供重试,而不是无限循环。
失败信号有两个来源:<img> 的 error 事件,以及 img.decode() 返回的 rejected Promise。后者能捕获「下载成功但解码失败」的情况,前者覆盖网络与格式错误。两个都要接。
尺寸变体:CSS 像素乘 DPR
目标宽度不是屏幕物理宽度,而是 CSS 宽度 × DPR,并且 DPR 要封顶:
target = ceil(cssWidth × min(dpr, 2))
封顶 2 的理由是收益递减:3x 屏上把 640px 的图换成 960px,视觉提升有限,字节数接近线性增长。对信息流这种一屏多图的场景,2x 已经足够;只有全屏大图或可缩放的详情页才值得放开。
省流模式(navigator.connection.saveData)直接把 DPR 降到 1,并优先选择压缩率更高的格式。弱网下还可以参考 effectiveType,在 2g / slow-2g 时退到更小的变体,但不要把网络类型当成永久状态——它会在会话中变化。
选择规则是两级排序:
- 先筛掉当前设备不能解码的格式;
- 在剩余变体里选「宽度不小于 target 的最小一张」;同宽度时按格式顺序取更省流量的那个。
没有够大的变体时取最大一张,不做无限放大。变体集合应当是离散的(320 / 640 / 960 / 1280 / 1920),不要为每个可能的宽度动态生成,否则缓存命中率会崩。
压缩参数不能跨格式比较
一个常见的误会是「质量 80 就是同一个画质」。不同编码器的 quality 标尺互不相通:
| 内容类型 | AVIF | WebP | JPEG |
|---|---|---|---|
| 照片 | 52 | 72 | 82 |
| UI 截图 | 58 | 78 | 86 |
| 文字截图 | 63 | 84 | 90 |
表里的数字是示例起点,不是实测最优值。需要说明两点:
- AVIF 的 52 与 JPEG 的 82 可能看起来接近,但把 JPEG 的 82 直接套给 AVIF 会显著增大文件,收益却很小。
- 文字截图需要更高保真,因为色度子采样和块效应在文字边缘特别明显;照片可以更低。
正确做法是按格式分别做小规模对比(同场景、同尺寸、肉眼或 SSIM),把结论写进配置,而不是从别的格式抄一个数字。
版本化 URL:换字节必须换地址
图片缓存有一条铁律:同一个 URL 永远返回同一份字节。 一旦违反,CDN、浏览器、Service Worker 三层缓存会各自返回不同版本,用户看到什么完全取决于命中哪个节点。
三种版本化方式:
| 方式 | 示例 | 风险 |
|---|---|---|
| 查询参数 | /a.jpg?v=3 | 部分 CDN/代理忽略查询串,缓存键不含它 |
| 文件名 | /a.9f3c1a2b.jpg | 需要改扩展名规则,部分工具链不友好 |
| 路径段 | /media/a/9f3c1a2b/w640.avif | 需要服务端路由支持,最稳 |
推荐路径段:/media/{id}/{contentHash}/w{width}.{format}。内容哈希进路径后,可以放心设置 Cache-Control: public, max-age=31536000, immutable——URL 变了,缓存自然失效;URL 没变,字节一定没变。
不要用人工递增的 v1、v2:多端同时发布容易撞号,回滚时也无法判断两个 v2 是不是同一份内容。内容哈希没有这个问题,代价是每次上传都要算一次(服务端算,端上只消费)。
旧版本的资源要保留一个回滚窗口。用户可能还开着旧页面,CDN 边缘也可能还没过期;立刻删除会让这批请求 404。
格式策略实现
// file: src/upload/format-policy.ts
// tier: L1
// verify: node --test test/upload/format-policy.test.ts
export type ImageFormat = "avif" | "webp" | "jpeg";
export type ImageContent = "photo" | "ui" | "text";
/**
* 这里的布尔值代表「本机解码探测通过」,不是 Accept 头里的 MIME 声明。
* 顺序即压缩效率顺序,jpeg 永远排在最后作为兜底。
*/
export interface DecodeCapability {
avif: boolean;
webp: boolean;
jpeg: boolean;
}
export interface VariantSpec {
width: number;
format: ImageFormat;
}
export interface PickInput {
displayWidth: number;
dpr: number;
saveData: boolean;
caps: DecodeCapability;
}
export interface MediaRef {
id: string;
version: string;
width: number;
format: ImageFormat;
}
const ORDER: readonly ImageFormat[] = ["avif", "webp", "jpeg"];
const HIGHEST_DPR = 2;
const VERSION_PATTERN = /^[0-9a-f]{8,64}$/;
// 示例值:跨格式的 quality 标尺不可比,只能按格式各自调。
const QUALITY: Record<ImageFormat, Record<ImageContent, number>> = {
avif: { photo: 52, ui: 58, text: 63 },
webp: { photo: 72, ui: 78, text: 84 },
jpeg: { photo: 82, ui: 86, text: 90 },
};
export function formatPreference(caps: DecodeCapability): ImageFormat[] {
return ORDER.filter((format) => caps[format]);
}
export function qualityFor(format: ImageFormat, content: ImageContent): number {
return QUALITY[format][content];
}
export function pickVariant(input: PickInput, variants: VariantSpec[]): VariantSpec | null {
if (!Number.isFinite(input.displayWidth) || input.displayWidth <= 0) {
throw new RangeError("displayWidth 必须大于 0");
}
if (!Number.isFinite(input.dpr) || input.dpr <= 0) {
throw new RangeError("dpr 必须大于 0");
}
const allowed = formatPreference(input.caps);
const rank = new Map<ImageFormat, number>(allowed.map((format, index) => [format, index]));
const rankOf = (format: ImageFormat): number => rank.get(format) ?? Number.MAX_SAFE_INTEGER;
const usable = variants.filter(
(variant) => rank.has(variant.format) && Number.isInteger(variant.width) && variant.width > 0,
);
if (usable.length === 0) return null;
const dpr = input.saveData ? 1 : Math.min(input.dpr, HIGHEST_DPR);
const target = Math.ceil(input.displayWidth * dpr);
const wider = usable
.filter((variant) => variant.width >= target)
.sort((a, b) => a.width - b.width || rankOf(a.format) - rankOf(b.format));
if (wider.length > 0) return wider[0];
// 没有够大的变体时退回最大一张:宁可放大,也不要给出比目标更糊的图。
const narrower = [...usable].sort(
(a, b) => b.width - a.width || rankOf(a.format) - rankOf(b.format),
);
return narrower[0];
}
export function fallbackChain(preferred: ImageFormat): ImageFormat[] {
return ORDER.slice(ORDER.indexOf(preferred));
}
export function nextFormatAfterDecodeFailure(
chain: ImageFormat[],
failed: ImageFormat,
): ImageFormat | null {
const index = chain.indexOf(failed);
if (index < 0) return null;
return chain[index + 1] ?? null;
}
export function buildMediaUrl(ref: MediaRef): string {
if (!VERSION_PATTERN.test(ref.version)) {
throw new RangeError("version 必须是 8 到 64 位小写十六进制内容哈希");
}
if (!Number.isInteger(ref.width) || ref.width <= 0) {
throw new RangeError("width 必须是正整数");
}
const segments = ref.id
.split("/")
.filter((segment) => segment !== "" && segment !== "." && segment !== "..");
if (segments.length === 0) throw new RangeError("id 不能为空");
return `/media/${segments.join("/")}/${ref.version}/w${ref.width}.${ref.format}`;
}
export function cacheControlFor(ref: MediaRef): string {
if (!VERSION_PATTERN.test(ref.version)) return "public, max-age=300";
return "public, max-age=31536000, immutable";
}
逐段解释
探测结果只是输入
DecodeCapability 的三个布尔值代表「本机解码探测通过」,不是 MIME 声明。把探测与策略分开,策略层就是纯函数,可以在 Node 里穷举设备组合;探测层则在真实浏览器里跑一次,结果缓存。两层之间只有一个对象,替换探测实现不影响选择逻辑。
变体选择是两级排序
pickVariant 先按能力过滤,再按「宽度升序、格式优先级」排序。rankOf 把格式顺序变成数字,宽度相同时 AVIF 排在 WebP 前面。没有够大的变体时退回最大一张,是因为「稍微放大」比「明显更糊」更可接受;如果连一个可用变体都没有,返回 null,由调用方决定是显示原图还是占位。
降级链是纯数组操作
fallbackChain("avif") 返回 ["avif", "webp", "jpeg"],nextFormatAfterDecodeFailure 返回下一档或 null。没有状态机、没有全局变量,失败时把当前格式传进去就能得到下一步。真正的状态在调用方:它需要记住「这张图已经失败到哪一档」,避免每次重渲染都从 AVIF 重新试。
quality 表是起点不是结论
qualityFor 只是把配置集中到一个地方,方便按格式和内容类型调整。它不承诺视觉质量,也不应该被当成跨格式的换算表。上线前要用真实素材做对比,尤其是文字截图和带透明通道的图。
URL 版本进路径
buildMediaUrl 校验版本必须是 8–64 位小写十六进制内容哈希,宽度必须是正整数,并对 id 做路径段清理(去掉空段、. 与 ..)。这些校验不是形式主义:版本格式错了会让 immutable 失效,id 里混入 .. 则可能穿越到别的资源目录。
边界:宁可拒绝,不要猜
非法版本、零宽度、空 id 都直接抛 RangeError。图片 URL 一旦生成就会被缓存很久,错误的值比崩溃更难修复;让它在构造阶段失败,比在生产环境里出现「所有图都 404」要好。
测试与真实输出
$ node --test test/upload/format-policy.test.ts
✔ 格式顺序按解码能力过滤,jpeg 永远最后 (0.849ms)
✔ 按 dpr 计算目标宽度并封顶 2 倍,选不小于目标的最小变体 (0.1805ms)
✔ 省流模式把 dpr 降到 1,不跟随高分屏 (0.089666ms)
✔ 同宽度优先更省流量的格式,没有够大的变体时取最大一张 (0.597958ms)
✔ 解码失败按 avif → webp → jpeg 降级,jpeg 之后没有下一档 (0.161417ms)
✔ 压缩参数按格式与内容分别取值 (0.107417ms)
✔ 版本进路径才能 immutable,非法版本与宽度直接拒绝 (0.37575ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 117.656833
七条用例覆盖的是策略层最容易出错的判断:格式过滤、DPR 封顶、省流降级、同宽度排序、降级链终点、质量参数分组、URL 与缓存约束。它们不需要浏览器,因为探测结果在测试里是直接构造的输入。
复盘:URL 没变,图变了
运营在后台替换了一张封面,文件覆盖到同一个 URL。上线后收到两类反馈:一部分用户看到旧图,一部分看到新图;更奇怪的是,同一用户在刷新几次后看到了「半新半旧」的拼接图。
根因有三层:
- 图片 CDN 按 URL 做缓存键,覆盖字节不会让缓存失效,不同边缘节点的过期时间还不一样。
- 浏览器侧设置了
immutable,命中缓存的用户根本不会回源。 - 转码服务按 URL 缓存了派生尺寸,卡片小图与大图分别命中不同批次的缓存,用户看到「换了但没完全换」的封面。
修复方式不是清 CDN,而是改 URL 契约:上传时由服务端计算内容哈希,所有派生 URL 都带哈希路径段;替换封面等于生成一组新 URL,旧 URL 保留 30 天。清缓存从「每次发布都要做的运维动作」变成了「不需要做的事」。
另一个事故来自 UA 判断:某定制 WebView 的 UA 里带着 Chrome 版本号,但解码器被裁剪,按 UA 下发的 AVIF 全部白屏。修复是加解码探测与 error / decode() 双重降级,并在监控里记录「探测通过但解码失败」的比例——这个比例一旦上升,说明设备矩阵里出现了新的异常组合。
小结
图片策略可以拆成五个纯函数:能力过滤、变体选择、降级链、质量参数、URL 构造。能力探测要在真实设备上做,结果只是策略的输入;尺寸按 CSS 宽度乘封顶后的 DPR;质量参数按格式分别调;URL 必须带内容哈希并允许长期缓存。把这些规则写进可测试的代码,设备矩阵再复杂也有一个确定的兜底路径。
下一章离开媒体资源,进入互动:点赞、收藏、评论这些「本地先行、服务端为准」的操作,如何在乐观更新与回滚之间取得平衡。
阅读导航:上一节:4.2 断点续传与失败恢复 · 下一节:5.1 点赞与收藏:本地先行、服务端为准 。
常见错误与边界
- 用 UA 或
Accept判断格式。声明支持不等于能解码,定制 WebView 会给出反例。 - 探测结果永久缓存。App 或 WebView 升级后解码能力可能变化,缓存 key 要带版本。
- 只发一种格式或一张大图。前者浪费流量,后者在低端设备上解码压力大。
- DPR 不封顶。3x 屏的文件体积接近线性增长,视觉收益却很小。
- 把 JPEG 的 quality 直接套给 AVIF。两套标尺不可比,结果通常是大而不优。
- 在同一 URL 上覆盖图片字节。CDN、浏览器、派生缓存会各自返回不同版本。
- 用
v1、v2人工递增做版本。多端发布容易撞号,回滚时无法判断内容。 - 降级链没有终点。JPEG 也失败时应显示占位并允许重试,而不是无限重试。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。