《互联网产品的客户端架构·端形态与体验基础》9.2 产物分析与体积预算

把构建产物的体积拆成产物、模块与包装开销三个口径,给出按模块归因、按预算阈值判定 pass/warn/fail 的 TypeScript 检查器与八组测试,讨论重复模块、贡献榜和超限后的处理顺序,并说明为什么抬高预算通常是最后而不是最先做的动作。

一个总数无法指派

9.1 把产物切开了,但切开不等于可控。某次发布后首包只涨了 18 KB,团队觉得可以接受;两周后又涨了 22 KB,仍然“问题不大”。三个月后首包翻倍,没人能说清是哪一次改动带来的。体积问题和内存泄漏一样,单次变化很小,累积起来才发现,而那时已经找不到起点。

预算的作用不是禁止增长,而是让每一次增长都有主人。要做到这一点,报告必须同时回答三个问题:哪个产物超了、哪个模块带来的、超了多少。只给一个总数的报告,最后只会变成一句“大家注意一下”。

本节把体积拆成三个口径,并给出一个可运行的检查器。文中的阈值与字节数都是案例假设值,只有测试输出是本机真实运行结果。

三个口径

第一个口径是产物:app.js、route-order.js、logo.png 各自多大。它对应网络请求,决定用户要下载几个文件、每个文件多大。首包和单个路由包的上限应该分别设置,因为二者的等待场景不同。

第二个口径是模块:某个 npm 包或业务模块在全部产物里累计占多少。它对应“谁带来的”,也是唯一能直接指派给团队的口径。一个日期库出现在三个产物里,按产物看是三份,按模块看是一个来源加两次重复。

第三个口径是包装开销:产物字节减去模块归因之和。它包含运行时、模块包装、被保留的注释、source map 引用等。开销不是错误,但如果它突然变大,通常意味着构建配置变了,而不是业务代码变了。

三个口径必须同时出现。只看产物,无法指派;只看模块,会忽略重复;只看总字节,会把开销当成业务增长。

阈值与判定

检查器把结果分成三档:

  • pass:所有指标低于预警线。
  • warn:有指标达到预警线但未超限。它不阻断发布,只要求有人看一眼。
  • fail:任意指标超过硬上限。它阻断发布。

预警线默认取上限的 90%,这是案例假设值,不是行业标准。团队可以按发布节奏调整,但必须写进配置,不能靠 reviewer 的记忆。硬上限则应该按“用户在这条路径上愿意等多久”推导,而不是按“上次发布是多少”倒推。

单模块上限是最容易被忽略的一条。总量没超,不代表没有问题:一个 200 KB 的图表库可能被三个路由分摊,每个路由看起来都合格,但用户一旦走完三条路径就要付出三次下载。单模块上限按累计口径计算,正好抓这种情况。

// file: src/build/size-budget.ts
// tier: L1
// verify: node --test test/build/size-budget.test.ts

export type ArtifactKind = "initial" | "route" | "asset";

export interface Artifact {
  name: string;
  kind: ArtifactKind;
  bytes: number;
  /** 模块字节归因;模块之和可以小于产物字节,差额是运行时与包装开销 */
  modules?: Record<string, number>;
}

export interface BudgetPolicy {
  initial: number;
  route: number;
  total: number;
  /** 单个模块在所有产物中的累计上限 */
  module?: Record<string, number>;
  /** 达到上限该比例即预警,默认 0.9 */
  warnRatio?: number;
}

export type Verdict = "pass" | "warn" | "fail";

export interface BudgetIssue {
  level: "warn" | "fail";
  kind: "initial" | "route" | "total" | "module";
  target: string;
  actual: number;
  limit: number;
  ratio: number;
}

export interface SizeReport {
  verdict: Verdict;
  byKind: Record<ArtifactKind, number>;
  moduleBytes: Record<string, number>;
  duplicated: Array<{ module: string; artifacts: string[] }>;
  overheadBytes: number;
  topModules: Array<{ module: string; bytes: number }>;
  issues: BudgetIssue[];
}

function sum(values: Iterable<number>): number {
  let total = 0;
  for (const value of values) total += value;
  return total;
}

/**
 * 按产物、按模块两个口径归因体积,再对照预算给出 pass / warn / fail。
 * 预警只表示接近上限,不改变通过与否;超限一律 fail。
 */
export function analyzeSize(
  artifacts: Artifact[],
  policy: BudgetPolicy,
  topLimit = 5,
): SizeReport {
  const byKind: Record<ArtifactKind, number> = { initial: 0, route: 0, asset: 0 };
  const moduleBytes: Record<string, number> = {};
  const moduleArtifacts = new Map<string, Set<string>>();
  let overheadBytes = 0;

  for (const artifact of artifacts) {
    byKind[artifact.kind] += artifact.bytes;
    const modules = artifact.modules ?? {};
    const attributed = sum(Object.values(modules));
    overheadBytes += Math.max(0, artifact.bytes - attributed);
    for (const [name, bytes] of Object.entries(modules)) {
      moduleBytes[name] = (moduleBytes[name] ?? 0) + bytes;
      const owners = moduleArtifacts.get(name) ?? new Set<string>();
      owners.add(artifact.name);
      moduleArtifacts.set(name, owners);
    }
  }

  const warnRatio = policy.warnRatio ?? 0.9;
  const issues: BudgetIssue[] = [];
  const check = (
    kind: BudgetIssue["kind"],
    target: string,
    actual: number,
    limit: number,
  ): void => {
    if (limit <= 0) return;
    const ratio = actual / limit;
    if (ratio > 1) {
      issues.push({ level: "fail", kind, target, actual, limit, ratio });
    } else if (ratio >= warnRatio) {
      issues.push({ level: "warn", kind, target, actual, limit, ratio });
    }
  };

  check("initial", "initial", byKind.initial, policy.initial);
  for (const artifact of artifacts) {
    if (artifact.kind === "route") {
      check("route", artifact.name, artifact.bytes, policy.route);
    }
  }
  check("total", "total", sum(Object.values(byKind)), policy.total);
  for (const [name, limit] of Object.entries(policy.module ?? {})) {
    check("module", name, moduleBytes[name] ?? 0, limit);
  }

  issues.sort((left, right) => {
    if (left.level !== right.level) return left.level === "fail" ? -1 : 1;
    if (left.ratio !== right.ratio) return right.ratio - left.ratio;
    return left.target.localeCompare(right.target);
  });

  const duplicated = [...moduleArtifacts]
    .filter(([, owners]) => owners.size > 1)
    .map(([module, owners]) => ({ module, artifacts: [...owners].sort() }))
    .sort((left, right) => left.module.localeCompare(right.module));

  const topModules = Object.entries(moduleBytes)
    .map(([module, bytes]) => ({ module, bytes }))
    .sort((left, right) => right.bytes - left.bytes || left.module.localeCompare(right.module))
    .slice(0, topLimit);

  const verdict: Verdict = issues.some((issue) => issue.level === "fail")
    ? "fail"
    : issues.length > 0
      ? "warn"
      : "pass";

  return { verdict, byKind, moduleBytes, duplicated, overheadBytes, topModules, issues };
}

报告里的 duplicated 不是错误,而是线索。它说明同一个模块被多个产物包含,可能是合理的共享,也可能是本该提升到 shared 却没有提升。overheadBytes 则用于解释“模块之和为什么小于总字节”,避免有人把差额当成统计错误。

测试与真实输出

测试覆盖八种情况:全部通过、预警、首包超限、单路由超限、总量与单模块、重复归因、包装开销、排序。完整测试文件如下:

// file: test/build/size-budget.test.ts
// tier: L1
// verify: node --test test/build/size-budget.test.ts

import { test } from "node:test";
import assert from "node:assert/strict";
import { analyzeSize } from "../../src/build/size-budget.ts";
import type { Artifact, BudgetPolicy } from "../../src/build/size-budget.ts";

const artifacts: Artifact[] = [
  {
    name: "app",
    kind: "initial",
    bytes: 100,
    modules: { runtime: 40, shell: 30 },
  },
  {
    name: "route-order",
    kind: "route",
    bytes: 80,
    modules: { order: 50, runtime: 20 },
  },
  {
    name: "route-profile",
    kind: "route",
    bytes: 60,
    modules: { profile: 40, runtime: 10 },
  },
  { name: "logo", kind: "asset", bytes: 10 },
];

const policy: BudgetPolicy = {
  initial: 150,
  route: 100,
  total: 400,
  module: { runtime: 100 },
};

test("全部在上限内时通过", () => {
  const report = analyzeSize(artifacts, policy);
  assert.equal(report.verdict, "pass");
  assert.deepEqual(report.issues, []);
  assert.equal(report.byKind.initial, 100);
  assert.equal(report.byKind.route, 140);
  assert.equal(report.byKind.asset, 10);
});

test("达到预警比例时给出 warn 但不判失败", () => {
  const report = analyzeSize(artifacts, { ...policy, initial: 105 });
  assert.equal(report.verdict, "warn");
  assert.equal(report.issues.length, 1);
  assert.equal(report.issues[0].level, "warn");
  assert.equal(report.issues[0].kind, "initial");
  assert.ok(report.issues[0].ratio > 0.9);
});

test("首包超限直接失败", () => {
  const report = analyzeSize(artifacts, { ...policy, initial: 90 });
  assert.equal(report.verdict, "fail");
  assert.ok(report.issues.some((issue) => issue.kind === "initial" && issue.level === "fail"));
});

test("单个路由包超限只影响该路由", () => {
  const report = analyzeSize(artifacts, { ...policy, route: 70 });
  assert.equal(report.verdict, "fail");
  const routeIssues = report.issues.filter((issue) => issue.kind === "route");
  assert.deepEqual(routeIssues.map((issue) => issue.target), ["route-order"]);
  assert.equal(routeIssues[0].actual, 80);
});

test("总量与单模块上限分别生效", () => {
  const total = analyzeSize(artifacts, { ...policy, total: 200 });
  assert.ok(total.issues.some((issue) => issue.kind === "total"));
  const module = analyzeSize(artifacts, { ...policy, module: { runtime: 60 } });
  assert.ok(module.issues.some((issue) => issue.kind === "module" && issue.target === "runtime"));
});

test("跨产物重复的模块被识别,归因仍按实际出现累计", () => {
  const report = analyzeSize(artifacts, policy);
  assert.deepEqual(report.duplicated, [
    { module: "runtime", artifacts: ["app", "route-order", "route-profile"] },
  ]);
  assert.equal(report.moduleBytes.runtime, 70);
});

test("未被归因的包装开销单独报告", () => {
  const report = analyzeSize(artifacts, policy);
  assert.equal(report.overheadBytes, 60);
  assert.equal(
    Object.values(report.moduleBytes).reduce((sum, bytes) => sum + bytes, 0) + 60,
    250,
  );
});

test("贡献榜按字节降序,超限项按比例排在前", () => {
  const report = analyzeSize(artifacts, { ...policy, initial: 90 });
  assert.deepEqual(
    report.topModules.map((entry) => entry.module),
    ["runtime", "order", "profile", "shell"],
  );
  assert.equal(report.issues[0].kind, "initial");
  assert.equal(report.issues[0].level, "fail");
});

在实验工程根目录执行:

node --test test/build/size-budget.test.ts

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

✔ 全部在上限内时通过 (0.951667ms)
✔ 达到预警比例时给出 warn 但不判失败 (0.621917ms)
✔ 首包超限直接失败 (0.203125ms)
✔ 单个路由包超限只影响该路由 (0.17775ms)
✔ 总量与单模块上限分别生效 (0.112334ms)
✔ 跨产物重复的模块被识别,归因仍按实际出现累计 (0.098791ms)
✔ 未被归因的包装开销单独报告 (0.073625ms)
✔ 贡献榜按字节降序,超限项按比例排在前 (0.075125ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 184.910292

测试里有一组数字值得核对:四个产物合计 250 字节,模块归因合计 190 字节,差额 60 字节正好等于每个产物的未归因部分之和。检查器没有把这个差额摊回模块,而是单独报告。这个选择很重要——摊回去会让归因看起来完整,却把包装开销伪装成业务模块,下一次排查就会指错人。

超限之后做什么

超限报告的第一反应不应该是抬高预算。正确的顺序是:

  1. 看 duplicated:重复的模块能不能提升为 shared,或者干脆只保留一份。
  2. 看 topModules:最大的贡献者是不是可以延迟加载,或者换成更小的实现。
  3. 看 overheadBytes:差额突然变大,先检查构建配置和 source map。
  4. 最后才讨论预算本身:如果产品确实需要这个体积,记录理由、调整上限,并在下一次评审时复核。

这个顺序把“预算”从一条行政红线变成了技术决策的记录。抬高预算不是禁止动作,但它必须留下原因,否则预算会在几次“特批”之后失去意义。

小结

体积要可归因,才能被指派。产物口径对应请求,模块口径对应 owner,包装开销解释差额。三者同时出现在报告里,预算才不是一句口号。

判定分 pass、warn、fail 三档,预警线不阻断发布,硬上限阻断。单模块累计上限用来抓“每个路由都合格、走完全程却重复下载”的情况。下一节处理最后一个问题:这些切好、量好的文件,怎样命名和缓存,才能让新版本立即生效、旧版本平滑退出。

阅读导航:上一节:9.1 打包、分包与按需加载 · 下一节:9.3 缓存与版本策略 。

常见错误与边界

  • 只报告总字节。没有归因,超限无法指派,最后只能全体背锅。
  • 把模块归因之和当成产物总字节。差额是包装开销,不能静默摊回模块。
  • 预警线当成硬门禁。warn 要求人看一眼,不是阻断发布。
  • 用上一次发布倒推预算。预算应该由任务路径的等待成本推导。
  • 忽略单模块累计体积。多个路由各摊一份,用户走完路径仍然重复付费。
  • 超限就先改预算。先看重复、再看贡献者、再看构建配置,最后才讨论上限。
  • 把 duplicated 一律当 bug。合理的共享也会重复,要结合访问路径判断。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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