首包是一个发布决策
「回声」的首页在某一版里从 180 KB 涨到 340 KB。来源不是新功能,而是三个团队各自“顺手”引用了一个日期库:内容流用它格式化时间,私信用它算相对时间,下单页用它校验预约日期。构建日志只有一个总数,没有人知道是谁带来的。复盘时最有用的一句话不是“以后注意体积”,而是“我们缺少一张能指认来源的图”。
用户下载的是产物,不是源码。源码里的 import 在执行前会被打包器改写,模块边界、加载时机和缓存粒度都在构建阶段定型。本章按“切、量、缓存”三步走:9.1 决定产物怎么分,9.2 决定怎么量、怎么判,9.3 决定切好的文件怎样在缓存里长期有效。顺序不能反——没有依赖图,分包只是按目录猜;没有体积归因,预算只是一条拍脑袋的红线;没有内容哈希,缓存策略会把旧资源和新页面绑死。
本节只解决“切”。文中出现的字节数与耗时都是案例假设值,只有测试输出是本机真实运行结果。
依赖图不是目录树
打包器的输入不是文件夹,而是一张有向图:节点是模块,边是依赖。目录结构只能表达“谁和谁放在一起”,不能表达“谁会一起被加载”。两个文件在同一个目录,可能一个进首包、一个进懒分包;两个文件隔着四层目录,也可能必须放在同一个产物里。
图上有两种边,语义完全不同:
- 静态依赖(
import ... from):父模块执行前,子模块必须已经加载。它会沿着边一路进入当前产物。 - 动态依赖(
import()):父模块执行到这里才去取子模块。它是一条分包边界,而不是普通依赖。
分包的第一个错误,是把动态导入写在了不会被执行的角落,然后以为分包生效了。第二个错误,是把路由组件静态导入到入口,再在配置里写一条 splitChunks,结果打包器只能把已经在首包里的代码再拆一次,首屏并没有变小。图必须先于配置。
图还需要做两项体检:悬空引用(引用了不存在的模块)和环。环在静态依赖里不一定报错,但会让闭包遍历重复访问,算法必须用集合去重,不能假设图是一棵树。
五条分包规则
下面的算法把“图 + 懒边界”变成“首包 + 懒分包 + 共享包”。规则只有五条:
- 从入口出发,沿静态边做闭包,得到首包。
- 每个
lazy边界的目标,是某个懒分包的起点。 - 懒分包的闭包同样只走静态边,但跳过已经在首包里的模块。
- 同一个模块被两个以上懒分包引用时,提升到
shared,只下载一次。 - 懒目标如果已经出现在首包里,记录边界冲突——这次分包不会生效。
这五条不是打包器的全部规则,但覆盖了最常见的失效原因。尤其是第 5 条:它不抛异常,只把问题写进报告,因为构建仍然能成功,只有首包悄悄变大。
// file: src/build/module-graph.ts
// tier: L1
// verify: node --test test/build/module-graph.test.ts
export interface LazyBoundary {
/** 懒加载入口模块 */
target: string;
/** 逻辑分包名,例如 route:order */
chunk: string;
}
export interface ModuleNode {
id: string;
bytes: number;
/** 静态依赖:加载本模块时一并下载 */
imports?: string[];
/** 动态导入:构成分包边界 */
lazy?: LazyBoundary[];
}
export interface ModuleGraph {
entry: string;
modules: Record<string, ModuleNode>;
}
export interface Chunk {
name: string;
modules: string[];
bytes: number;
}
export interface SplitPlan {
chunks: Chunk[];
initialBytes: number;
lazyBytes: number;
/** 懒加载目标又被静态依赖引用,分包不会生效 */
boundaryViolations: string[];
/** 多个懒分包共同依赖、被提升为 shared 的模块 */
sharedModules: string[];
/** 图里引用但不存在的模块 */
missing: string[];
}
/** 找出图中所有悬空引用,包括入口本身。 */
export function validateGraph(graph: ModuleGraph): string[] {
const missing = new Set<string>();
if (!graph.modules[graph.entry]) missing.add(graph.entry);
for (const node of Object.values(graph.modules)) {
for (const dep of node.imports ?? []) {
if (!graph.modules[dep]) missing.add(dep);
}
for (const boundary of node.lazy ?? []) {
if (!graph.modules[boundary.target]) missing.add(boundary.target);
}
}
return [...missing].sort();
}
function staticClosure(
graph: ModuleGraph,
start: string,
skip: Set<string>,
): Set<string> {
const seen = new Set<string>();
const stack = [start];
while (stack.length > 0) {
const id = stack.pop()!;
if (seen.has(id) || skip.has(id) || !graph.modules[id]) continue;
seen.add(id);
for (const dep of graph.modules[id].imports ?? []) {
if (!seen.has(dep)) stack.push(dep);
}
}
return seen;
}
function toChunk(name: string, ids: Iterable<string>, graph: ModuleGraph): Chunk {
const modules = [...ids].sort();
const bytes = modules.reduce((sum, id) => sum + graph.modules[id].bytes, 0);
return { name, modules, bytes };
}
/**
* 以入口的静态闭包作为首包,以 lazy 目标作为懒分包起点。
* 同一个模块被两个以上懒分包引用时提升到 shared,避免重复计入。
*/
export function splitBundle(graph: ModuleGraph): SplitPlan {
const missing = validateGraph(graph);
const initial = staticClosure(graph, graph.entry, new Set());
const lazies = new Map<string, Set<string>>();
for (const node of Object.values(graph.modules)) {
for (const boundary of node.lazy ?? []) {
if (!graph.modules[boundary.target]) continue;
const bucket = lazies.get(boundary.chunk) ?? new Set<string>();
bucket.add(boundary.target);
lazies.set(boundary.chunk, bucket);
}
}
const boundaryViolations: string[] = [];
for (const [chunk, targets] of lazies) {
for (const target of targets) {
if (initial.has(target)) boundaryViolations.push(`${chunk}:${target}`);
}
}
boundaryViolations.sort();
const owners = new Map<string, Set<string>>();
for (const [chunk, targets] of lazies) {
const closure = new Set<string>();
for (const target of targets) {
for (const id of staticClosure(graph, target, initial)) closure.add(id);
}
for (const id of closure) {
const set = owners.get(id) ?? new Set<string>();
set.add(chunk);
owners.set(id, set);
}
}
const sharedModules = [...owners]
.filter(([, chunks]) => chunks.size > 1)
.map(([id]) => id)
.sort();
const chunks: Chunk[] = [toChunk("initial", initial, graph)];
if (sharedModules.length > 0) {
chunks.push(toChunk("shared", sharedModules, graph));
}
for (const chunk of [...lazies.keys()].sort()) {
const ids = [...owners]
.filter(([id, hits]) => hits.has(chunk) && !sharedModules.includes(id))
.map(([id]) => id);
if (ids.length > 0) chunks.push(toChunk(chunk, ids, graph));
}
const initialBytes = chunks[0].bytes;
const lazyBytes = chunks.slice(1).reduce((sum, chunk) => sum + chunk.bytes, 0);
return { chunks, initialBytes, lazyBytes, boundaryViolations, sharedModules, missing };
}
算法刻意保持“按模块计字节”,不模拟 tree shaking、CSS 提取、压缩和协议开销。它回答的是结构问题:哪些模块会一起到达、哪些边界是假的。真实构建还需要把这些结构结果交给打包器,再用 9.2 的归因核对实际产物。
首包与懒加载的边界
首包不是越小越好。把登录态、路由表和设计系统 token 也拆出去,首屏会先白一下再补样式,用户感知到的不是“包小了”,而是“页面抖了”。判断归属的单位应该是任务路径,而不是模块大小。
| 模块 | 什么时候需要 | 归属 |
|---|---|---|
| 应用壳、路由表 | 任意页面进入前 | initial |
| 设计系统 token 与基础组件 | 首屏就要绘制 | initial |
| 下单表单与校验 | 进入下单页 | route:order |
| 富文本编辑器 | 用户点击回复 | interaction:composer |
| 设置页与帮助中心 | 用户主动进入 | route:settings |
| 低频图表库 | 打开统计面板 | interaction:stats |
这张表的读法是:首包装“进入产品就必须存在的东西”,懒分包装“某个任务开始后才存在的东西”。介于两者之间的模块,问一句“不做这件事的用户会不会为它付出下载成本”,答案通常是会,那就拆出去。
共享模块要单独算账。shared 不是免费的:它多一次请求,也把两个懒分包的发布绑在一起。只有当两个分包在同一个会话里确实都会被访问时,提升才划算。测试里的 12 字节只是演示,真实项目要拿访问路径的命中率来算。
测试与真实输出
测试覆盖六种情况:首包闭包、共享提升、边界冲突、悬空引用、静态成环、字节守恒。完整测试文件如下:
// file: test/build/module-graph.test.ts
// tier: L1
// verify: node --test test/build/module-graph.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { splitBundle, validateGraph } from "../../src/build/module-graph.ts";
import type { ModuleGraph } from "../../src/build/module-graph.ts";
function fixture(): ModuleGraph {
return {
entry: "entry",
modules: {
entry: {
id: "entry",
bytes: 10,
imports: ["shell"],
lazy: [
{ target: "order", chunk: "route:order" },
{ target: "composer", chunk: "interaction:composer" },
],
},
shell: { id: "shell", bytes: 30, imports: ["ui"] },
ui: { id: "ui", bytes: 20 },
order: { id: "order", bytes: 40, imports: ["checkout", "form"] },
checkout: { id: "checkout", bytes: 25, imports: ["form"] },
composer: { id: "composer", bytes: 15, imports: ["form"] },
form: { id: "form", bytes: 12 },
},
};
}
test("首包只包含入口的静态闭包", () => {
const plan = splitBundle(fixture());
const initial = plan.chunks[0];
assert.equal(initial.name, "initial");
assert.deepEqual(initial.modules, ["entry", "shell", "ui"]);
assert.equal(initial.bytes, 60);
assert.equal(plan.initialBytes, 60);
});
test("多个懒分包共同依赖的模块提升为 shared", () => {
const plan = splitBundle(fixture());
assert.deepEqual(plan.sharedModules, ["form"]);
assert.deepEqual(
plan.chunks.map((chunk) => chunk.name),
["initial", "shared", "interaction:composer", "route:order"],
);
const shared = plan.chunks.find((chunk) => chunk.name === "shared");
assert.deepEqual(shared?.modules, ["form"]);
assert.equal(shared?.bytes, 12);
});
test("懒目标被静态导入时记录边界冲突", () => {
const graph = fixture();
graph.modules.entry.imports = ["shell", "order"];
const plan = splitBundle(graph);
assert.deepEqual(plan.boundaryViolations, ["route:order:order"]);
assert.ok(plan.chunks[0].modules.includes("order"));
});
test("悬空引用进入 missing,算法不崩溃", () => {
const graph = fixture();
graph.modules.checkout.imports = ["form", "ghost"];
assert.deepEqual(validateGraph(graph), ["ghost"]);
const plan = splitBundle(graph);
assert.deepEqual(plan.missing, ["ghost"]);
assert.equal(plan.initialBytes, 60);
});
test("静态依赖成环时闭包仍然终止", () => {
const graph = fixture();
graph.modules.checkout.imports = ["form", "order"];
const plan = splitBundle(graph);
const order = plan.chunks.find((chunk) => chunk.name === "route:order");
assert.deepEqual(order?.modules, ["checkout", "order"]);
assert.equal(order?.bytes, 65);
});
test("分包前后总字节守恒,每个模块只计一次", () => {
const plan = splitBundle(fixture());
const total = Object.values(fixture().modules).reduce(
(sum, node) => sum + node.bytes,
0,
);
assert.equal(plan.initialBytes + plan.lazyBytes, total);
const seen = plan.chunks.flatMap((chunk) => chunk.modules);
assert.equal(new Set(seen).size, seen.length);
});
在实验工程根目录执行:
node --test test/build/module-graph.test.ts
本机一次运行的真实输出如下,耗时随机器变化:
✔ 首包只包含入口的静态闭包 (1.060792ms)
✔ 多个懒分包共同依赖的模块提升为 shared (0.152959ms)
✔ 懒目标被静态导入时记录边界冲突 (0.1605ms)
✔ 悬空引用进入 missing,算法不崩溃 (0.240416ms)
✔ 静态依赖成环时闭包仍然终止 (0.111542ms)
✔ 分包前后总字节守恒,每个模块只计一次 (0.101417ms)
ℹ tests 6
ℹ suites 0
ℹ pass 6
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 207.646334
最后一条测试值得单独说:它断言 initialBytes + lazyBytes 等于所有模块字节之和,并且所有分包里出现的模块 id 不重复。这条不变量能挡住两类回归——共享模块被重复计数,以及某个模块既没进首包也没进任何懒分包,从产物里静默消失。
小结
分包的本质是把依赖图按“何时需要”切成几块,而不是把目录按大小切碎。首包是入口的静态闭包,懒分包是动态导入目标的静态闭包,跨分包复用的模块提升为 shared,懒目标落进首包则是必须报告的边界冲突。
算法只处理结构,不替代打包器,但它能在构建之前回答三个问题:首包为什么这么大、某个路由到底有没有被拆出去、共享模块是不是被重复下载。下一节把“字节”变成可归因、可判定的预算。
阅读导航:上一节:8.3 可访问性与交互边界 · 下一节:9.2 产物分析与体积预算 。
常见错误与边界
- 把目录结构当依赖图。同目录不代表同生命周期,跨目录也不代表必须分开。
- 路由组件静态导入入口,再期待分包配置生效。边界冲突不会让构建失败,只会让首包变大。
- 把
shared当成默认优化。多一次请求、多一条发布耦合,命中率不高时是负收益。 - 只统计压缩前字节。结构算法可以用原始字节,预算必须用实际上线口径。
- 忽略悬空引用。图里少一个节点,闭包会静默变小,测试必须显式检查
missing。 - 假设图是树。静态依赖可以成环,遍历必须用集合去重。
- 用首包大小单独评价分包。用户感知的是任务完成时间,不是某一个文件的字节数。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。