1.3 分包与首包体
1.1 量出:一个入口从 187 个模块里只静态可达 6 个。这是好事,但它只解决了「不把无关模块打进来」,没解决「进来的是不是都该在第一帧就加载」。一个真实的游戏入口会拉起内核、场景、渲染、UI、音频一大片系统——如果它们全在一个 chunk 里,玩家就得等整包下载完才能看到第一帧。
分包(code splitting)就是把「整包」按加载时机切成若干 chunk:首屏必需的那部分立即下载,其余的等玩家真正走到那个功能时再拉。判断标准很简单:玩家看到第一屏可交互画面之前,需要哪些模块? 只有这些进首包,其余全进懒加载 chunk。
这一节我们在 1.1 的模块图上再加一个「分包器」:给定入口和一组懒加载根(那些用动态 import() 拉起的模块),把图切成「入口 chunk + 若干懒加载 chunk」,量出首包与整包的真实字节。关键机制是共享依赖上提——如果两个懒加载 chunk 都依赖内核,内核只该在第一份 chunk 里出现一次,而不是每份都带一份。
首屏必需 vs 懒加载
先把「首屏必需」说清楚。《微光岛》的启动流程大致是:启动内核 → 显示主菜单 → 玩家点「开始」→ 加载关卡 → 进入可玩。从「玩家看到主菜单」到「可玩」之间,可以等一个进度条;但主菜单本身必须秒开。所以分包的天然切点就在主菜单与关卡之间。
对应到模块,入口 chunk 应该装的是:内核(kernel、arena、slotmap、subsystem、fixed-step-loop)、最小的 ECS 与事件循环、以及绘制主菜单所需的最小渲染。而关卡逻辑、具体玩法演示、重型渲染子系统,都可以懒加载。本节用一个简化但真实的切法来演示:入口取 kernel-boot.ts(内核装配启动,6 个模块),懒加载根取两个演示模块——glimmer-isle-demo.ts(可玩 demo)和 frame-sync-demo.ts(帧同步演示)。
在真实代码里,「懒加载根」是通过动态 import() 声明的。它和静态 import 的区别,正是「立即要」和「用到再要」的区别:
// snippet: 懒加载根在游戏代码里的声明方式
// tier: L1
// 静态 import:进入这个模块时就一起加载(首屏必需)。
import { Kernel } from '../core/kernel.ts';
// 动态 import:只有真正调用时才去拉对应 chunk(懒加载)。
async function enterGameplay(): Promise<void> {
const { runDemo, recordedInput } = await import('../apps/glimmer-isle-demo.ts');
runDemo(recordedInput(120));
}
为什么用「根」这个词?因为动态 import() 的落点是一个模块,但从那个模块往下是一整棵子树。分包器要做的不是把「一个模块」挪走,而是把「从这个模块可达、且没被更早的 chunk 认领的整棵子树」挪走。共享的那部分(比如内核)如果已经在入口 chunk 里,就不重复——这就是共享依赖上提。
怎么挑懒加载根?两个原则。第一,按玩家能感知的「关卡/模式」切,而不是按代码目录切——切点应该对应一个明确的「用户操作后进入」的时刻,比如「点开始游戏」「打开设置」「进入联机」。第二,别切太碎。每个 chunk 都是一次额外的网络往返,几十个小 chunk 的首字节延迟会拖垮加载体验;通常一个中等游戏 5 到 15 个 chunk 是合理区间。本节只切两个懒加载根,是为了让字节数干净可读。
还有一个边界情况要提前想清楚:首包与懒加载之间如果有循环依赖怎么办。比如入口要用关卡里的一个常量,关卡又依赖入口的内核。这时静态分析会把「被两边都需要的模块」上提到入口 chunk,循环就断开了——因为上提后两边引用的都是入口里的同一份。真出现「入口依赖懒加载模块」的动态引用(入口运行到某一步才去 import() 懒加载模块),那属于运行时的按需加载,不在静态分包的处理范围内,得靠 import() 的返回 Promise 显式编排。本节的分包器只处理静态可达,这是刻意的边界。
| chunk | 加载时机 | 内容 | 本节实测字节 |
|---|---|---|---|
| 入口(首包必需) | 进游戏前 | 内核装配链,6 个模块 | 19517 字节(gzip 6558) |
| gameplay(懒加载) | 玩家点「开始」 | 可玩 demo,共享依赖已剥除 | 3004 字节 |
| frame-sync(懒加载) | 玩家进入同步演示 | 帧同步演示,共享依赖已剥除 | 6691 字节 |
分包器:入口 chunk 与懒加载 chunk
算法只有两条规则,但顺序很关键:
- 入口 chunk = 从入口出发的静态可达集,遇到懒加载根就停(不展开它的依赖)。这些模块是首屏必需的,第一个被认领。
- 懒加载 chunk[k] = 从
lazyRoot[k]完全展开的可达集,减去已经被更早 chunk 认领的模块。认领顺序就是 manifest 的顺序,先到先得。
「先到先得」正是共享依赖上提的实现:内核被入口 chunk 先认领,于是后面每个懒加载 chunk 都自动少掉内核那部分。如果把顺序反过来、让懒加载先认领,内核就会被塞进第一个懒加载 chunk,首包反而缺了它——所以入口必须第一个认领。
分包器的输入是一份 manifest:一个入口模块,加一个有序的懒加载根列表。它是一份纯数据,不掺任何构建配置——换游戏、换切法,只改 manifest,不改分包器代码。这正是把「切分策略」和「切分算法」分开的价值:策略是产品决策(什么时候让玩家等),算法是工程实现(怎么保证不重复)。下面这个文件是完整的分包器,它 import 1.1 的 module-graph.mjs 与 bundle-analyze.mjs。
// file: tools/split-chunks.mjs
// tier: L1
// verify: node tools/split-chunks.mjs
//
// 分包器:给定「入口模块」和一组「懒加载根」(动态 import 的落点),
// 把模块图切成若干 chunk——
// - 入口 chunk = 从入口静态可达、且不穿过任何懒加载根的模块(首包必需)
// - 懒加载 chunk[k] = 从 lazyRoot[k] 可达、且未被更早的 chunk 认领的模块
// 量出「首包」与「整包」的真实字节数,以及 gzip 后的首包体积。
import { gzipSync } from 'node:zlib';
import { buildGraph } from './module-graph.mjs';
import { bundle } from './bundle-analyze.mjs';
/** 从 start 出发的静态可达集;lazyRoots 里的模块当作叶子(不展开其依赖)。 */
function reachFrom(graph, start, lazyRoots) {
const lazy = new Set(lazyRoots);
const seen = new Set();
const stack = [start];
while (stack.length) {
const cur = stack.pop();
if (seen.has(cur)) continue;
seen.add(cur);
if (cur !== start && lazy.has(cur)) continue; // 懒加载根:到此为止
for (const dep of graph.modules.get(cur)?.imports ?? []) if (!seen.has(dep)) stack.push(dep);
}
return seen;
}
/** 完全展开的可达集(穿懒加载根),用于算懒加载 chunk 的大小。 */
function reachAll(graph, start) {
const seen = new Set();
const stack = [start];
while (stack.length) {
const cur = stack.pop();
if (seen.has(cur)) continue;
seen.add(cur);
for (const dep of graph.modules.get(cur)?.imports ?? []) if (!seen.has(dep)) stack.push(dep);
}
return seen;
}
/** 按 manifest 切分,返回 { entry, lazy: [{name, root, modules}] }。 */
export function split(graph, manifest) {
const lazyRoots = manifest.lazy.map((l) => l.root);
const assigned = new Set();
const entryModules = [...reachFrom(graph, manifest.entry, lazyRoots)].filter((m) => !lazyRoots.includes(m));
for (const m of entryModules) assigned.add(m);
const lazy = [];
for (const l of manifest.lazy) {
const mods = [...reachAll(graph, l.root)].filter((m) => !assigned.has(m));
for (const m of mods) assigned.add(m);
lazy.push({ name: l.name, root: l.root, modules: mods });
}
return { entry: entryModules, lazy };
}
const bytesOf = (graph, mods) => mods.reduce((n, m) => n + (graph.modules.get(m)?.bytes ?? 0), 0);
if (import.meta.url === `file://${process.argv[1]}`) {
const graph = buildGraph('src');
const manifest = {
entry: 'src/apps/kernel-boot.ts',
lazy: [
{ name: 'gameplay', root: 'src/apps/glimmer-isle-demo.ts' },
{ name: 'frame-sync', root: 'src/apps/frame-sync-demo.ts' },
],
};
const { entry, lazy } = split(graph, manifest);
const kb = (n) => (n / 1024).toFixed(1);
const entryBytes = bytesOf(graph, entry);
console.log('== 《微光岛》分包:首包 vs 懒加载 ==');
console.log(`入口 chunk(首包必需) ${entry.length} 模块 ${entryBytes} 字节 (${kb(entryBytes)} KB)`);
let lazyTotal = 0;
for (const l of lazy) {
const b = bytesOf(graph, l.modules);
lazyTotal += b;
console.log(`懒加载 ${l.name.padEnd(10)} ${String(l.modules.length).padStart(2)} 模块 ${b} 字节 (${kb(b)} KB)`);
}
const total = entryBytes + lazyTotal;
console.log(`\n首包 ${entryBytes} 字节,整包 ${total} 字节,首包占比 ${((entryBytes / total) * 100).toFixed(1)}%`);
const entryGzip = gzipSync(bundle(graph, entry), { level: 9 }).length;
console.log(`首包 gzip 后 ${entryGzip} 字节(真实压缩入口 chunk 的 bundle)`);
// 对照:不做「共享依赖上提」,每个懒加载根各带一份完整子树(会重复打包)。
const lazyRoots = manifest.lazy.map((l) => l.root);
const naiveEntry = reachFrom(graph, manifest.entry, lazyRoots);
const naiveLazy = manifest.lazy.map((l) => [...reachAll(graph, l.root)]);
const naiveBytes =
bytesOf(graph, [...naiveEntry]) + naiveLazy.reduce((n, m) => n + bytesOf(graph, m), 0);
console.log(`\n对照:不上提共享依赖时,各 chunk 各带一份,整包膨胀到 ${naiveBytes} 字节`);
console.log(` (相对上提后的 ${total} 字节,多出 ${naiveBytes - total} 字节重复)`);
}
可运行产物:把 chunk 落盘并验证
光有数字还不够——分包器必须真的把每个 chunk 拼成可独立运行的文件。tools/emit-chunks.mjs 复用 1.1 的 bundle(),把三个 chunk 各写一份到 artifacts/chunks/ 下,并报出各自的 bundle 字节与 gzip 字节。这一步同时也是正确性验证:入口 chunk 落盘后能直接 node 跑,说明它的模块顺序、依赖闭合都没问题。
// file: tools/emit-chunks.mjs
// tier: L1
// verify: node tools/emit-chunks.mjs
//
// 把分包结果真正落盘:每个 chunk 用 bundle() 拼成一份可独立运行的 .ts,
// 写到 artifacts/chunks/ 下,并报出各自的原始字节与 gzip 字节。
// 首包(entry)与懒加载 chunk 的字节之和,就是「整包」的真实体积。
import { gzipSync } from 'node:zlib';
import { writeFileSync, mkdirSync } from 'node:fs';
import { buildGraph } from './module-graph.mjs';
import { bundle } from './bundle-analyze.mjs';
import { split } from './split-chunks.mjs';
const OUT_DIR = 'artifacts/chunks';
/** 把一个 chunk 的模块拼成 bundle,落盘并返回 { bytes, gzip }。 */
function emit(graph, name, modules) {
const code = bundle(graph, modules);
const bytes = Buffer.byteLength(code);
const gz = gzipSync(code, { level: 9 }).length;
writeFileSync(`${OUT_DIR}/${name}.ts`, code);
return { bytes, gzip: gz };
}
if (import.meta.url === `file://${process.argv[1]}`) {
const graph = buildGraph('src');
const manifest = {
entry: 'src/apps/kernel-boot.ts',
lazy: [
{ name: 'gameplay', root: 'src/apps/glimmer-isle-demo.ts' },
{ name: 'frame-sync', root: 'src/apps/frame-sync-demo.ts' },
],
};
const { entry, lazy } = split(graph, manifest);
mkdirSync(OUT_DIR, { recursive: true });
let rawTotal = 0;
let gzTotal = 0;
console.log('== 落盘的 chunk(bundle 后字节 / gzip 后字节)==');
for (const [name, mods] of [['entry', entry], ...lazy.map((l) => [l.name, l.modules])]) {
const { bytes, gzip } = emit(graph, name, mods);
rawTotal += bytes;
gzTotal += gzip;
console.log(`${name.padEnd(10)} ${String(mods.length).padStart(2)} 模块 ${String(bytes).padStart(6)} 字节 gzip ${String(gzip).padStart(5)} 字节`);
}
console.log(`\n整包(三 chunk 之和) ${rawTotal} 字节,gzip 后 ${gzTotal} 字节`);
console.log(`落盘目录:${OUT_DIR}/entry.ts, gameplay.ts, frame-sync.ts`);
}
先跑分包报告,再跑落盘。两条命令的本机实跑输出:
$ node tools/split-chunks.mjs
== 《微光岛》分包:首包 vs 懒加载 ==
入口 chunk(首包必需) 6 模块 19517 字节 (19.1 KB)
懒加载 gameplay 1 模块 3004 字节 (2.9 KB)
懒加载 frame-sync 2 模块 6691 字节 (6.5 KB)
首包 19517 字节,整包 29212 字节,首包占比 66.8%
首包 gzip 后 6558 字节(真实压缩入口 chunk 的 bundle)
对照:不上提共享依赖时,各 chunk 各带一份,整包膨胀到 47439 字节
(相对上提后的 29212 字节,多出 18227 字节重复)
$ node tools/emit-chunks.mjs
== 落盘的 chunk(bundle 后字节 / gzip 后字节)==
entry 6 模块 19122 字节 gzip 6558 字节
gameplay 1 模块 2925 字节 gzip 1416 字节
frame-sync 2 模块 6566 字节 gzip 2781 字节
整包(三 chunk 之和) 28613 字节,gzip 后 10755 字节
落盘目录:artifacts/chunks/entry.ts, gameplay.ts, frame-sync.ts
落盘后 node artifacts/chunks/entry.ts 能直接跑出内核装配的四帧日志,说明入口 chunk 是自洽的。注意两处数字的小差:split-chunks 报的入口是 19517 字节(源码字节),emit-chunks 报的是 19122 字节(bundle 字节),差的 395 字节就是 1.1 讲的「去掉 import/export 语法糖」省下的部分。同一件事在两个工具里口径一致,正是复用同一套 bundle() 的好处。
为什么 emit-chunks 要单独存在,而不是让 split-chunks 顺手落盘?因为度量和产出是两件事:split-chunks 只回答「怎么切、各多大」,随时可跑、无副作用;emit-chunks 才真正写文件,是构建流水线里的一步。把两者分开,前者能在设计阶段反复试不同的 manifest,后者只在最终构建时跑一次。这也是本书后面每个构建工具的共同形态:分析工具纯净,产出工具落盘。
共享依赖上提为什么是正确性问题
回头看对照那一行:不上提共享依赖,整包从 29212 膨胀到 47439 字节,多出 18227 字节。很多人会把它当成「体积优化」,其实它首先是正确性问题。
如果内核那 5 个模块同时出现在入口 chunk 和 gameplay chunk 里,运行时会发生两件坏事。其一,同一个模块被求值两次,里面的模块级单例(比如全局资源注册表、类型注册表)会有两份实例,A 里注册的东西在 B 里查不到——这种 bug 极难定位,因为它只在特定加载顺序下出现。其二,重复的代码白白占下载量与解析时间。所以共享依赖上提不是「能省则省」,而是「必须只此一份」:任何被多个 chunk 共同依赖的模块,都要落到它们最早的公共祖先 chunk 里。
这也是为什么分包器的认领顺序不能随便改。入口第一个认领,就保证了「凡是入口也需要的模块,一律进首包」;剩下的懒加载 chunk 各拿各的独有子树,交集为空。一个健康的切分结果应当满足:各 chunk 的模块集合两两不相交,并集等于整包。本节三个 chunk 的模块数 6 + 1 + 2 = 9,小于全图 187,也小于「入口可达 + gameplay 可达」的并集——差额正是被上提去重的那部分。
最后提醒一句:首包大小不是一个数,而是一组数。源码字节用于对比模块取舍,gzip 字节用于估算真实下载量,模块数与 chunk 数用于估算 HTTP 请求数。工程决策时三个都要看——只看源码字节会高估首包,只看 gzip 字节又会忽略模块数带来的往返开销。本节把这三组数都报了出来,就是为了让「首包到底多大」这件事有据可查。
再补一个真实项目里常被问的问题:分包会不会影响确定性?答案是不会——分包只改变模块的加载时机,不改变模块的求值结果。只要共享依赖只此一份、且每个 chunk 内部依赖闭合,那么无论玩家是先加载首包再懒加载,还是(假设网络允许)一次性全部加载,最终运行时的模块状态都是同一份。helio 的确定性回放(2.3)能成立,前提正是「模块图是确定的、求值顺序是确定的」,而分包器恰恰守住了这条底线。
到这里,构建与资产管线这条线就闭环了:先建模块图(1.1),再量资源体积(1.2),最后按加载时机切 chunk(1.3)。三者共用同一套「扫图 + 量字节」的工具链,没有一处估算。下一章我们把矛头转向测试——怎么在没有 GPU 的 CI 里,确定性地验证内核逻辑与渲染输出。
阅读导航:上一节:1.2 资源压缩与纹理管线 · 下一节:2.1 内核单测与假对象 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。