1.1 打包与产物分析
前三卷把 helio 引擎的每一块都讲透了:内核、渲染、子系统。但它们一直以「一堆 .ts 文件」的形态躺在仓库里——src/ 下 187 个非测试模块,谁依赖谁、一个可执行入口真正会带上多少代码,没有人量过。工程化的第一件事就是把源码变成一个可度量的产物:先知道入口拉起了哪些模块,再知道这份产物有多大、压缩后多大、有多少是白带进来的死代码。没有这三个数字,后面所有「优化」都是凭感觉。
这一节只做度量,不做优化。我们要写两个 Node 直跑的工具:一个把 src/ 扫成模块依赖图,一个在图上算出「源码字节 → 打包字节 → 压缩字节」三段体积,并用符号级 tree-shaking 找出入口用不到的顶层导出。数字全部来自本机实跑,不估、不猜。度量先行也是为了让后面每一处优化都有据可依——优化前后体积差了多少字节,这套工具随时能回答。
为什么从体积开始?因为《微光岛》的交付目标里有「首包要小、加载要快」这一条,而首包大小取决于两件事:入口真正需要哪些模块,以及打包器能不能把用不到的导出摇掉。这两件事都建立在同一张模块图上,所以先建图。
有人会问:真实项目直接上 esbuild 或 Vite 不就行了,为什么要自己写分析器?两个原因。其一,本书的约束是每个代码块都能在本机 Node 直跑、不依赖外部安装——引入一个打包器就等于把「可复现」押在它的版本与配置上。其二,分析体积这件事本身不需要一个完整的打包器:我们关心的是「哪些模块被拉进来」「各占多少字节」「压缩后多大」,这些只依赖静态的依赖关系与文件字节数,一个两百行的扫描器就够了。真正的 esbuild 强在生成可部署产物(处理 CSS、worker、外部依赖、目标降级),那是第 8 章构建部署的事;本节只做度量,所以自己写反而更透明。
本章与站内既有内容的分工
站内已经有不少「方法级」的内容,本卷和它们的边界要划清楚——本卷只做「把前三卷的引擎工程化跑起来 + 真实构建/测试/性能数字」,不重推原理。
| 站内既有内容 | 它讲什么 | 本卷《工程与实战》做什么 |
|---|---|---|
/graphics-gpu-profiling-debugging/ | GPU 性能分析的方法论 | 在真实 helio 产物上量体积、压缩率与加载耗时 |
/game/engine/
顶层综述 | 引擎选型与架构综述 | 不选型,直接把前三卷的引擎工程化交付 |
/posts/typescript/ | TypeScript 语言与工程 | 用 Node 直跑的构建工具链,量真实字节 |
换句话说:讲「怎么测 GPU 耗时」的去看 graphics-gpu-profiling-debugging;讲「该不该自己写引擎」的去看 game/engine;而「helio 的入口到底带了多少 KB、怎么把它压到首包能接受」——这是本卷的事。
模块图:打包分析的地基
打包器的第一步永远是建图。它从入口出发,顺着 import 语句把一个个模块连成一张有向图;只有站在这张图上,「入口可达集」「共享依赖」「死代码」才有定义。我们不引入任何第三方打包器,而是自己写一个构建期扫描器——它不执行代码,只用正则解析 import / export 语法,把每个模块的字节数、直接依赖、以及「从每个依赖里按名字取了哪些符号」记下来。
// file: 块都是完整可编译的文件。下面这个文件是整节的地基,后面 1.3 的分包器也会 import 它。
// file: tools/module-graph.mjs
// tier: L1
// verify: node tools/module-graph.mjs
//
// 构建期静态分析:把 src/ 下的 ES 模块扫成一张依赖图。不打包、不跑代码,
// 只解析 import / export 语法,得到每个模块的源字节数、它直接依赖的模块,
// 以及它从每个依赖里「按名字」取了哪些符号。后面 bundle-analyze 与
// split-chunks 都 import 这里的 buildGraph / reachableModules。
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, dirname, resolve, relative } from 'node:path';
const IMPORT_RE = /(?:^|\n)\s*(?:import|export)\s+(?:type\s+)?[\s\S]*?\s*from\s*['"]([^'"]+)['"]/g;
const NAMED_IMPORT_RE = /import\s+(?:type\s+)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g;
const EXPORT_DECL_RE = /^export\s+(?:async\s+)?(?:function|class|const|let|var)\s+([A-Za-z_$][\w$]*)/gm;
const EXPORT_LIST_RE = /^export\s*\{([^}]*)\}/gm;
/** 递归收集 rootDir 下所有非测试 .ts 文件。 */
export function collectModules(rootDir) {
const out = [];
const walk = (dir) => {
for (const name of readdirSync(dir).sort()) {
const full = join(dir, name);
if (statSync(full).isDirectory()) walk(full);
else if (name.endsWith('.ts') && !name.endsWith('.test.ts')) out.push(full);
}
};
walk(rootDir);
return out;
}
/** 抽出一个模块的静态依赖说明符、导出符号名、以及「从谁取了哪些符号」。 */
export function parseModule(source) {
const specs = [];
for (const m of source.matchAll(IMPORT_RE)) {
const s = m[1];
if (s.startsWith('.') || s.startsWith('/')) specs.push(s);
}
const exports = new Set();
for (const m of source.matchAll(EXPORT_DECL_RE)) exports.add(m[1]);
for (const m of source.matchAll(EXPORT_LIST_RE)) {
for (const part of m[1].split(',')) {
const name = part.trim().split(/\s+as\s+/).pop().trim();
if (name) exports.add(name);
}
}
const uses = [];
for (const m of source.matchAll(NAMED_IMPORT_RE)) {
const names = [];
for (const part of m[1].split(',')) {
const raw = part.trim();
if (!raw || raw.startsWith('type ')) continue;
names.push(raw.split(/\s+as\s+/)[0].trim());
}
uses.push({ spec: m[2], names });
}
return { specs, exports: [...exports], uses };
}
/** 建图:modules 以「相对 cwd 的路径」为键。 */
export function buildGraph(rootDir) {
const files = collectModules(rootDir);
const byAbs = new Map(files.map((f) => [resolve(f), relative(process.cwd(), f)]));
const modules = new Map();
for (const f of files) {
const source = readFileSync(f, 'utf8');
const { specs, exports, uses } = parseModule(source);
const imports = [];
const seen = new Set();
for (const s of specs) {
const dep = byAbs.get(resolve(dirname(f), s));
if (dep !== undefined && !seen.has(dep)) {
seen.add(dep);
imports.push(dep);
}
}
const deps = [];
for (const g of uses) {
const dep = byAbs.get(resolve(dirname(f), g.spec));
if (dep !== undefined) deps.push({ dep, names: g.names });
}
modules.set(byAbs.get(resolve(f)), {
path: byAbs.get(resolve(f)),
bytes: Buffer.byteLength(source),
imports,
exports,
uses: deps,
});
}
return { modules };
}
/** 模块级可达集:从 entry 出发静态能到达的所有模块路径(含 entry)。 */
export function reachableModules(graph, entry) {
const seen = new Set();
const stack = [entry];
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;
}
/** 一组模块的源字节总和。 */
export function totalBytes(graph, paths) {
let n = 0;
for (const p of paths) n += graph.modules.get(p)?.bytes ?? 0;
return n;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const graph = buildGraph('src');
const entry = 'src/apps/glimmer-isle-demo.ts';
const reach = reachableModules(graph, entry);
console.log(`模块总数(不含测试):${graph.modules.size}`);
console.log(`入口 ${entry} 可达模块:${reach.size}`);
console.log(`可达源码字节:${totalBytes(graph, reach)}`);
}
直接 node tools/module-graph.mjs 跑一遍,本机输出:
模块总数(不含测试):187
入口 src/apps/glimmer-isle-demo.ts 可达模块:6
可达源码字节:21231
一个数字就说明了很多问题:全仓库 187 个模块,但《微光岛》的可玩 demo(glimmer-isle-demo.ts)从入口出发只静态可达 6 个模块、共 21231 字节。也就是说,模块级的 tree-shaking 已经天然发生了——打包器根本不会把另外 181 个模块带进来,因为它们不在入口的可达集里。这是「按需加载」的地基,1.3 的分包就建立在这张可达集上。
这个扫描器用的是正则而不是真正的 TypeScript 解析器,所以要清楚它的边界。它只认静态的 import ... from '...' 与 export ... from '...',对动态 import()、对字符串拼接出来的模块名、对 require,一概视而不见。对我们的用途这恰好够:helio 的引擎代码全是静态导入,动态导入只出现在游戏层的懒加载点,而那种场景我们会在 1.3 用显式的「懒加载根」清单来建模,不指望扫描器猜出来。真要做通用工具,这里该换成 TypeScript Compiler API 或 esbuild 的 metafile——但那会把一个两百行的脚本变成几百行的工程,得不偿失。
三段体积与 tree-shaking
有了图,就能算体积了。打包器把入口可达的模块按拓扑序(被依赖者在前)拼成一份 bundle,去掉 import / export 语法糖,再用 node:zlib 真压 gzip 与 brotli。同一份模块图还能顺手做符号级 tree-shaking:从入口出发,凡是既没被别的模块 import、又没在本模块内部被引用的顶层导出,整段删掉。下面是 tools/bundle-analyze.mjs 的核心节选——完整文件里还包含读文件、拼字符串等琐碎代码。
// snippet: tools/bundle-analyze.mjs(节选:拓扑拼接与三段体积)
// tier: L1
// verify: node tools/bundle-analyze.mjs
/** 拓扑序:被依赖者在前(Kahn);环会让 Kahn 提前停下,剩余按名字补末尾。 */
function topoOrder(graph, paths) {
const set = new Set(paths);
const indeg = new Map([...set].map((p) => [p, 0]));
for (const p of set) {
for (const dep of graph.modules.get(p)?.imports ?? []) {
if (set.has(dep)) indeg.set(p, indeg.get(p) + 1);
}
}
const queue = [...set].filter((p) => indeg.get(p) === 0).sort();
const out = [];
while (queue.length) {
const p = queue.shift();
out.push(p);
for (const q of set) {
if (q !== p && (graph.modules.get(q)?.imports ?? []).includes(p)) {
indeg.set(q, indeg.get(q) - 1);
if (indeg.get(q) === 0) queue.push(q);
}
}
}
for (const p of [...set].sort()) if (!out.includes(p)) out.push(p);
return out;
}
/** 把一组模块按拓扑序拼成一份 bundle 文本(带分隔注释,便于人工核对)。 */
export function bundle(graph, paths) {
return topoOrder(graph, paths)
.map((p) => `// ---- ${p} ----\n${stripModuleSyntax(readFileSync(resolve(p), 'utf8')).trim()}`)
.join('\n\n') + '\n';
}
跑 node tools/bundle-analyze.mjs,本机实跑输出如下:
== 三段体积:源码 / 打包 / 压缩 ==
全图模块数 187 入口可达模块数 6
源码字节 全图 636208 入口可达 21231
bundle字节 全图 627495 入口可达 20842
gzip字节 入口可达 7230
brotli字节 入口可达 6176
== 符号级 tree-shaking ==
保留导出体 11272 字节,删除未被引用导出体 793 字节
删除样例:src/core/kernel.ts#RenderSubsystem, src/core/fixed-step-loop.ts#interpolate, src/core/arena.ts#ScratchArena
写出 artifacts/glimmer.bundle.ts(20842 字节)
几个结论值得逐条读:
- 打包本身就省字节。入口可达源码 21231 字节,拼成 bundle 后是 20842 字节——去掉
import/export语法糖与空行,直接少了 389 字节,没有任何运行时开销。 - 压缩才是大头。20842 字节的 bundle 经 gzip 只剩 7230 字节(34.7%),brotli 更是 6176 字节(29.6%)。源码里大量是重复的标识符与中文注释,字典压缩对它们极其友好——这也解释了为什么「发到线上的首包」永远要看 gzip 后的数字。
- 符号级 tree-shaking 只省了 793 字节。被摇掉的三个符号
RenderSubsystem、interpolate、ScratchArena都是「定义了但入口这条路径没用到」的顶层导出。793 字节不多,但方向对:模块级可达已经把 181 个模块挡在门外,符号级再在留下的模块里抠出没用的导出。
gzip 与 brotli 的差距(7230 对 6176)也值得记一笔。gzip 用固定 32KB 窗口与 DEFLATE 的哈夫曼编码,是所有 HTTP 服务器默认就支持的格式;brotli 的静态字典专门收录了 function、return 这类 JS/TS 高频词,所以对源码文本的压缩率通常再高 10% 到 20%。工程上的结论是:首包按 gzip 估算上限、按 brotli 估算下限,两者都报出来,才不会在切换压缩格式时被体积变化打个措手不及。
值得强调的是第 3 点背后的双重判据:一个顶层导出只有在「外部没人 import」且「本模块内部也没引用」时才能删。kernel.ts 里的 World 类就没被删——因为 Kernel 构造函数内部 new World() 用到了它。只按「有没有被别的模块 import」来删,会把模块自用的符号误删,编译直接报未定义。tree-shaking 从来不是「删掉没被 import 的」,而是「删掉没有任何活引用链到达的」。
还有一点容易被忽略:上面统计的「全图 636208 字节」是把 187 个模块全部算进去的源码体量。它和入口可达的 21231 字节之间的差额,正是「如果不管三七二十一全打包」会多出来的部分。很多团队的首包之所以大,不是因为入口真的用了那么多,而是因为打包配置把整个 src/ 都塞进了一个 chunk,或者某个 barrel 文件 export * from 把一片模块都拖了进来。有了这张图,这类问题一眼可见:只要入口的可达集远小于模块总数,就说明打包器的静态分析在正常工作。
可运行产物:拼出来的 bundle 能跑
前面所有分析都建立在一个前提上:我们拼出来的 bundle 是正确的。验证方式很朴素——把它写成一个 .ts 文件,用 node 直接跑,看输出是否与原始 demo 逐位相同。bundle-analyze.mjs 已经把它写到了 artifacts/glimmer.bundle.ts,执行:
$ node artifacts/glimmer.bundle.ts
[glimmer-isle] frames=120 step=0.016666666666666666 speed=2
[glimmer-isle] run A: x=9.71445146547012e-17 z=2.666666666666667 checksum=5142.666666666668
[glimmer-isle] run B: x=9.71445146547012e-17 z=2.666666666666667 checksum=5142.666666666668
[glimmer-isle] deterministic: x=true z=true checksum=true
[glimmer-isle] PASS: 两次运行逐位相同
把这段输出和直接 node src/apps/glimmer-isle-demo.ts 的输出做 diff,两者完全一致。这说明「扫图 → 拓扑排序 → 去语法糖 → 拼接」这条链没有破坏模块语义:依赖在前,引用先于使用,行为不变。这也顺手验证了 module-graph.mjs 的图是准的——如果依赖边漏了一条或序排错了,拼出来的文件会因为「用了未定义的名字」直接崩掉,而不会安静地跑出同样的校验和。
这一节我们只建立了「度量」这条腿:入口可达集、三段体积、tree-shaking 的真实收益。下一节换一种资源——贴图——看它在磁盘上、压缩后、以及 GPU 块压缩后各是什么体积,解码又要花多少时间。
阅读导航:上一节:回到目录 · 下一节:1.2 资源压缩与纹理管线 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。