《互联网产品的客户端架构·端形态与体验基础》9.3 缓存与版本策略

从内容哈希、缓存头语义、版本兼容窗口和旧资源退役四条线,给出可运行的 TypeScript 缓存决策函数与八组测试,解释 immutable 的前提、HTML 与 Service Worker 为什么必须回源校验,以及灰度窗口内旧哈希文件应该保留多久。

缓存事故的两种形状

第一种形状是“新版本不生效”。用户更新后仍然看到旧页面,客服让他清缓存,清完就好了。第二种形状是“新页面引用了旧文件”。HTML 已经是新的,但它指向的 app.3.js 还没同步到边缘节点,页面白屏。两种事故的根因相反:前者缓存太久,后者缓存不一致。

缓存策略不是“把静态资源设长一点”。它是一组按资源类型分配的决策:哪些文件可以一年不变,哪些必须每次回源,哪些旧文件在退役后还要留一段时间。做出这组决策的前提,是文件名能表达内容版本——也就是内容哈希。

本节把哈希、缓存头、版本窗口和旧资源退役放在一个函数集里。文中的缓存时长是案例假设值,只有测试输出是本机真实运行结果。

内容哈希解决什么

内容哈希的规则很简单:文件内容变,文件名就变;内容不变,文件名不变。这样缓存就可以按“文件名”永久化,因为同一个 URL 永远不会对应两份不同内容。

三个前提必须同时成立:

  • 哈希由最终产物内容计算,而不是由源码或构建时间计算。源码没变但构建时间变了,不应该换名。
  • 引用方必须使用带哈希的名字。HTML、清单、Service Worker 里如果还写着旧名字,哈希就白算。
  • 哈希足够长,碰撞概率可忽略。示例用 32 位 FNV-1a 只是为了可读,真实构建应该用 128 位以上的哈希。

反过来,HTML、清单和 Service Worker 不能带内容哈希后永久缓存。它们是版本指针:内容变了但 URL 不变,或者 URL 变了但旧指针还被别人引用。它们必须回源校验。

缓存头是一张决策表

把资源类型和缓存语义写成表,比在每个部署脚本里手写字符串可靠:

类型Cache-ControlETag原因
带哈希的 JS/CSS/字体/图片public, max-age=31536000, immutable不需要内容变则换名
HTMLno-cache需要每次都校验,命中 304 也很快
资源清单no-cache需要它是版本指针
Service Workerno-cache需要必须让浏览器发现更新
接口响应no-store不需要缓存语义由业务显式声明

immutable 的语义是“在 max-age 内不要发起校验请求”,它只对带哈希的资源成立。把 HTML 设成 immutable,用户会在一年内看不到新版本;把 Service Worker 设成 immutable,浏览器连更新检查都不会发。

// file: src/build/asset-cache.ts
// tier: L1
// verify: node --test test/build/asset-cache.test.ts

/**
 * FNV-1a 32 位哈希,只用于演示“内容变则文件名变”。
 * 真实构建应使用 128 位以上的哈希,并处理超长文件的分块读取。
 */
export function contentHash(content: string): string {
  let hash = 0x811c9dc5;
  for (const byte of new TextEncoder().encode(content)) {
    hash ^= byte;
    hash = Math.imul(hash, 0x01000193) >>> 0;
  }
  return hash.toString(16).padStart(8, "0");
}

export function hashedName(name: string, content: string): string {
  const dot = name.lastIndexOf(".");
  const base = dot > 0 ? name.slice(0, dot) : name;
  const ext = dot > 0 ? name.slice(dot) : "";
  return `${base}.${contentHash(content)}${ext}`;
}

export function isImmutableName(name: string): boolean {
  return /\.[0-9a-f]{8}\.(js|css|woff2|png|svg|webp)$/.test(name);
}

export type AssetKind = "hashed" | "html" | "manifest" | "service-worker" | "api";

export interface CacheDecision {
  cacheControl: string;
  etag: boolean;
  immutable: boolean;
  reason: string;
}

const CACHE_RULES: Record<AssetKind, CacheDecision> = {
  hashed: {
    cacheControl: "public, max-age=31536000, immutable",
    etag: false,
    immutable: true,
    reason: "文件名含内容哈希,内容变化必然换名",
  },
  html: {
    cacheControl: "no-cache",
    etag: true,
    immutable: false,
    reason: "HTML 引用当前版本资源,每次都要回源校验",
  },
  manifest: {
    cacheControl: "no-cache",
    etag: true,
    immutable: false,
    reason: "清单是版本指针,不能与旧清单长期共存",
  },
  "service-worker": {
    cacheControl: "no-cache",
    etag: true,
    immutable: false,
    reason: "脚本要让浏览器发现更新,不能 immutable",
  },
  api: {
    cacheControl: "no-store",
    etag: false,
    immutable: false,
    reason: "接口响应默认不落共享缓存,由业务显式声明缓存",
  },
};

export function cacheDecision(kind: AssetKind): CacheDecision {
  return CACHE_RULES[kind];
}

export interface VersionWindow {
  current: string;
  minimum: string;
}

export interface CompatDecision {
  status: "supported" | "upgrade-required" | "unknown";
  reason: string;
}

export function parseVersion(input: string): [number, number, number] | undefined {
  const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(input);
  if (!match) return undefined;
  return [Number(match[1]), Number(match[2]), Number(match[3])];
}

export function compareVersion(left: string, right: string): number {
  const a = parseVersion(left);
  const b = parseVersion(right);
  if (!a || !b) throw new Error(`无法比较版本:${left} / ${right}`);
  for (let index = 0; index < 3; index += 1) {
    if (a[index] !== b[index]) return a[index] - b[index];
  }
  return 0;
}

export function compatDecision(client: string, window: VersionWindow): CompatDecision {
  if (!parseVersion(client) || !parseVersion(window.minimum) || !parseVersion(window.current)) {
    return { status: "unknown", reason: `版本号不可解析:${client}` };
  }
  if (compareVersion(client, window.minimum) < 0) {
    return { status: "upgrade-required", reason: `低于最低支持版本 ${window.minimum}` };
  }
  if (compareVersion(client, window.current) > 0) {
    return { status: "supported", reason: "客户端新于服务端,按向前兼容处理" };
  }
  return {
    status: "supported",
    reason: `位于兼容窗口 ${window.minimum}..${window.current}`,
  };
}

export type AssetManifest = Record<string, string>;

export interface Resolution {
  ok: boolean;
  path?: string;
  reason: string;
}

export function resolveAsset(manifest: AssetManifest, logical: string): Resolution {
  const path = manifest[logical];
  if (!path) return { ok: false, reason: `清单中没有 ${logical}` };
  return { ok: true, path, reason: "命中当前清单" };
}

/**
 * history 由旧到新排列。哈希文件从最新清单消失后,仍要在灰度窗口内保留,
 * 因为旧 HTML 或旧 Service Worker 可能继续引用它。
 */
export function retirementPlan(
  history: AssetManifest[],
  graceReleases = 1,
): { keep: string[]; drop: string[] } {
  if (history.length === 0) return { keep: [], drop: [] };
  const latest = new Set(Object.values(history[history.length - 1]));
  const cutoff = history.length - 1 - graceReleases;
  const lastSeen = new Map<string, number>();
  history.forEach((manifest, index) => {
    for (const path of Object.values(manifest)) {
      if (!latest.has(path)) lastSeen.set(path, index);
    }
  });
  const keep: string[] = [];
  const drop: string[] = [];
  for (const [path, index] of lastSeen) {
    if (index >= cutoff) keep.push(path);
    else drop.push(path);
  }
  return { keep: keep.sort(), drop: drop.sort() };
}

版本兼容窗口

缓存解决“文件对不对”,版本窗口解决“客户端和服务端能不能对话”。客户端发布不可回滚,旧版本会长期存在,所以服务端必须显式声明一个最低支持版本。

compatDecision 把客户端版本分成三种结果:supported、upgrade-required、unknown。低于最低版本要求升级;位于窗口内正常服务;高于服务端当前版本按向前兼容处理,因为新客户端可能已经会处理旧服务端没有的字段。无法解析的版本返回 unknown,调用方应该按“不支持”处理写路径,而不是假设它是最新版。

窗口不是越宽越好。每多支持一个旧版本,服务端就多一份兼容分支和测试成本。窗口的宽度应该由真实版本分布决定,而不是由“保守一点”决定。

旧资源怎么失效

带哈希的文件不会互相覆盖,但会不断累积。删除太早,旧 HTML 或旧 Service Worker 可能还在引用;删除太晚,存储和部署清单会越来越重。

退役计划按“最后一次出现”计算:某个哈希文件从最新清单消失后,在灰度窗口内继续保留,超过窗口才删除。测试里有一个细节——同一个哈希可能在连续两版都出现,如果按“出现过的版本”逐条判断,它会同时进入保留和删除两个集合。实现改成记录“最后一次出现的下标”,每个文件只归入一边。

这个问题是测试抓出来的,不是评审看出来的。退役逻辑看着简单,边界却在“同一个值出现多次”上。

测试与真实输出

测试覆盖八种情况:哈希稳定性、文件名格式、缓存头、版本窗口、版本比较、清单解析、退役窗口、空历史。完整测试文件如下:

// file: test/build/asset-cache.test.ts
// tier: L1
// verify: node --test test/build/asset-cache.test.ts

import { test } from "node:test";
import assert from "node:assert/strict";
import {
  cacheDecision,
  compatDecision,
  compareVersion,
  contentHash,
  hashedName,
  isImmutableName,
  resolveAsset,
  retirementPlan,
} from "../../src/build/asset-cache.ts";

test("内容哈希稳定、定长,且不同内容得到不同值", () => {
  assert.equal(contentHash("body{color:red}"), "053ccf13");
  assert.equal(contentHash("body{color:red}"), contentHash("body{color:red}"));
  assert.match(contentHash("回声"), /^[0-9a-f]{8}$/);
  assert.notEqual(contentHash("回声"), contentHash("回声 "));
});

test("哈希文件名保留目录与扩展名", () => {
  const name = hashedName("assets/app.js", "body{color:red}");
  assert.equal(name, "assets/app.053ccf13.js");
  assert.equal(isImmutableName(name), true);
  assert.equal(isImmutableName("assets/app.js"), false);
});

test("缓存头按资源类型区分", () => {
  const hashed = cacheDecision("hashed");
  assert.match(hashed.cacheControl, /immutable/);
  assert.match(hashed.cacheControl, /max-age=31536000/);
  assert.equal(hashed.etag, false);

  const html = cacheDecision("html");
  assert.equal(html.cacheControl, "no-cache");
  assert.equal(html.etag, true);
  assert.equal(html.immutable, false);

  assert.equal(cacheDecision("api").cacheControl, "no-store");
  assert.equal(cacheDecision("service-worker").immutable, false);
});

test("版本窗口区分支持、强制升级与未知", () => {
  const window = { current: "2.4.0", minimum: "2.0.0" };
  assert.equal(compatDecision("2.2.0", window).status, "supported");
  assert.equal(compatDecision("1.9.9", window).status, "upgrade-required");
  assert.equal(compatDecision("2.4", window).status, "unknown");
  assert.match(compatDecision("2.5.0", window).reason, /新于服务端/);
});

test("版本比较按三段数字,不按字符串", () => {
  assert.equal(compareVersion("2.10.0", "2.9.9"), 1);
  assert.equal(compareVersion("2.0.0", "2.0.0"), 0);
  assert.throws(() => compareVersion("2.x.0", "2.0.0"), /无法比较版本/);
});

test("清单解析命中与缺失都给出明确原因", () => {
  const manifest = { "app.js": "app.053ccf13.js" };
  assert.deepEqual(resolveAsset(manifest, "app.js"), {
    ok: true,
    path: "app.053ccf13.js",
    reason: "命中当前清单",
  });
  const missing = resolveAsset(manifest, "vendor.js");
  assert.equal(missing.ok, false);
  assert.match(missing.reason, /vendor\.js/);
});

test("退役计划在灰度窗口内保留旧哈希文件", () => {
  const history = [
    { app: "app.1.js", old: "old.1.js" },
    { app: "app.1.js", late: "late.2.js" },
    { app: "app.3.js" },
  ];
  assert.deepEqual(retirementPlan(history, 1), {
    keep: ["app.1.js", "late.2.js"],
    drop: ["old.1.js"],
  });
  assert.deepEqual(retirementPlan(history, 2), {
    keep: ["app.1.js", "late.2.js", "old.1.js"],
    drop: [],
  });
});

test("空历史没有可保留或可删除的资源", () => {
  assert.deepEqual(retirementPlan([]), { keep: [], drop: [] });
});

在实验工程根目录执行:

node --test test/build/asset-cache.test.ts

本机一次运行的真实输出如下,耗时随机器变化:

✔ 内容哈希稳定、定长,且不同内容得到不同值 (0.592875ms)
✔ 哈希文件名保留目录与扩展名 (0.159ms)
✔ 缓存头按资源类型区分 (0.073667ms)
✔ 版本窗口区分支持、强制升级与未知 (0.169875ms)
✔ 版本比较按三段数字,不按字符串 (0.688292ms)
✔ 清单解析命中与缺失都给出明确原因 (0.609625ms)
✔ 退役计划在灰度窗口内保留旧哈希文件 (0.194584ms)
✔ 空历史没有可保留或可删除的资源 (0.069458ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 191.552666

真实构建还要多测两件事:哈希是否只由内容决定(同一份源码重复构建应得到相同名字),以及清单里每个逻辑名是否都能解析到实际存在的文件。前者防止缓存失效,后者防止发布时引用到已经被清理的旧产物。

小结

缓存与版本策略的核心是让 URL 表达内容:内容哈希负责“变”,缓存头负责“存多久”,版本窗口负责“旧客户端还能不能用”,退役计划负责“旧文件什么时候删”。四者缺一不可。

最容易犯的错是把 immutable 当成通用优化,或者把 HTML 和 Service Worker 也设成长期缓存。它们是指针,不是内容。指针必须回源,内容才可以永久缓存。

下一章进入渲染路径:CSR、SSR、SSG 与流式渲染。构建产物决定了“有什么可发”,渲染方式决定了“用户先看到什么”。

阅读导航:上一节:9.2 产物分析与体积预算 · 下一节:10.1 CSR、SSR、SSG 与流式 。

常见错误与边界

  • 用构建时间或源码哈希代替产物哈希。内容没变却换名,缓存全部失效。
  • 给 HTML 设 immutable。用户会在 max-age 内看不到任何新版本。
  • 给 Service Worker 设长期缓存。浏览器连更新检查都不会发出。
  • 哈希文件名只在 JS 里使用,HTML 仍引用旧名字。哈希没有闭环。
  • 版本窗口只写“最低版本”,不处理高于当前版本的客户端。新客户端会被误判。
  • 无法解析的版本当成最新版。写路径应该 fail closed(失败时按不支持处理)。
  • 旧文件一退役就删。灰度窗口内旧 HTML 和旧 Service Worker 仍可能引用。
  • 退役判断按“出现过的版本”逐条累加。同一哈希会同时进入保留与删除集合,必须按最后出现位置归类。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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