《互联网产品的客户端架构·内容、社交与实时互动》4.3 图片格式选择与端上降级

同一张封面图,在支持 AVIF 的手机上清晰又小,在旧 WebView 里却可能直接白屏。本节把能力探测、格式顺序、尺寸变体、压缩参数与版本化 URL 串成一条可测策略:只在解码探测通过时使用新格式,按 DPR 选最小可用变体,解码失败沿降级链回退,并用内容哈希路径保证缓存不会串图。

本节不做什么

本节不讨论服务端转码与图片 CDN 的实现,也不重复 3.3 的懒加载、占位与 CLS。这里只回答一个问题:端上已经拿到一组候选资源(不同格式、不同宽度)之后,如何选择、失败时如何降级、URL 如何版本化,才能既清晰又不串图。

一张图在三种设备上的三种结果

同一张 2400×1600 的封面,在三台设备上会遇到完全不同的约束:

设备解码能力屏幕期望结果
新款手机AVIF + WebP + JPEGDPR 3640–960px 的 AVIF,清晰且流量小
中端 Android WebViewWebP + JPEGDPR 2.75640px 的 WebP
老平板只有 JPEGDPR 2640px 的 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 时退到更小的变体,但不要把网络类型当成永久状态——它会在会话中变化。

选择规则是两级排序:

  1. 先筛掉当前设备不能解码的格式;
  2. 在剩余变体里选「宽度不小于 target 的最小一张」;同宽度时按格式顺序取更省流量的那个。

没有够大的变体时取最大一张,不做无限放大。变体集合应当是离散的(320 / 640 / 960 / 1280 / 1920),不要为每个可能的宽度动态生成,否则缓存命中率会崩。

压缩参数不能跨格式比较

一个常见的误会是「质量 80 就是同一个画质」。不同编码器的 quality 标尺互不相通:

内容类型AVIFWebPJPEG
照片527282
UI 截图587886
文字截图638490

表里的数字是示例起点,不是实测最优值。需要说明两点:

  • 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。上线后收到两类反馈:一部分用户看到旧图,一部分看到新图;更奇怪的是,同一用户在刷新几次后看到了「半新半旧」的拼接图。

根因有三层:

  1. 图片 CDN 按 URL 做缓存键,覆盖字节不会让缓存失效,不同边缘节点的过期时间还不一样。
  2. 浏览器侧设置了 immutable,命中缓存的用户根本不会回源。
  3. 转码服务按 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 也失败时应显示占位并允许重试,而不是无限重试。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查