《互联网产品的客户端架构·端形态与体验基础》3.2 包体与内存预算

把首包、路由包、图片、字体与运行内存拆成可执行的预算条目,说明预算如何从目标设备与网络反推、如何在 CI 中以非零状态卡住,并给出不依赖文件系统的 TypeScript 体积与内存预算检查器、注入时钟的采样器、真实测试输出与一次预算失守的复盘。

预算不是数字,是承诺

「回声」客户端曾经有一个很具体的线上问题:首包从 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 慢,而是峰值内存超过了设备给进程的额度。

内存预算至少看两个量:峰值与增长斜率。峰值回答“会不会被杀”,斜率回答“是不是在泄漏”。只看峰值会漏掉缓慢增长,只看斜率会漏掉一次性的大分配。

采样同样需要注入时钟。生产环境每分钟记一次堆内存,测试环境用手动推进的假时钟,几条样本就能覆盖“峰值超限”“斜率超限”“稳定通过”三个分支。

预算怎么定

可操作的顺序是:

  1. 选定目标设备与网络分位,例如“中端安卓、4G、p75”。
  2. 测当前版本在该条件下的首包、路由、图片、字体与内存分布。
  3. 把预算定在“当前值再收紧一档”,而不是一步到位抄竞品。
  4. 每次发布只允许预算不变或变小;要变大必须走一次显式评审。

下面是一组案例假设值,仅用于演示判定结构:

条目预算说明
首包200KBbrotli 传输大小,不含 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" 只是数据,流水线还要把它变成退出码。推荐的做法是:

  1. 构建完成后生成资产清单 JSON,包含路径、类型、路由与字节数。
  2. 在 Node 脚本里读取清单,调用 checkBundleBudget。
  3. status === "fail" 时打印每条 BudgetIssue,然后 process.exitCode = 1。
  4. 把报告作为构建产物保留,方便对比上一次的预算变化。

内存检查通常不在每次构建里跑,而是在性能测试任务里跑:启动应用、按脚本操作十分钟、采样堆内存,再调用 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 都可能不同。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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