预算不是数字,是承诺
「回声」客户端曾经有一个很具体的线上问题:首包从 180KB 涨到 250KB,没有任何一次发布说明提到它。每次需求都只增加几 KB,代码评审看的是功能,构建日志看的是成功,直到低端机上的冷启动时间翻倍,团队才开始追溯是哪几个依赖把包体撑大的。
包体预算的作用不是“限制开发”,而是把“这次发布愿意付出多少启动成本”变成一个事先写下的承诺。没有承诺,每次新增依赖都是局部最优;有了承诺,新增依赖必须回答“从哪一笔预算里出”。
预算要可执行,必须满足三个条件:口径固定、测量位置固定、失败方式固定。口径固定指压缩方式、是否包含 source map、是否包含懒加载路由;测量位置固定指在 CI 的产物上量,不在开发机上看;失败方式固定指超限必须让流水线变红,而不是只在报告里标黄。
文中所有预算数字、体积、耗时都是案例假设值,用来演示判定过程,不是任何真实产品的实测。
四类资源,四本账
首包
首包是用户打开应用必须下载并执行的最小集合:入口脚本、首屏必需的样式、运行时和共享依赖。它决定“能不能开始渲染”,所以最贵。
首包预算通常按传输大小计算,并且要写清楚压缩算法。gzip 与 brotli 的结果可能差 15% 到 25%,不写清楚就会出现“本地 180KB、线上 220KB”的争论。source map 不计入传输预算,但要单独设一个产物体积上限,避免调试文件失控。
路由包
路由包是按页面拆分的增量代码。预算应该按“单个路由自己的代码”计算,而不是按整个应用的 chunk 总和。一个路由包太大,用户从首页跳到该页时仍要等很久;一个路由包太小,拆得太碎又会增加请求数与缓存失效面。
共享代码要有明确归属:要么算进首包,要么单独设共享 chunk 预算。最忌讳的是“共享代码谁都不管”,最后每个路由都偷偷带一份。
图片
图片预算要区分“首屏关键图”和“滚动后才出现的图”。本节的检查器接收的是“一次首屏的资产清单”,所以图片预算按该清单内图片总量计算;单张图片的上限应该在生成清单前就裁掉,而不是等总量超了再回头找。
格式优先级通常是 AVIF/WebP 优于 JPEG/PNG,但不要把格式当成免死金牌。一张 4000 像素宽的 hero 图,即使压成 AVIF 也可能超预算;尺寸、裁切和懒加载比格式更早决定体积。
字体
字体预算容易被忽略,因为字体文件通常不在 JavaScript 分析器里。一个中文字体动辄几 MB,完整引入会让首包预算瞬间失效。可行做法是子集化、只保留实际用到的字重,并设置 font-display 避免阻塞文本渲染。
字体数量也要限制。三个字重、两种字体族,乘起来就是六次下载;每个文件都不大,加起来仍然可观。预算表里应该同时写“总字节”和“文件数”,后者防止用很多小文件绕开总量。
内存预算:峰值与斜率
包体回答“下载多少”,内存回答“运行时装不装得下”。移动端应用被系统杀掉,很多时候不是 CPU 慢,而是峰值内存超过了设备给进程的额度。
内存预算至少看两个量:峰值与增长斜率。峰值回答“会不会被杀”,斜率回答“是不是在泄漏”。只看峰值会漏掉缓慢增长,只看斜率会漏掉一次性的大分配。
采样同样需要注入时钟。生产环境每分钟记一次堆内存,测试环境用手动推进的假时钟,几条样本就能覆盖“峰值超限”“斜率超限”“稳定通过”三个分支。
预算怎么定
可操作的顺序是:
- 选定目标设备与网络分位,例如“中端安卓、4G、p75”。
- 测当前版本在该条件下的首包、路由、图片、字体与内存分布。
- 把预算定在“当前值再收紧一档”,而不是一步到位抄竞品。
- 每次发布只允许预算不变或变小;要变大必须走一次显式评审。
下面是一组案例假设值,仅用于演示判定结构:
| 条目 | 预算 | 说明 |
|---|---|---|
| 首包 | 200KB | brotli 传输大小,不含 source map |
| 单路由包 | 120KB | 该路由自有代码,不含首包 |
| 图片总量 | 400KB | 一次首屏清单内的图片 |
| 字体总量 | 100KB | 子集化后的 woff2 |
| 总传输 | 800KB | 上述条目之和 |
| 内存峰值 | 200MB | 目标设备 p75 |
| 内存增长 | 10MB/min | 连续使用十分钟的斜率 |
这些数字不是行业标准。换一个产品、换一个目标设备,合理的预算就会变。重要的是数字被写下来、被测量、被流水线执行。
完整检查器
下面的实现包含体积检查、内存检查与注入时钟的内存采样器。它不读文件系统,也不依赖第三方包;CI 负责把构建产物解析成 AssetRecord[],检查器只负责规则与判定。
// file: src/quality/bundle-budget.ts
// tier: L1
// verify: node --test test/quality/bundle-budget.test.ts
export type AssetKind = "entry" | "route" | "image" | "font";
export interface AssetRecord {
path: string;
kind: AssetKind;
bytes: number;
/** route 类型必填:该资源属于哪个路由包 */
route?: string;
}
export interface SizePolicy {
entry: number;
route: number;
image: number;
font: number;
total: number;
}
export interface BudgetIssue {
code: "asset-over" | "route-over" | "total-over";
target: string;
actual: number;
limit: number;
}
export interface BundleReport {
totals: Record<AssetKind, number>;
totalBytes: number;
routes: Record<string, number>;
issues: BudgetIssue[];
status: "pass" | "fail";
}
export interface MemorySample {
atMs: number;
heapMb: number;
}
export interface MemoryPolicy {
peakMb: number;
growthMbPerMinute: number;
}
export interface MemoryVerdict {
peakMb: number;
growthMbPerMinute: number;
status: "pass" | "fail";
reasons: string[];
}
export interface MemorySampler {
sample(heapMb: number): MemorySample;
samples(): readonly MemorySample[];
}
/**
* 体积预算检查器只吃已经解析好的资产清单,不读文件系统。
* 这样 CI 里的文件扫描、gzip/brotli 测量都可以在外面做,
* 预算规则本身可以在普通单元测试里被完整覆盖。
*/
export function checkBundleBudget(
assets: readonly AssetRecord[],
policy: SizePolicy,
): BundleReport {
const totals: Record<AssetKind, number> = {
entry: 0,
route: 0,
image: 0,
font: 0,
};
const routes: Record<string, number> = {};
const issues: BudgetIssue[] = [];
for (const asset of assets) {
if (!Number.isInteger(asset.bytes) || asset.bytes < 0) {
throw new Error(`资产 ${asset.path} 的 bytes 必须是非负整数`);
}
totals[asset.kind] += asset.bytes;
if (asset.kind === "route") {
if (!asset.route) {
throw new Error(`路由资产 ${asset.path} 缺少 route`);
}
routes[asset.route] = (routes[asset.route] ?? 0) + asset.bytes;
}
}
for (const kind of Object.keys(totals) as AssetKind[]) {
if (kind === "route") {
// route 预算按单个路由包比对,聚合总量由 total 兜底,避免同一超限报两次。
continue;
}
if (totals[kind] > policy[kind]) {
issues.push({
code: "asset-over",
target: kind,
actual: totals[kind],
limit: policy[kind],
});
}
}
for (const [route, bytes] of Object.entries(routes)) {
if (bytes > policy.route) {
issues.push({
code: "route-over",
target: route,
actual: bytes,
limit: policy.route,
});
}
}
const totalBytes = Object.values(totals).reduce((sum, value) => sum + value, 0);
if (totalBytes > policy.total) {
issues.push({
code: "total-over",
target: "total",
actual: totalBytes,
limit: policy.total,
});
}
return {
totals,
totalBytes,
routes,
issues,
status: issues.length === 0 ? "pass" : "fail",
};
}
/**
* 内存预算同时看峰值与斜率。
* 峰值回答“会不会被系统杀掉”,斜率回答“是不是在慢慢泄漏”。
*/
export function checkMemoryBudget(
samples: readonly MemorySample[],
policy: MemoryPolicy,
): MemoryVerdict {
if (samples.length < 2) {
throw new Error("checkMemoryBudget: 至少需要 2 个内存样本");
}
for (let index = 1; index < samples.length; index += 1) {
if (samples[index].atMs <= samples[index - 1].atMs) {
throw new Error("checkMemoryBudget: 样本时间必须严格递增");
}
}
const peakMb = Math.max(...samples.map((sample) => sample.heapMb));
const first = samples[0];
const last = samples[samples.length - 1];
const minutes = (last.atMs - first.atMs) / 60_000;
const growthMbPerMinute = (last.heapMb - first.heapMb) / minutes;
const reasons: string[] = [];
if (peakMb > policy.peakMb) {
reasons.push(`峰值 ${peakMb.toFixed(1)}MB 超过上限 ${policy.peakMb}MB`);
}
if (growthMbPerMinute > policy.growthMbPerMinute) {
reasons.push(
`增长 ${growthMbPerMinute.toFixed(2)}MB/min 超过上限 ${policy.growthMbPerMinute}MB/min`,
);
}
return {
peakMb,
growthMbPerMinute,
status: reasons.length === 0 ? "pass" : "fail",
reasons,
};
}
/**
* 时钟由外部注入:生产里用 performance.now,测试里用手动推进的假时钟。
* 这样采样器本身不依赖定时器,也不需要等待真实时间流逝。
*/
export function createMemorySampler(clock: () => number): MemorySampler {
const collected: MemorySample[] = [];
return {
sample(heapMb: number): MemorySample {
if (!Number.isFinite(heapMb) || heapMb < 0) {
throw new Error(`memory sampler: heapMb 必须是非负有限数,收到 ${heapMb}`);
}
const sample: MemorySample = { atMs: clock(), heapMb };
collected.push(sample);
return sample;
},
samples(): readonly MemorySample[] {
return collected;
},
};
}
有三个设计取舍值得说明。
第一,route 类型的聚合总量不参与 asset-over 判定。否则一个路由超限会同时报“route 总量超限”和“单个路由超限”,同一个问题出现两次。总量由最后的 total 兜底。
第二,检查器不做压缩。它接收的是已经测好的字节数,压缩算法、是否 gzip、是否 brotli 都由上游决定。这样规则可以稳定测试,测量口径变化时只需要改上游。
第三,内存斜率为负时不会报错。内存下降是好事,status 仍然是 pass;只有峰值或正增长超限才失败。
测试与真实输出
实验工程里有 8 个测试,覆盖全通过、单路由聚合、多类同时超限、非法输入、内存峰值、内存斜率、注入时钟与样本校验。在工程根目录执行:
node --test test/quality/bundle-budget.test.ts
本机一次运行的真实输出如下,耗时随机器变化:
✔ 全部资源都在预算内时状态为 pass (0.837ms)
✔ 路由包按路由聚合后再比对预算 (0.080917ms)
✔ 单类资源与总量可同时报告 (0.125792ms)
✔ 非法字节数与缺少 route 的资产会抛错 (0.606458ms)
✔ 内存峰值超限判失败 (0.180417ms)
✔ 内存斜率超限判失败 (0.069917ms)
✔ 采样器使用注入时钟,稳定内存判通过 (0.098875ms)
✔ 样本时间不递增或数量不足会抛错 (0.076666ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 149.9365
“单类资源与总量可同时报告”这个用例里,入口脚本、图片、字体分别超限,总量也超限,检查器一次返回四条问题。这正是 CI 需要的行为:一次构建暴露全部超限项,而不是修一个跑一次。
在 CI 里卡住
检查器返回 status: "fail" 只是数据,流水线还要把它变成退出码。推荐的做法是:
- 构建完成后生成资产清单 JSON,包含路径、类型、路由与字节数。
- 在 Node 脚本里读取清单,调用
checkBundleBudget。 status === "fail"时打印每条BudgetIssue,然后process.exitCode = 1。- 把报告作为构建产物保留,方便对比上一次的预算变化。
内存检查通常不在每次构建里跑,而是在性能测试任务里跑:启动应用、按脚本操作十分钟、采样堆内存,再调用 checkMemoryBudget。它比体积检查更慢,但能抓到泄漏;体积检查快,适合每个 PR 都跑。
预算失败时不要自动放宽阈值。放宽阈值应该是显式提交,并且在评审里说明“为什么这次必须变大、下一次从哪里还回来”。否则预算会像橡皮筋,第一次拉长之后就再也回不去。
一次预算失守的复盘
某次版本(案例假设)里,首包从 180KB 涨到 250KB,单路由 checkout 从 90KB 涨到 130KB。CI 没有拦住,因为当时只看了构建成功,没有体积门禁。复盘发现三笔增量:
- 引入了一个完整的日期库,实际只用了格式化,增加约 35KB。
- 图标从按需引入改成整包引入,增加约 25KB。
- 结算页把两个共享组件复制进了路由包,增加约 20KB。
如果当时有本节的门禁,首包会先报 asset-over,checkout 会报 route-over,总量可能同时报 total-over。修复顺序也会很清楚:先换掉日期库的整包引入,再把图标改回按需,最后把复制进路由的共享组件提回首包或共享 chunk。
预算不能自动告诉你“哪个依赖该换”,但它能保证问题在合并前出现,而不是在低端机用户投诉后出现。
小结
包体预算要分四本账:首包、路由包、图片、字体;内存预算要看峰值与斜率。预算数字必须从目标设备与网络反推,写清楚压缩口径与测量位置,并且用 CI 的非零退出码执行。
检查器本身应该保持纯粹:不读文件系统、不做压缩、不依赖第三方库。测量在上游,规则在函数里,测试就能覆盖所有分支。下一节讨论更棘手的问题:首屏快、功能全、包体小这三件事无法同时满足时,优先级到底怎么排。
阅读导航:上一节:3.1 首屏、可交互与流畅度 · 下一节:3.3 不可能三角与优先级 。
常见错误与边界
- 不写压缩口径。gzip 与 brotli 的差异足以让预算结论反转。
- 把 source map 算进传输预算。它不该发给用户,但应单独限制产物体积。
- 共享代码没有归属。既不算首包也不设共享 chunk 预算,最后每个路由偷偷带一份。
- 路由包按全应用 chunk 总和算。单个路由仍然可能大到无法接受。
- 图片只看格式不看尺寸。一张超大 AVIF 仍然会超预算。
- 字体不子集化。中文字体整包引入可以单独吃掉全部预算。
- 内存只看峰值。缓慢增长的泄漏在十分钟内可能还没到峰值。
- 预算超了就直接放宽阈值。没有回补计划的放宽会让门禁失效。
- 只在开发机上看包体。CI 产物与本地构建的依赖、压缩、tree shaking 都可能不同。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。