一个总数无法指派
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 字节正好等于每个产物的未归因部分之和。检查器没有把这个差额摊回模块,而是单独报告。这个选择很重要——摊回去会让归因看起来完整,却把包装开销伪装成业务模块,下一次排查就会指错人。
超限之后做什么
超限报告的第一反应不应该是抬高预算。正确的顺序是:
- 看
duplicated:重复的模块能不能提升为 shared,或者干脆只保留一份。 - 看
topModules:最大的贡献者是不是可以延迟加载,或者换成更小的实现。 - 看
overheadBytes:差额突然变大,先检查构建配置和 source map。 - 最后才讨论预算本身:如果产品确实需要这个体积,记录理由、调整上限,并在下一次评审时复核。
这个顺序把“预算”从一条行政红线变成了技术决策的记录。抬高预算不是禁止动作,但它必须留下原因,否则预算会在几次“特批”之后失去意义。
小结
体积要可归因,才能被指派。产物口径对应请求,模块口径对应 owner,包装开销解释差额。三者同时出现在报告里,预算才不是一句口号。
判定分 pass、warn、fail 三档,预警线不阻断发布,硬上限阻断。单模块累计上限用来抓“每个路由都合格、走完全程却重复下载”的情况。下一节处理最后一个问题:这些切好、量好的文件,怎样命名和缓存,才能让新版本立即生效、旧版本平滑退出。
阅读导航:上一节:9.1 打包、分包与按需加载 · 下一节:9.3 缓存与版本策略 。
常见错误与边界
- 只报告总字节。没有归因,超限无法指派,最后只能全体背锅。
- 把模块归因之和当成产物总字节。差额是包装开销,不能静默摊回模块。
- 预警线当成硬门禁。warn 要求人看一眼,不是阻断发布。
- 用上一次发布倒推预算。预算应该由任务路径的等待成本推导。
- 忽略单模块累计体积。多个路由各摊一份,用户走完路径仍然重复付费。
- 超限就先改预算。先看重复、再看贡献者、再看构建配置,最后才讨论上限。
- 把
duplicated一律当 bug。合理的共享也会重复,要结合访问路径判断。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。