一、技术难点
前 34 篇一直在浏览器里量东西:渲染管线、加载、网络、SSR、运行时、bundle、图片字体、Worker、WASM、性能监控。这一篇要回到所有那一切的上游——产物是怎么被造出来的。
构建工具是前端工程里最"黑盒"的一层。它每天被跑几百次,但绝大多数人对它的认知停留在三个词:vite build、tree-shaking、hash 变了要重传。一旦构建慢了、产物诡异了、缓存莫名失效了,就只能靠猜。
所以我不用任何第三方依赖,从零写了一个迷你打包器:scan.mjs(词法器 + 模块语法扫描)、build.mjs(模块图 + tree-shaking + 作用域提升链接 + 分块 + 内容哈希),配 test-bundler.mjs(33 条断言全绿,其中 5 组是同真实磁盘上的原生 ESM 做差分对照)和 bench.mjs(13 组实测)。环境:Node 22.23.3 / darwin-arm64 / V8 12.4。
先把结论摆出来。下面每一条都是这台机器上跑出来的,不是推演:
| 流传的说法 | 实测结果 | 差在哪 |
|---|---|---|
| 「扫描依赖就是正则找 import」 | 3 个伪装样本(多行模板 / 块注释 / 正则字面量里各藏一条假 import)3/3 全部误报成真边;同时它比真词法器快 43.7x(0.35ms vs 15.22ms / 500 模块) | 快是真的,错也是真的。错误依赖边 = 多打包或漏打包,且只在运行时炸 |
| 「tree-shaking 就是删没用的 export」 | 同一份 300 模块源码,按入口引用比例 10%/30%/60%/100%,产物降幅 96.5% / 88.8% / 78.2% / 64.6% | 降幅几乎完全由"有多少模块根本没进图"决定(270/301 未进图),不是由摇树算法多聪明决定 |
| 「拆细一点缓存命中率高」 | 改一行活代码:单块失效 100%;shared 两块 61.1%;per-module 141 块只失效 89B = 0.4% | 但 per-module 是 141 个请求。体积换请求数,没有免费午餐 |
| 「chunk hash 把依赖也算进去更安全」 | 改了 1 个叶子模块:只哈希自身失效 89B (0.4%);链式哈希失效 18.6KB (91.8%),波及 124 个块 | 爆炸半径放大 214.40x。安全的是"绝不拿到旧产物",不是"缓存能省" |
| 「构建慢就换语言重写」 | 全链路 114.9ms 里:建图(含解析) 58.5%、摇树 14.8%、链接 18.2%、哈希 8.4%;其中纯词法切分就占 31.5% | 先量。我的链接阶段原本是 O(语句×token),改单遍后 65.9ms → 21.1ms(3.13x),比换语言便宜得多 |
| 「自己写哈希比 sha256 快」 | BigInt 版 FNV-1a 16.26 MB/s;Math.imul 双通道版 344.38 MB/s;原生 sha256 478.39 MB/s | 手写 BigInt 哈希比原生 sha256 慢 29.4x。"JS 一定比 C 慢"在这里是错的 |
1.1 难点一:扫描不是"找字符串",是"避免把字符串当真边"
真正难的不是找到 import,而是在找到它之前知道自己处在什么语境里。同一段文本,import 四个字母出现在字符串、注释、正则字面量、模板插值里时,语义完全不同:
const tricky = [
'const t = `\nimport x from "./fake-tpl.mjs"\n`;',
'/*\nimport y from "./fake-block.mjs"\n*/',
'const re = /\nimport z from "\\/fake-re.mjs"\n/;',
];
更隐蔽的是 / 的歧义:if (a) /re/.test(b) 里是正则,(a + b) / 2 / c 里是除法,两者前面的 token 序列长度一样。工业界的解法是维护一个"控制语句括号栈":if/while/for/with/switch/catch 后面的 ( 被标记为 ctrl,与之配对的 ) 后面允许正则;普通 ( 配对出来的 ) 后面是除法。这一个细节就是 30 行代码,漏掉它,词法器会从某一处开始把整个文件切错。
1.2 难点二:改名不是字符串替换,是"作用域 + 语境"
打包器做作用域提升(scope hoisting)时要把每个模块的顶层绑定改名成全局唯一名(Rollup 就是这么干的)。词法级改名看起来能糊过去,但有两个必踩的坑:
坑 A:模板插值。 `${name}` 里 name 是引用,必须改;但它藏在 tpl token 的嵌套 parts 里,扁平遍历 token 流根本看不见。实测:400 个模块中 197 个用了模板插值,漏递归直接让整个产物抛 ReferenceError(0/400 通过)。一个 40 行函数级别的疏漏,在 197 个模块上同时爆发,而且只在运行时暴露——构建是全绿的。
坑 B:改名位置判定。 同一个标识符 token,出现在 obj.name(属性,不能改)、{ name: 1 }(键,不能改)、{ name }(简写属性,必须改成 name: mN$name)、return name(引用,要改)时,处置完全不同。
1.3 难点三:tree-shaking 的正确性边界
真正难的是"什么时候不能删":
- 顶层副作用:模块里有一句
globalThis.x = 1,整块就不能丢。 - import 语句假活:如果让 import 语句参与"存活语句"集合,那么入口里所有
import {a, b, c}都会把 a/b/c 判成活的——我第一版就是这样,产物里 3 个只有 1 个真正被用到的导出全部留着。 - 默认导出的名字口径:
export default function fmt(){}的"导出名"是default,"本地名"是fmt。用导出名做存活分析,具名默认导出永远摇不掉。 - 复导出边:
export { x } from './y.mjs'里的./y.mjs也是一条依赖边。漏掉它,./y.mjs根本不进图,产物里引用一个不存在的名字。
1.4 难点四:分块、哈希与"爆炸半径"
缓存不是"有没有 hash"的问题,是一次改动会让多少字节失效的问题。而这取决于两个彼此独立的选择:分块粒度(§2 表里的 100% → 0.4%)和哈希是否链上依赖(0.4% → 91.8%)。很多人只调前者,然后在"为什么改了 utils 全站缓存都废"里出不来——那是后者的锅。
1.5 难点五:确定性
同一个仓库、同一份源码,两次构建必须产出逐字节相同的结果(否则 CDN 上会出现内容相同、文件名不同的产物,缓存命中率归零)。这件事靠"小心"是保证不了的,必须靠固定的遍历序:本实现用"依赖先于依赖者"的后序 DFS(topoOrder)+ Tarjan 环检测。反例实测很直白:改成按文件枚举顺序拼接,打乱 5 次得到 5 种不同哈希。
1.6 难点六:性能,以及"你以为的瓶颈"
bench.mjs 的 §9 把 3000 模块冷启动拆开:建图(含解析)67.3ms / 摇树 17.0ms / 链接 20.9ms / 哈希 9.71ms,合计 114.9ms,其中纯词法切分 36.2ms = 31.5%。这个分布说明两件事:第一,词法/解析是整条管道的下限,想有量级提升只能换 parser(esbuild/SWC 用 Go/Rust 换的就是这 31.5%);第二,链接阶段我原本写了 O(语句数 × token 数)——对每条语句都线性扫一遍全部 token 来标记删除区间。3000 模块下它是 65.9ms,改成"token 单遍扫描 + 语句指针推进"后 21.1ms,快 3.13x 且输出逐字节相同。
这个 3.13x 比任何"上一个 Rust 重写"都便宜。先量,再换。
二、完整解法
2.1 词法器:正则歧义 + 模板递归
核心是两个状态:parens 栈(判断 ) 后面能不能是正则)和 parts(模板插值的嵌套 token)。
const CONTROL_KW = new Set(['if', 'while', 'for', 'with', 'switch', 'catch']);
const REGEX_PREV_KW = new Set(['return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void', 'throw', 'case', 'do', 'else', 'yield', 'await']);
function regexAllowed(prev) {
if (!prev) return true; // 文件开头
if (prev.t === 'num' || prev.t === 'str' || prev.t === 'tpl' || prev.t === 're') return false;
if (prev.t === 'id') return REGEX_PREV_KW.has(prev.v);
if (prev.t === 'punc') {
if (prev.v === ')') return prev.ctrl === true; // if(...) 的 ) → 允许正则
if (prev.v === ']') return false;
if (prev.v === '++' || prev.v === '--') return false;
return true;
}
return true;
}
ctrl 标记在遇到 ( 时记录"前一个 token 是不是控制关键字",遇到 ) 时出栈带出来。模板字符串则把 ${...} 里的表达式递归词法化,存成 parts,并记下绝对偏移 es/ee——后面改名要靠它还原位置。
实测性能:500 模块 / 196.3KB,tokenize 13.28ms(14.43 MB/s),scanModule 15.22ms(12.59 MB/s)。这个 14 MB/s 偏低,原因很具体——见 §2.6。
2.2 模块语法扫描:语句区间是后面一切的基础
扫描器不只回答"导入了什么",还要给出每条语句的字符区间(statements),因为 tree-shaking 要按语句删、链接要按语句删包装:
const stmtEnd = (k, groupAt = -1) => {
let d = 0;
let openedBlock = false;
for (let m = k; m < toks.length; m++) {
const t = toks[m];
if (t.t !== 'punc') continue;
if (t.v === '(' || t.v === '[') d++;
else if (t.v === ')' || t.v === ']') { d--; if (d < 0) return m; }
else if (t.v === '{') { if (m !== groupAt && d === 0) openedBlock = true; d++; }
else if (t.v === '}') {
d--;
if (d < 0) return m;
if (d === 0 && openedBlock) return m;
} else if (t.v === ';' && d === 0) return m;
}
return toks.length - 1;
};
这段 18 行的函数踩过两个真实的坑,都值得写进任何构建器的 code review checklist:
坑 1:export function f(){} 没有分号。 只找 ';' 的话,stmtEnd 会一路吞掉后面几十行,直到遇到下一个分号。测试里那条 无分号的 export function 不会吞掉后续语句 就是在守这个。
坑 2:说明符列表的 {} 不是语句块。 import { a } from './x' 里的 { a } 在括号深度上和函数体一模一样。所以 stmtEnd 加了 groupAt 参数:调用方把说明符 { 的下标传进来,那对花括号不参与"语句块"判定。没这个参数,import 语句会在 } 处提前结束,from './x' 被当作一条顶层语句——进而被判成副作用。
2.3 模块图:Tarjan 找环 + 后序定序
export function topoOrder(modules, entryId) {
const out = [];
const state = new Map(); // 1=在栈 2=完成
const walk = (id) => {
if (state.get(id) === 2) return;
if (state.get(id) === 1) return; // 环:跳过,不死循环
state.set(id, 1);
for (const d of modules[id].deps) walk(d.targetId);
state.set(id, 2);
out.push(id);
};
walk(entryId);
return out;
}
环用 Tarjan 单独识别(返回所有长度 > 1 的强连通分量 + 自环)。执行顺序用后序 DFS 保证"依赖先于依赖者",环检测用 Tarjan 保证不漏——两件事分开做,因为它们的正确条件不同。
还有一个容易被忽略的点:export { x } from './y.mjs' 也是依赖边。建图时必须把 scan.exports 里带 source 的项也 visit 一遍,否则被复导出的模块压根不进图,byPath.get(path) 直接拿到 undefined。
实测建图:200 / 1000 / 3000 模块 = 3.4ms / 16.8ms / 52.3ms,单模块摊薄 0.017 / 0.017 / 0.017ms,近似线性。
顺带一个反直觉的收获:3000 模块的项目,磁盘上 3001 个文件,只有 1201 个进了图、被解析。入口不引用的模块连词法分析都不做——这才是"项目很大但构建还行"的真正原因,也让"源码总量"这个指标基本没有参考价值。
2.4 tree-shaking:两级存活分析
模块内做语句级存活(导出声明 + 顶层声明的可达性传播),模块间做模块级存活(没被任何存活语句引用、且无副作用 → 整块丢弃)。
const markUsedExport = (modId, name) => {
const cur = usedExports.get(modId);
if (cur === '*') return false;
const set = cur ?? new Set();
if (set.has(name)) return false;
set.add(name);
usedExports.set(modId, set);
return true;
};
口径统一是关键:usedExports 里存的永远是本地绑定名(local),不是导出名(exported)。export default function fmt(){} 记 fmt,export { hidden as viaList } 记 hidden。这条规则让我修掉了"具名默认导出永远摇不掉"的 bug。
引用传播用 fixpoint:语句活着 → 它引用到的本模块声明也活着 → 递归。import 语句不参与这个集合(它们不产出代码),只按"存活语句真正引用到的本地名"决定是否需要向目标模块要那个导出。
sideEffects 判定的实测代价(§11):同一份 100 模块项目,全部干净时产物 4.0KB;给每个模块加一条顶层副作用语句后 10.2KB,放大 2.55x。这就是 package.json 里 "sideEffects": false 的真实价值——也是它配错会把有副作用的模块摇掉、造成线上故障的原因。
2.5 链接:作用域提升 + 改名(含模板递归)
改名表按模块建立:本地声明 → m{id}${name},导入的本地名 → 目标模块对应绑定的全局名。渲染时走单遍扫描:
const rewriteToken = (t, toks, k, renames) => {
if (t.t !== 'id') return t.v;
const prevV = toks[k - 1] ? toks[k - 1].v : undefined;
const nextV = toks[k + 1] ? toks[k + 1].v : undefined;
if (prevV === '.' || prevV === '?.') return t.v; // 属性访问
if (nextV === ':' && (prevV === '{' || prevV === ',')) return t.v; // 对象键
const target = renames.get(t.v);
if (!target) return t.v;
const prevIsOpen = prevV === '{' || prevV === ',';
const nextIsClose = nextV === '}' || nextV === ',';
if (prevIsOpen && nextIsClose) return `${t.v}: ${target}`; // 简写属性
return target;
};
模板 token 则整段交给递归改名器,它按 parts 里的绝对偏移把每段 ${...} 切出来重新处理:
export function renameSnippet(text, renames) {
if (!renames.size) return text;
const toks = tokenizeSnippet(text);
let out = '';
let prevEnd = 0;
for (let k = 0; k < toks.length; k++) {
const t = toks[k];
out += text.slice(prevEnd, t.s);
if (t.t === 'tpl' && t.parts) {
let rebuilt = '';
let last = t.s;
for (const part of t.parts) {
if (part.expr) {
rebuilt += text.slice(last, part.es) + renameSnippet(text.slice(part.es, part.ee), renames);
last = part.ee;
}
}
rebuilt += text.slice(last, t.e);
out += rebuilt;
} else {
out += rewriteToken(t, toks, k, renames);
}
prevEnd = t.e;
}
out += text.slice(prevEnd);
return out;
}
命名空间导入(import * as ns)会生成一个对象字面量把目标模块的导出聚起来。这里有个我自己踩的坑:这个 shim 必须挂成"导入方模块的 prelude",不能只拼在最终 code 字符串头部——否则分了块之后,chunkCode() 从 pieces 重新拼装时它凭空消失。单块构建看起来完全正常,一上多块就 Cannot read properties of undefined。
2.6 性能:三处实测出来的优化
① 字符分类用 charCode,不用正则。 词法器只有 14 MB/s,热路径就在这里:
const isIdPartCode = (c) => {
const x = c.charCodeAt(0);
return (x >= 48 && x <= 57) || (x >= 65 && x <= 90) || (x >= 97 && x <= 122) || x === 95 || x === 36;
};
200 万次判断:正则 .test() 39.3ms,charCode 比较 8.0ms,快 4.9x。每个字符都新建一次正则匹配上下文——这就是那 14 MB/s 的来历。
② 链接别写成双循环。 见 §1.6:65.9ms → 21.1ms(3.13x),输出逐字节相同。测试里我用 lF.code === lN.code 守住这个等价性——没有等价性断言的优化就是重写。
③ 哈希别再手搓 BigInt。 三种实现(200KB / 200 块):
| 实现 | 耗时 | 吞吐 |
|---|---|---|
| FNV-1a 64(BigInt 逐字符) | 12.04ms | 16.26 MB/s |
FNV-1a ×2 32(Math.imul 双通道) | 0.57ms | 344.38 MB/s |
| sha256(原生 OpenSSL) | 0.41ms | 478.39 MB/s |
Math.imul 版比 BigInt 版快 21.2x,但原生 sha256 仍比它快 1.4x,比 BigInt 版快 29.4x。生产里选 xxhash 从来不是为了"比 sha256 快",而是为了跨语言一致、增量哈希和抗碰撞;如果你在 JS 里手写哈希,"自己写的更快"基本是错觉。
2.7 测试:33 条断言 + 5 组真实 ESM 差分
单元断言能守住边界,但守不住语义。所以关键手段是差分对照:把同一份夹具真实写进临时目录,用原生 import() 跑一遍,再用打包产物跑一遍,逐个导出比对。
const native = await import(pathToFileURL(path.join(dir, 'src/main.mjs')).href);
const bundled = new Function('__ex', r.chunks[0].code + '\nreturn __ex;')({});
for (const k of Object.keys(native)) {
const nv = native[k], bv = bundled[k];
if (typeof nv === 'function') {
assert.equal(String(bv()), String(nv()), `导出 ${k}() 结果不一致`);
continue;
}
assert.deepEqual(bv, nv, `导出 ${k} 与原生不一致`);
}
5 组差分覆盖:常规项目(影子变量 / 解构 / 模板 / 命名空间 / 副作用)、复导出链(export {} from + export * 穿透两层)、副作用语句顺序、120 模块合成项目、默认导出四种形态(表达式 / 具名类 / 具名函数 / 列表改名)。
这 5 组差分抓出了 4 个我自己写出来的 bug:模板插值里的引用看不见、具名默认导出摇不掉、复导出边没进图、re-export 目标模块没被纳入。每一个都让构建全绿、产物出错。
其余断言里最值钱的三条:
// 1) 确定性:打乱文件枚举顺序,产物哈希必须不变
assert.equal(a.chunks[0].hash, b.chunks[0].hash, '输出不确定 → CI 里缓存会一直失效');
// 2) 分块粒度:改一个模块,per-module 只失效一块
assert.equal(changed, 1, `应只失效 1 块,实际 ${changed}`);
// 3) 无分号的 export function 不能吞掉后续语句
assert.ok(scanModule("export function f(){}\nexport const K = 1;").exports.some((e) => e.exported === 'K'));
三、应用场景
3.1 先定位"慢在哪一档",再谈选型
用本篇的全链路拆分当尺子,把常见现象对上号:
| 现象 | 对应阶段 | 该动的地方 |
|---|---|---|
| 冷启动慢、热更新慢但产物正常 | 建图(含解析)58.5%,词法 31.5% | 换 parser(esbuild/SWC 做 transform,Vite 默认就是这个组合);或做扫描缓存 |
| 改一行要等好几秒 | 链接/打包粒度 | 走 dev 不打包(Vite 的 ESM dev server);或 chunk 级增量(Turbopack) |
| 构建不慢,但每次改完用户还是要重下几 MB | 分块 + 哈希传播 | 调 chunk 策略、把 vendor 拆稳定的部分、检查哈希有没有把依赖链进来 |
| 产物比预期大很多 | tree-shaking 边界 | 查 sideEffects 配置、查有没有顶层副作用语句、查 export default 口径 |
| 昨天的产物和今天一模一样但 hash 变了 | 确定性 | 查遍历序有没有依赖 readdir/Object.keys/时间戳 |
3.2 「拆多细」是一道有唯一正确答案的题
§4 的四个策略在同一次一行改动下的表现:
| 策略 | 块数 | 总字节 | 需重下 | 比例 |
|---|---|---|---|---|
| single | 1 | 20.4KB | 20.4KB | 100.0% |
| vendor | 1 | 20.4KB | 20.4KB | 100.0% |
| shared | 2 | 20.4KB | 12.5KB | 61.1% |
| per-module | 141 | 20.3KB | 89B | 0.4% |
注意 vendor 在这里退化成 1 块(合成项目没有真实 node_modules),这恰好说明策略必须匹配项目结构。而 per-module 的 0.4% 不是"免费"——它是 141 个 HTTP 请求,在 HTTP/1.1 下会被队头阻塞吃光,在 HTTP/2 下也有服务器并发流上限。
工程答案: 用"引用计数"分层(shared 那一路),把被 ≥2 个入口引用、且变更频率低的模块放进长期缓存块;把高频变更的业务代码按路由切。判据不是"越小越好",是变更频率 × 共享度的二维聚类。
3.3 哈希链:安全性与缓存命中率的正面冲突
§5 的数字值得单独记:一次叶子改动,只哈希自身失效 89B(0.4%),链式哈希失效 18.6KB(91.8%),波及 124 个块,爆炸半径放大 214.40x。
链式哈希(把依赖的 hash 也算进本块 hash)买的是"依赖变了不可能拿到旧产物"这个硬保证,代价是缓存命中率。所以:
- 产物之间的依赖(A chunk 引用了 B chunk 的导出的名字/序号)→ 必须链,否则运行时对不上。
- 只是"逻辑上相关"(A 和 B 都 import 了 utils)→ 不该链。
分清这两者,是"改了 utils 全站缓存全废"和"上线白屏资源 404"之间唯一的分界线。
3.4 什么时候真的该自己写构建器
不是"造轮子好玩",是三种具体的场景:
- 库作者:你要产出的是
esm+cjs+.d.ts三件套,需要精确控制external、preserveModules、sideEffects——这些行为必须能从源码层面解释,不能靠试。 - 私有语法 / DSL:公司内部的
definePage()、自研的模板指令需要 transform 插件,而插件写对的前提是知道扫描器怎么切 token、怎么判副作用。 - 构建卡点必须自己定位:CI 上 8 分钟构建,厂商只会说"升级版本"。本篇这套拆分(建图/摇树/链接/哈希 + 词法占比)可以在一小时内指出是哪一档超了预算。
3.5 与前后篇的咬合
- Day25 渲染管线 / Day29 运行时性能 / Day34 性能监控:本篇的产物直接决定前三篇量的那些指标。性能预算的卡点就应该落在这里——构建时把首屏 chunk 字节卡在阈值上,比上线后看 LCP 便宜两个数量级。
- Day26 加载性能 / Day30 bundle 优化:Day30 讲的拆包/预加载/manifest,其上游就是本篇的
planChunks与contentHash。分块策略选错,Day30 的一切优化都白做。 - Day28 SSR/SSG/ISR:SSR 的产物也要经过构建,且"服务端 chunk / 客户端 chunk"是两套图,本篇的确定性要求对它是硬约束(否则每次 build 都产生新文件)。
- Day33 WASM:WASM 资源怎么进 chunk、怎么算 hash、怎么做流式编译,落在本篇的分块与哈希里。
- Day36 Monorepo / Day38 流水线:增量构建与远程缓存的地基就是本篇 §10——扫描缓存能省掉解析(198ms → 131ms,1.5x),但省不掉图遍历;要再往上就得做 chunk 级增量。
- Day40 可观测性:构建埋点(各阶段耗时、缓存命中率、产物体积 diff)应该和运行时的 correlation id 打通,才能回答"这次变慢是代码变了还是构建变了"。
四、总结
第一,构建工具的核心不是"打包",是"精确的语境判断"。 找 import 是 5 分钟的事,判断这个 import 是真代码还是注释里的字面量,是 500 行的事。三个伪装样本 3/3 误报,而它比真词法器快 43.7x——快的部分正好是不需要的部分。
第二,tree-shaking 的收益主要来自"没进图",不来自"删得巧"。 96.5% 的降幅里有 270/301 个模块从未被解析过。真正需要算法的地方是那些反例:顶层副作用、具名默认导出、复导出边。(2.55x 的副作用代价就是最好的注脚。)
第三,缓存是两个独立旋钮,别只拧一个。 分块粒度管 100% → 0.4%,哈希链管子 0.4% → 91.8%(214.40x)。前者是体积与请求数的权衡,后者是安全性与命中率的权衡。混为一谈,就会在"缓存没生效"和"上线 404"之间反复横跳。
第四,确定性是构建的硬指标,而且是可以用断言守住的。 打乱枚举顺序后 5 种不同哈希——这种 bug 不会让构建失败,只会让 CDN 缓存静默失效。一条 assert.equal(hash1, hash2) 就永久解决。
第五,性能优化要先量,而且量之前先对齐口径。 我第一次测"词法占全链路多少"时得出了"建图比纯词法还快"的荒谬结论——因为词法基准跑了全部 3001 个文件,而建图只解析可达的 1201 个。口径不对齐,所有优化决策都是错的。 对齐之后真相很清楚:词法 31.5%、链接 18.2%(原本 46%,一个双循环把它抬上去的)。
最后一句: 33 条断言 + 5 组真实 ESM 差分,抓出了 4 个"构建全绿、产物出错"的 bug——漏递归模板插值、具名默认导出摇不掉、复导出边没进图、命名空间 shim 在分块时消失。构建工具的质量不体现在它能打包,体现在它拒绝打包错的东西。 下一篇 Day36 进 Monorepo,那个话题的每一个痛点(依赖提升、幽灵依赖、增量构建、跨包引用)都会回到今天这张模块图上。
全部代码与实测脚本:
day35-assets/(scan.mjs/build.mjs/fixtures.mjs/test-bundler.mjs/bench.mjs/bench-log.txt)。 复现:node test-bundler.mjs(33 条断言,exit 0)、node bench.mjs(13 组实测,全部数字现场跑出)。
附录:完整代码
以下脚本均来自
day35-assets/(完整源码)。 运行:node test-bundler.mjs验证构建器语义;node bench.mjs跑基准;node extract-and-test.mjs提取正文代码块逐段自检。
build.mjs —— 手写构建器内核(依赖图、转换、打包、chunk 切分,837 行)
// build.mjs —— 零依赖迷你打包器:模块图 → tree-shaking → 作用域提升链接 → 分块 → 内容哈希
import { scanModule, hasSideEffects, tokenize as tokenizeSnippet } from './scan.mjs';
const EXTS = ['.mjs', '.js'];
let uid = 0;
/* ============ 1. 虚拟文件系统与解析 ============ */
export function createFS(files) {
// files: Map<path, source> 或普通对象;一律转成 Map,路径规范成不带 ./ 的形式
const map = new Map();
const src = files instanceof Map ? [...files.entries()] : Object.entries(files);
for (const [k, v] of src) map.set(norm(k), v);
return {
get: (p) => map.get(norm(p)),
has: (p) => map.has(norm(p)),
keys: () => [...map.keys()],
size: () => map.size,
set: (p, v) => map.set(norm(p), v),
clone: () => createFS(new Map(map)),
raw: map,
};
}
function norm(p) {
const parts = p.replace(/\\/g, '/').split('/');
const out = [];
for (const seg of parts) {
if (seg === '' || seg === '.') continue;
if (seg === '..') { out.pop(); continue; }
out.push(seg);
}
return out.join('/');
}
function dirname(p) {
const i = p.lastIndexOf('/');
return i === -1 ? '' : p.slice(0, i);
}
/**
* 解析模块说明符。真实构建器这里是 npm 解析算法(exports 字段/条件导出/自引用…),
* 这里只实现相对路径 + 扩展名探测 + 目录 index,够覆盖 ESM 语义差异。
*/
export function resolveSpec(fs, fromPath, spec) {
if (!spec.startsWith('.')) {
// 裸模块:当作 node_modules 下的路径
const base = `node_modules/${spec}`;
for (const cand of probe(fs, base)) return cand;
return null;
}
const base = norm(`${dirname(fromPath)}/${spec}`);
for (const cand of probe(fs, base)) return cand;
return null;
}
function* probe(fs, base) {
if (fs.has(base) && !isDirish(fs, base)) { yield base; return; }
for (const e of EXTS) {
if (fs.has(base + e)) { yield base + e; return; }
}
for (const e of EXTS) {
const idx = `${base}/index${e}`;
if (fs.has(idx)) { yield idx; return; }
}
}
// 虚拟 FS 里用 "a/" 结尾代表目录占位,直接忽略
const isDirish = (fs, p) => p.endsWith('/');
/* ============ 2. 模块图 ============ */
export function buildGraph(fs, entryPath, opts = {}) {
fs = fs.raw ? fs : createFS(fs);
const entry = norm(entryPath);
const byPath = new Map();
const modules = [];
const unresolved = [];
// 扫描缓存:键=路径,值={source, scan}。源码按字符串比较,
// 命中就直接复用上次的词法/语法结果 —— 这就是"增量构建"能成立的全部原因。
const cache = opts.cache ?? null;
let cacheHit = 0, cacheMiss = 0;
const addModule = (path) => {
const source = fs.get(path);
if (source === undefined) throw new Error(`模块不存在: ${path}`);
let scan;
const cached = cache ? cache.get(path) : undefined;
if (cached && cached.source === source) { scan = cached.scan; cacheHit++; }
else { scan = scanModule(source); cacheMiss++; if (cache) cache.set(path, { source, scan }); }
const mod = {
id: modules.length,
path,
source,
scan,
bytes: source.length,
// 静态依赖(会参与 tree-shaking 与执行顺序)
deps: [],
// 动态 import(单独成块,不参与 tree-shaking)
dynamicDeps: [],
sideEffects: hasSideEffects(scan),
};
modules.push(mod);
byPath.set(path, mod);
return mod;
};
const visit = (path) => {
if (byPath.has(path)) return byPath.get(path);
const mod = addModule(path);
for (const imp of mod.scan.imports) {
const target = resolveSpec(fs, path, imp.source);
if (!target) { unresolved.push({ from: path, spec: imp.source }); continue; }
mod.deps.push({ spec: imp.source, targetPath: target });
}
// `export {x} from './y'` / `export * from './y'` 同样是依赖边,
// 漏掉它们会导致被复导出的模块根本没进图(踩过:byPath.get() 拿到 undefined)
for (const ex of mod.scan.exports) {
if (!ex.source) continue;
const target = resolveSpec(fs, path, ex.source);
if (!target) { unresolved.push({ from: path, spec: ex.source }); continue; }
if (!mod.deps.some((d) => d.targetPath === target)) mod.deps.push({ spec: ex.source, targetPath: target });
}
for (const spec of mod.scan.dynamicImports) {
if (spec == null) { mod.dynamicDeps.push({ spec: null, targetPath: null }); continue; }
const target = resolveSpec(fs, path, spec);
if (!target) { unresolved.push({ from: path, spec }); continue; }
mod.dynamicDeps.push({ spec, targetPath: target });
}
if (mod.sideEffects) mod.sideEffectDeps = mod.deps.length; // 仅作记录
for (const d of mod.deps) visit(d.targetPath);
for (const d of mod.dynamicDeps) if (d.targetPath) visit(d.targetPath);
return mod;
};
const entryMod = visit(entry);
// 回填 targetId(因为 visit 期间 id 可能尚未分配)
for (const mod of modules) {
for (const d of mod.deps) d.targetId = byPath.get(d.targetPath).id;
for (const d of mod.dynamicDeps) d.targetId = d.targetPath ? byPath.get(d.targetPath).id : null;
}
const cycles = findCycles(modules);
const order = topoOrder(modules, entryMod.id);
return {
fs, entry, entryId: entryMod.id, modules, byPath, order, cycles, unresolved,
scanStats: { cacheHit, cacheMiss },
};
}
/** Tarjan 强连通分量:返回所有长度>1 的环(含自环) */
export function findCycles(modules) {
const index = new Map();
const low = new Map();
const onStack = new Set();
const stack = [];
const comps = [];
let counter = 0;
const strong = (v) => {
index.set(v, counter); low.set(v, counter); counter++;
stack.push(v); onStack.add(v);
for (const d of modules[v].deps) {
const w = d.targetId;
if (!index.has(w)) { strong(w); low.set(v, Math.min(low.get(v), low.get(w))); }
else if (onStack.has(w)) low.set(v, Math.min(low.get(v), index.get(w)));
}
if (low.get(v) === index.get(v)) {
const comp = [];
let w;
do { w = stack.pop(); onStack.delete(w); comp.push(w); } while (w !== v);
comps.push(comp);
}
};
for (const m of modules) if (!index.has(m.id)) strong(m.id);
return comps.filter((c) => {
if (c.length > 1) return true;
// 自环
const v = c[0];
return modules[v].deps.some((d) => d.targetId === v);
});
}
/** 后序 DFS:依赖先于依赖者(环内按发现顺序打破) */
export function topoOrder(modules, entryId) {
const out = [];
const state = new Map(); // 0=未访问 1=在栈 2=完成
const walk = (id) => {
if (state.get(id) === 2) return;
if (state.get(id) === 1) return; // 环:直接跳过,避免死循环
state.set(id, 1);
for (const d of modules[id].deps) walk(d.targetId);
state.set(id, 2);
out.push(id);
};
walk(entryId);
return out;
}
/* ============ 3. 导出解析(含 re-export 穿透) ============ */
/**
* 把「模块 M 的导出名 name」解析成「某个模块的本地绑定」。
* 需要穿透 export {x} from './y' 与 export * from './y',并防止环。
*/
export function resolveExport(graph, modId, name, seen = new Set()) {
const key = `${modId}#${name}`;
if (seen.has(key)) return null;
seen.add(key);
const mod = graph.modules[modId];
for (const e of mod.scan.exports) {
if (e.exported !== name) continue;
if (e.source) {
const target = resolveSpec(graph.fs, mod.path, e.source);
if (!target) return null;
const tid = graph.byPath.get(target).id;
return resolveExport(graph, tid, e.imported, seen);
}
return { modId, local: e.local };
}
// export * 穿透
for (const e of mod.scan.exports) {
if (e.exported !== '*' || !e.source) continue;
const target = resolveSpec(graph.fs, mod.path, e.source);
if (!target) continue;
const tid = graph.byPath.get(target).id;
const r = resolveExport(graph, tid, name, seen);
if (r) return r;
}
return null;
}
/** 模块对外暴露的全部导出名(含穿透来的) */
export function allExportNames(graph, modId, seen = new Set()) {
if (seen.has(modId)) return [];
seen.add(modId);
const mod = graph.modules[modId];
const names = [];
for (const e of mod.scan.exports) {
if (e.exported === '*') {
const target = resolveSpec(graph.fs, mod.path, e.source);
if (target) names.push(...allExportNames(graph, graph.byPath.get(target).id, seen));
} else if (!names.includes(e.exported)) {
names.push(e.exported);
}
}
return names;
}
/* ============ 4. Tree-shaking ============ */
/**
* 两件事:
* A. 语句级存活分析(模块内:导出声明 + 顶层声明的可达性传播)
* B. 模块级存活分析(没被任何存活语句引用的、且无副作用的模块整块丢弃)
*/
export function treeShake(graph) {
const M = graph.modules;
const stmtCount = (m) => m.scan.statements.length;
// ---- 每个模块的语句元信息 ----
for (const mod of M) {
mod.stmtInfo = mod.scan.statements.map((st, i) => ({
i, kind: st.kind, names: st.names, start: st.start, end: st.end,
}));
// 顶层非 import/export 的语句 = 潜在副作用语句(由 scan 的 topLevelStmts 粗判)
mod.declStmt = new Map(); // name -> stmt index
for (const si of mod.stmtInfo) {
if (si.kind === 'export-decl' || si.kind === 'export-list' || si.kind === 'export-default') {
for (const nm of si.names) mod.declStmt.set(nm, si.i);
}
}
}
const included = new Set(); // 整模块纳入
const usedExports = new Map(); // modId -> Set<name> | '*'
const keptStmts = new Map(); // modId -> Set<stmtIndex>
const markUsedExport = (modId, name) => {
const cur = usedExports.get(modId);
if (cur === '*') return false;
const set = cur ?? new Set();
if (set.has(name)) return false;
set.add(name);
usedExports.set(modId, set);
return true;
};
// 入口整个模块纳入
included.add(graph.entryId);
usedExports.set(graph.entryId, '*');
let changed = true;
const guard = 10000;
let iter = 0;
while (changed) {
changed = false;
if (++iter > guard) throw new Error('tree-shake 未收敛');
for (const mod of M) {
const whole = usedExports.get(mod.id) === '*';
if (!included.has(mod.id)) continue;
// ---- 模块内语句存活 ----
const kept = new Set();
// 有副作用的模块:所有语句都必须保留(顺序也保留)
const keepAll = mod.sideEffects || whole;
if (keepAll) {
// import 语句本身不产出代码,不能当"存活语句"参与引用传播,
// 否则 entry 里所有 import 的本地名都会假活(踩过)
for (const si of mod.stmtInfo) { if (si.kind !== 'import') kept.add(si.i); }
} else {
const used = usedExports.get(mod.id) ?? new Set();
for (const si of mod.stmtInfo) {
if (si.kind === 'statement') { kept.add(si.i); continue; }
if (si.kind === 'import') continue; // 单独处理
const hit = si.names.some((n) => used.has(n));
if (hit) kept.add(si.i);
}
}
// ---- 引用传播:保持活着的语句引用到的本地声明也要活 ----
// 用 token 区间取引用集合(scan 已给出语句区间)
const localRefs = collectStmtRefs(mod);
let grew = true;
while (grew) {
grew = false;
for (const si of mod.stmtInfo) {
if (!kept.has(si.i)) continue;
for (const nm of localRefs.get(si.i) ?? []) {
const declSi = mod.declStmt.get(nm);
if (declSi !== undefined && !kept.has(declSi)) { kept.add(declSi); grew = true; }
}
}
}
const prev = keptStmts.get(mod.id);
keptStmts.set(mod.id, kept);
if (!prev || prev.size !== kept.size) changed = true;
// ---- 模块级:被存活语句用到的 import 才算需要 ----
const liveRefs = new Set();
for (const si of mod.stmtInfo) {
if (!kept.has(si.i)) continue;
for (const nm of localRefs.get(si.i) ?? []) liveRefs.add(nm);
}
// 只按"存活语句真正引用到的本地名"决定 import 是否需要。
// (有副作用的模块里所有语句都存活,liveRefs 自然覆盖,无需特判)
const needAllImports = false;
for (const imp of mod.scan.imports) {
if (imp.sideEffectOnly) {
const t = resolveSpec(graph.fs, mod.path, imp.source);
if (!t) continue;
const tid = graph.byPath.get(t).id;
if (!included.has(tid)) { included.add(tid); changed = true; }
continue;
}
for (const sp of imp.specifiers) {
if (!needAllImports && !liveRefs.has(sp.local)) continue;
const r = resolveExport(graph, mod.id, sp.imported === '*' ? '__ns__' : sp.imported);
const t = resolveSpec(graph.fs, mod.path, imp.source);
if (!t) continue;
const tid = graph.byPath.get(t).id;
if (!included.has(tid)) { included.add(tid); changed = true; }
if (sp.imported === '*') {
if (usedExports.get(tid) !== '*') { usedExports.set(tid, '*'); changed = true; }
} else {
const target = resolveExport(graph, tid, sp.imported);
if (target) {
if (!included.has(target.modId)) { included.add(target.modId); changed = true; }
if (markUsedExport(target.modId, target.local)) changed = true;
}
void r;
}
}
}
// 复导出边:`export {x} from` / `export * from` 的目标必须纳入
for (const ex of mod.scan.exports) {
if (!ex.source) continue;
const tt = resolveSpec(graph.fs, mod.path, ex.source);
if (!tt) continue;
const tid2 = graph.byPath.get(tt).id;
if (!included.has(tid2)) { included.add(tid2); changed = true; }
}
// 动态导入:始终纳入(单独成块),不动摇 shake
for (const d of mod.dynamicDeps) {
if (d.targetId != null && !included.has(d.targetId)) { included.add(d.targetId); changed = true; }
}
}
}
// 统计
const includedMods = M.filter((m) => included.has(m.id));
const totalBytes = M.reduce((a, m) => a + m.bytes, 0);
const keptBytes = includedMods.reduce((a, m) => a + moduleEmittedSize(m, keptStmts.get(m.id)), 0);
return {
included, usedExports, keptStmts,
stats: {
modulesTotal: M.length,
modulesIncluded: includedMods.length,
modulesDropped: M.length - includedMods.length,
bytesTotal: totalBytes,
bytesKept: keptBytes,
},
};
}
/** 估算某模块实际会输出的源码字节(按保留的语句累加,含模块壳开销为 0) */
function moduleEmittedSize(mod, kept) {
if (!kept) return 0;
let n = 0;
const stmts = mod.scan.statements;
for (const si of mod.stmtInfo) {
if (!kept.has(si.i)) continue;
n += si.end - si.start;
}
return n;
}
/** 每条语句引用到的标识符(排除属性名与对象键),用 token 区间切 */
function collectStmtRefs(mod) {
if (mod._stmtRefs) return mod._stmtRefs;
const map = new Map();
// 模板插值是嵌套 token 数组:不展开就看不见 `${importedName}`
const flatten = (list, base = 0, out = []) => {
for (const tk of list) {
if (tk.t === 'tpl' && tk.parts) {
for (const pt of tk.parts) {
// 插值里的 token 偏移是"相对于插值片段"的,必须加上片段起点还原成绝对偏移
if (pt.expr) flatten(pt.expr, pt.es, out);
}
continue;
}
out.push(base === 0 ? tk : { ...tk, s: tk.s + base, e: tk.e + base });
}
return out;
};
const toks = flatten(mod.scan.toks);
for (const si of mod.stmtInfo) {
const refs = new Set();
for (let k = 0; k < toks.length; k++) {
const t = toks[k];
if (t.t !== 'id') continue;
if (t.s < si.start || t.e > si.end) continue;
const prevV = toks[k - 1] ? toks[k - 1].v : undefined;
const nextV = toks[k + 1] ? toks[k + 1].v : undefined;
if (prevV === '.' || prevV === '?.') continue;
if (nextV === ':' && (prevV === '{' || prevV === ',')) continue;
refs.add(t.v);
}
map.set(si.i, refs);
}
mod._stmtRefs = map;
return map;
}
/* ============ 5. 链接(作用域提升 + 改名) ============ */
const g = (modId, name) => `m${modId}$${name}`;
export function link(graph, shake, opts = {}) {
const emit = opts.emitter === 'naive' ? emitModuleNaive : emitModule;
const M = graph.modules;
const pieces = [];
const renamesByMod = new Map();
let nsShimBytes = 0;
// 每个模块建立改名表:本地名 → 全局名
for (const mod of M) {
if (!shake.included.has(mod.id)) continue;
const rn = new Map();
for (const d of mod.scan.decls) rn.set(d, g(mod.id, d));
for (const imp of mod.scan.imports) {
for (const sp of imp.specifiers) {
if (sp.imported === '*') { rn.set(sp.local, g(mod.id, sp.local)); continue; }
const t = resolveSpec(graph.fs, mod.path, imp.source);
if (!t) continue;
const tid = graph.byPath.get(t).id;
const target = resolveExport(graph, tid, sp.imported);
if (target) rn.set(sp.local, g(target.modId, target.local));
}
}
renamesByMod.set(mod.id, rn);
}
// 命名空间导入:生成一个对象字面量把目标模块的导出聚起来。
// 必须挂成"导入方模块的 prelude",否则分块时会被丢掉(这个坑真踩过:
// 单块构建看起来正常,一旦走 chunkCode 重新拼装 ns 声明就凭空消失)。
const nsDeclsByMod = new Map();
for (const mod of M) {
if (!shake.included.has(mod.id)) continue;
const kept = shake.keptStmts.get(mod.id);
if (!kept) continue;
for (const imp of mod.scan.imports) {
const t = resolveSpec(graph.fs, mod.path, imp.source);
if (!t) continue;
const tid = graph.byPath.get(t).id;
for (const sp of imp.specifiers) {
if (sp.imported !== '*') continue;
const fields = allExportNames(graph, tid)
.map((n) => {
const r = resolveExport(graph, tid, n);
return r ? `${JSON.stringify(n)}: ${g(r.modId, r.local)}` : null;
})
.filter(Boolean);
const line = `const ${g(mod.id, sp.local)} = { ${fields.join(', ')} };`;
nsShimBytes += line.length + 1;
const arr = nsDeclsByMod.get(mod.id) ?? [];
arr.push(line);
nsDeclsByMod.set(mod.id, arr);
}
}
}
// 依赖先于依赖者
for (const id of graph.order) {
if (!shake.included.has(id)) continue;
const mod = M[id];
const kept = shake.keptStmts.get(id) ?? new Set();
const code = emit(mod, kept, renamesByMod.get(id), mod.scan);
const prelude = (nsDeclsByMod.get(id) ?? []).join('\n');
const full = [prelude, code].filter((x) => x && x.trim()).join('\n');
if (!full.trim()) continue;
pieces.push({ modId: id, path: mod.path, code: full });
}
const code = pieces.map((p) => p.code).join('\n');
return {
code, pieces, nsShimBytes,
stats: { bytes: code.length, pieces: pieces.length },
};
}
/**
* 把一个模块按保留语句 emit 出来:删 import/export 包装 + 改名。
*
* 这里是全流程最容易写出 O(n²) 的地方:
* 第一版对"每条语句"都线性扫一遍全部 token 来标记删除区间,
* 复杂度 = 语句数 × token 数。3000 模块实测链接阶段占全链路 46%,
* 比词法+解析还贵。改成"token 单走一遍 + 语句指针推进"后为 O(token)。
*/
function emitModule(mod, kept, renames, scan) {
const src = mod.source;
const toks = scan.toks;
const stmts = (mod.stmtInfo ?? scanStatements(mod, scan)).slice()
.sort((a, b) => a.start - b.start);
const action = new Uint8Array(toks.length); // 1 = 删除
const insertBefore = new Map();
const firstTok = new Map(); // stmtIndex -> 首 token 下标
let ptr = 0;
for (let k = 0; k < toks.length; k++) {
const t = toks[k];
while (ptr < stmts.length && stmts[ptr].end <= t.s) ptr++;
if (ptr >= stmts.length) break;
const si = stmts[ptr];
if (t.s < si.start || t.e > si.end) continue; // 语句之外
if (!firstTok.has(si.i)) firstTok.set(si.i, k);
const k0 = firstTok.get(si.i);
if (!kept.has(si.i)) { action[k] = 1; continue; }
if (si.kind === 'import' || si.kind === 'export-list' || si.kind === 'export-star') {
action[k] = 1;
continue;
}
if (si.kind === 'export-decl') {
// 只删 export 关键字,声明本体留下
if (k === k0) action[k] = 1;
continue;
}
if (si.kind === 'export-default') {
// 具名 function/class → 连同 default 一起删;否则补 `const mN$__default = `
const namedFnClass = toks[k0 + 2] && ['function', 'class', 'async'].includes(toks[k0 + 2].v)
&& toks[k0 + 3] && toks[k0 + 3].t === 'id';
if (k === k0 || k === k0 + 1) action[k] = 1;
else if (k === k0 + 2 && !namedFnClass) insertBefore.set(k, `const ${g(mod.id, '__default')} = `);
}
}
let out = '';
let prevEnd = 0;
for (let k = 0; k < toks.length; k++) {
const t = toks[k];
const gap = src.slice(prevEnd, t.s);
if (action[k] === 1) {
const nl = countNewlines(gap);
out += nl ? '\n'.repeat(nl) : ' ';
prevEnd = t.e;
continue;
}
out += gap;
if (insertBefore.has(k)) out += insertBefore.get(k);
let rep;
if (t.t === 'tpl' && t.parts) rep = renameSnippet(t.v, renames);
else rep = rewriteToken(t, toks, k, renames);
out += rep;
prevEnd = t.e;
}
out += src.slice(prevEnd);
return clean(out);
}
/** 第一版的 O(语句×token) 实现,仅保留给 bench 做"改前 vs 改后"对照 */
export function emitModuleNaive(mod, kept, renames, scan) {
const src = mod.source;
const toks = scan.toks;
const action = new Map();
const insertBefore = new Map();
for (const si of (mod.stmtInfo ?? scanStatements(mod, scan))) {
if (!kept.has(si.i)) {
for (let k = 0; k < toks.length; k++) {
const t = toks[k];
if (t.s >= si.start && t.e <= si.end) action.set(k, 1);
}
continue;
}
if (si.kind === 'import' || si.kind === 'export-list' || si.kind === 'export-star') {
for (let k = 0; k < toks.length; k++) {
const t = toks[k];
if (t.s >= si.start && t.e <= si.end) action.set(k, 1);
}
continue;
}
let k0 = -1;
for (let k = 0; k < toks.length; k++) if (toks[k].s === si.start) { k0 = k; break; }
if (k0 < 0) continue;
if (toks[k0].v === 'export') {
if (si.kind === 'export-default') {
const namedFnClass = toks[k0 + 2] && ['function', 'class', 'async'].includes(toks[k0 + 2].v)
&& toks[k0 + 3] && toks[k0 + 3].t === 'id';
action.set(k0, 1); action.set(k0 + 1, 1);
if (!namedFnClass) insertBefore.set(k0 + 2, `const ${g(mod.id, '__default')} = `);
} else {
action.set(k0, 1);
}
}
}
let out = '';
let prevEnd = 0;
for (let k = 0; k < toks.length; k++) {
const t = toks[k];
const gap = src.slice(prevEnd, t.s);
if (action.get(k) === 1) {
const nl = countNewlines(gap);
out += nl ? '\n'.repeat(nl) : ' ';
prevEnd = t.e;
continue;
}
out += gap;
if (insertBefore.has(k)) out += insertBefore.get(k);
out += (t.t === 'tpl' && t.parts) ? renameSnippet(t.v, renames) : rewriteToken(t, toks, k, renames);
prevEnd = t.e;
}
out += src.slice(prevEnd);
return clean(out);
}
function countNewlines(s) {
let n = 0;
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 10) n++;
return n;
}
function clean(s) {
// 折叠多余空白行,避免删语句留下大段空白把体积算虚
return s.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
}
function scanStatements(mod, scan) {
return scan.statements.map((st, i) => ({
i, kind: st.kind, names: st.names, start: st.start, end: st.end,
}));
}
/**
* 对一段独立源码片段做改名(模板字符串的 ${...} 插值用)。
* 词法改名最容易漏的就是插值:`${value}` 里的 value 不走这个递归就会留下原名 → ReferenceError。
*/
export function renameSnippet(text, renames) {
if (!renames.size) return text;
const toks = tokenizeSnippet(text);
let out = '';
let prevEnd = 0;
for (let k = 0; k < toks.length; k++) {
const t = toks[k];
out += text.slice(prevEnd, t.s);
if (t.t === 'tpl' && t.parts) {
let rebuilt = '';
let last = t.s;
for (const part of t.parts) {
if (part.expr) {
rebuilt += text.slice(last, part.es) + renameSnippet(text.slice(part.es, part.ee), renames);
last = part.ee;
}
}
rebuilt += text.slice(last, t.e);
out += rebuilt;
} else {
out += rewriteToken(t, toks, k, renames);
}
prevEnd = t.e;
}
out += text.slice(prevEnd);
return out;
}
/** 单 token 改名:处理属性访问、对象键、简写属性 */
function rewriteToken(t, toks, k, renames) {
if (t.t !== 'id') return t.v;
const prevV = toks[k - 1] ? toks[k - 1].v : undefined;
const nextV = toks[k + 1] ? toks[k + 1].v : undefined;
if (prevV === '.' || prevV === '?.') return t.v;
if (nextV === ':' && (prevV === '{' || prevV === ',')) return t.v; // 对象键
const target = renames.get(t.v);
if (!target) return t.v;
// 简写属性 { foo } → { foo: m0$foo }
const prevIsOpen = prevV === '{' || prevV === ',';
const nextIsClose = nextV === '}' || nextV === ',';
if (prevIsOpen && nextIsClose) return `${t.v}: ${target}`;
return target;
}
/* ============ 6. 分块 ============ */
/**
* 四种分块策略,用来对比"改一个字节会让多少字节的缓存失效"。
* - single: 全部一个块
* - vendor: node_modules 单独一块
* - shared: 被 ≥2 个入口引用的模块进共享块
* - per-module: 一模块一块(极端情况)
*/
export function planChunks(graph, shake, strategy) {
const includedIds = [...shake.included];
const isVendor = (m) => m.path.startsWith('node_modules/');
if (strategy === 'single') {
return [{ name: 'bundle', modIds: includedIds }];
}
if (strategy === 'vendor') {
const v = includedIds.filter((id) => isVendor(graph.modules[id]));
const a = includedIds.filter((id) => !isVendor(graph.modules[id]));
const chunks = [];
if (a.length) chunks.push({ name: 'app', modIds: a });
if (v.length) chunks.push({ name: 'vendor', modIds: v });
return chunks;
}
if (strategy === 'per-module') {
return includedIds.map((id) => ({ name: `m${id}`, modIds: [id] }));
}
// shared:统计每个模块被多少个入口可达(这里以"被多少个模块依赖"近似引用计数)
const refCount = new Map();
for (const mod of graph.modules) {
if (!shake.included.has(mod.id)) continue;
for (const d of mod.deps) {
if (d.targetId == null) continue;
refCount.set(d.targetId, (refCount.get(d.targetId) ?? 0) + 1);
}
}
const shared = [];
const own = [];
for (const id of includedIds) {
if (id === graph.entryId) { own.push(id); continue; }
if ((refCount.get(id) ?? 0) >= 2) shared.push(id); else own.push(id);
}
const chunks = [];
if (own.length) chunks.push({ name: 'entry', modIds: own });
if (shared.length) chunks.push({ name: 'shared', modIds: shared });
return chunks;
}
/** 把 chunk 的模块代码拼起来(用于算体积与哈希) */
export function chunkCode(linkResult, chunk) {
const set = new Set(chunk.modIds);
return linkResult.pieces.filter((p) => set.has(p.modId)).map((p) => p.code).join('\n');
}
/* ============ 7. 内容哈希 ============ */
const FNV_OFFSET = 0xcbf29ce484222325n;
const FNV_PRIME = 0x100000001b3n;
const MASK64 = (1n << 64n) - 1n;
/** FNV-1a 64,按 UTF-16 code unit 处理(真实工具用 xxhash/content hash,思路一致) */
export function contentHash(str) {
let h = FNV_OFFSET;
for (let i = 0; i < str.length; i++) {
h ^= BigInt(str.charCodeAt(i) & 0xff);
h = (h * FNV_PRIME) & MASK64;
h ^= BigInt(str.charCodeAt(i) >> 8);
h = (h * FNV_PRIME) & MASK64;
}
return h.toString(16).padStart(16, '0');
}
/** 双通道 FNV-1a 32 位(Math.imul)拼 64 位:比 BigInt 版快一个量级 */
export function contentHashFast(str) {
let h1 = 0x811c9dc5, h2 = 0x01000193;
for (let i = 0; i < str.length; i++) {
const c = str.charCodeAt(i);
h1 = Math.imul(h1 ^ (c & 0xff), 0x01000193);
h2 = Math.imul(h2 ^ (c >>> 8), 0x01000193);
}
return ((h1 >>> 0).toString(16).padStart(8, '0')) + ((h2 >>> 0).toString(16).padStart(8, '0'));
}
/* ============ 8. 一键构建 ============ */
export function build(files, entry, opts = {}) {
const fs = createFS(files);
const graph = buildGraph(fs, entry, { cache: opts.cache });
const shake = treeShake(graph);
const linked = link(graph, shake);
// 把入口导出挂到调用方给的变量上,方便测试里直接取结果做差分对照。
// 注意要加在"包含入口模块的那个 chunk"尾部,加在 linked.code 上会被分块丢掉。
const exposeTail = opts.expose
? (() => {
const fields = allExportNames(graph, graph.entryId).map((n) => {
const r = resolveExport(graph, graph.entryId, n);
return r ? `${JSON.stringify(n)}: ${g(r.modId, r.local)}` : null;
}).filter(Boolean);
return `\n;${opts.expose} = { ${fields.join(', ')} };`;
})()
: '';
const strategy = opts.strategy ?? 'single';
const chunks = planChunks(graph, shake, strategy);
const emitted = chunks.map((c) => {
let code = chunkCode(linked, c);
if (exposeTail && c.modIds.includes(graph.entryId)) code += exposeTail;
return { name: c.name, modIds: c.modIds, code, bytes: code.length, hash: contentHash(code) };
});
return { graph, shake, linked, chunks: emitted, unresolved: graph.unresolved };
}
export { norm, dirname, uid };
scan.mjs —— 静态分析(依赖扫描、tree-shaking 可达性分析)
// scan.mjs —— 零依赖 ESM 词法器 + 模块语法扫描器
// 目标:不做完整 AST,但把"决定打包结果"的那部分语法精确切出来
// import/export 语句、顶层绑定、动态 import、顶层副作用
/* ============ 1. 词法器 ============ */
const PUNC3 = ['...', '===', '!==', '**=', '<<=', '>>=', '>>>', '&&=', '||=', '??='];
const PUNC2 = [
'=>', '==', '!=', '<=', '>=', '&&', '||', '??', '?.', '++', '--',
'+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=', '**', '<<', '>>',
];
// 这些关键字后面出现的 / 一定是正则,不是除法
const REGEX_PREV_KW = new Set([
'return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void',
'throw', 'case', 'do', 'else', 'yield', 'await',
]);
// 这些关键字后面的 ( ... ) 是"控制条件括号",其 ) 后面允许正则
const CONTROL_KW = new Set(['if', 'while', 'for', 'with', 'switch', 'catch']);
const isIdStart = (c) => /[A-Za-z_$#]/.test(c) || c > '\x7f';
const isIdPart = (c) => /[A-Za-z0-9_$]/.test(c) || c > '\x7f';
const isDigit = (c) => c >= '0' && c <= '9';
/**
* 把源码切成 token。
* token: { t, v, s, e }
* t: 'id' | 'num' | 'str' | 'tpl' | 're' | 'punc'
* tpl 额外带 parts: [{ s, e } | { expr: token[] }]
* 全局维护 parenStack,用来判断 ) 之后能不能是正则。
*/
export function tokenize(src) {
const toks = [];
const n = src.length;
let i = 0;
// 括号栈:记录每个 ( 是否属于控制语句
const parens = [];
// 记录上一个"有效"token,用于正则歧义消解
let prev = null;
const push = (tok) => {
toks.push(tok);
prev = tok;
return tok;
};
// 只读前瞻:找前一个非空白字符
const lastSignificant = () => prev;
const regexAllowed = () => {
const p = lastSignificant();
if (!p) return true; // 文件开头
if (p.t === 'num' || p.t === 'str' || p.t === 'tpl' || p.t === 're') return false;
if (p.t === 'id') return REGEX_PREV_KW.has(p.v);
if (p.t === 'punc') {
if (p.v === ')') return p.ctrl === true; // if(...)/for(...) 的 ) → 允许
if (p.v === ']') return false;
if (p.v === '}') return true; // 块结束,保守放行
if (p.v === '++' || p.v === '--') return false;
return true;
}
return true;
};
// 扫描模板字符串,递归扫描 ${ } 内部表达式
function scanTemplate(start) {
// start 指向反引号
let j = start + 1;
const parts = [];
let segStart = j;
while (j < n) {
const c = src[j];
if (c === '\\') { j += 2; continue; }
if (c === '`') {
parts.push({ s: segStart, e: j });
return { end: j + 1, parts };
}
if (c === '$' && src[j + 1] === '{') {
parts.push({ s: segStart, e: j });
// 递归词法化表达式部分
const exprStart = j + 2;
let depth = 1, k = exprStart;
while (k < n && depth > 0) {
const ch = src[k];
if (ch === '\\') { k += 2; continue; }
if (ch === '{') depth++;
else if (ch === '}') depth--;
if (depth === 0) break;
// 跳过字符串,避免字符串里的 } 影响配平
if (ch === '"' || ch === "'" || ch === '`') {
k = skipStringLike(k);
continue;
}
k++;
}
const inner = src.slice(exprStart, k);
// 记录绝对区间,改名时要在插值里递归处理(词法改名最容易漏的地方)
parts.push({ expr: tokenize(inner), es: exprStart, ee: k });
j = k + 1;
segStart = j;
continue;
}
j++;
}
throw new SyntaxError('未闭合的模板字符串');
}
// 返回结束后的下标(指向闭合引号之后)
function skipStringLike(k) {
const q = src[k];
if (q === '`') {
// 粗略跳过模板
let m = k + 1;
while (m < n) {
if (src[m] === '\\') { m += 2; continue; }
if (src[m] === '`') return m + 1;
m++;
}
return m;
}
let m = k + 1;
while (m < n) {
const ch = src[m];
if (ch === '\\') { m += 2; continue; }
if (ch === q) return m + 1;
if (ch === '\n') return m; // 未闭合,止损
m++;
}
return m;
}
while (i < n) {
const c = src[i];
// 空白
if (c === ' ' || c === '\t' || c === '\r' || c === '\n' || c === '\uFEFF') { i++; continue; }
// 注释
if (c === '/' && src[i + 1] === '/') {
while (i < n && src[i] !== '\n') i++;
continue;
}
if (c === '/' && src[i + 1] === '*') {
const end = src.indexOf('*/', i + 2);
i = end === -1 ? n : end + 2;
continue;
}
// 模板字符串
if (c === '`') {
const { end, parts } = scanTemplate(i);
push({ t: 'tpl', v: src.slice(i, end), s: i, e: end, parts });
i = end;
continue;
}
// 普通字符串
if (c === '"' || c === "'") {
let j = i + 1;
while (j < n) {
const ch = src[j];
if (ch === '\\') { j += 2; continue; }
if (ch === c) { j++; break; }
j++;
}
push({ t: 'str', v: src.slice(i, j), s: i, e: j });
i = j;
continue;
}
// 数字
if (isDigit(c) || (c === '.' && isDigit(src[i + 1]))) {
let j = i;
if (c === '0' && /[xXbBoO]/.test(src[i + 1] || '')) {
j = i + 2;
while (j < n && /[0-9a-fA-F_]/.test(src[j])) j++;
} else {
while (j < n && /[0-9_]/.test(src[j])) j++;
if (src[j] === '.') { j++; while (j < n && /[0-9_]/.test(src[j])) j++; }
if (/[eE]/.test(src[j] || '')) {
const save = j; j++;
if (/[+-]/.test(src[j] || '')) j++;
if (isDigit(src[j] || '')) { while (j < n && /[0-9_]/.test(src[j])) j++; }
else j = save;
}
}
if (src[j] === 'n') j++;
push({ t: 'num', v: src.slice(i, j), s: i, e: j });
i = j;
continue;
}
// 标识符 / 关键字
if (isIdStart(c)) {
let j = i + 1;
while (j < n && isIdPart(src[j])) j++;
push({ t: 'id', v: src.slice(i, j), s: i, e: j });
i = j;
continue;
}
// 正则字面量(歧义消解后)
if (c === '/' && regexAllowed()) {
let j = i + 1;
let inClass = false;
while (j < n) {
const ch = src[j];
if (ch === '\\') { j += 2; continue; }
if (ch === '[') inClass = true;
else if (ch === ']') inClass = false;
else if (ch === '/' && !inClass) { j++; break; }
else if (ch === '\n') break;
j++;
}
while (j < n && /[a-z]/.test(src[j])) j++; // flags
push({ t: 're', v: src.slice(i, j), s: i, e: j });
i = j;
continue;
}
// 标点(最长匹配)
let matched = null;
for (const p of PUNC3) if (src.startsWith(p, i)) { matched = p; break; }
if (!matched) for (const p of PUNC2) if (src.startsWith(p, i)) { matched = p; break; }
if (!matched) matched = c;
if (matched === '(') {
const p = lastSignificant();
parens.push(p && p.t === 'id' && CONTROL_KW.has(p.v));
push({ t: 'punc', v: '(', s: i, e: i + 1, ctrl: parens[parens.length - 1] });
} else if (matched === ')') {
const ctrl = parens.length ? parens.pop() : false;
push({ t: 'punc', v: ')', s: i, e: i + 1, ctrl });
} else {
push({ t: 'punc', v: matched, s: i, e: i + matched.length });
}
i += matched.length;
}
return toks;
}
/* ============ 2. 模块语法扫描 ============ */
/**
* 从 token 流里抽出模块级事实。
* 只认顶层(花括号深度 0)的 import/export/声明。
*/
export function scanModule(src) {
const toks = tokenize(src);
const imports = []; // { source, specifiers:[{imported:'default'|'*'|name, local}], sideEffectOnly }
const exports = []; // { exported, local?|expression?|source?|imported? }
const decls = []; // 顶层声明的裸名(用于建立本模块绑定表)
const dynamicImports = [];
const topLevelStmts = []; // 顶层语句首 token 分类,用于判断副作用
const statements = []; // { kind, start, end, names } 字符区间,打包时用来整段删除
let depth = 0;
let i = 0;
const at = (k) => toks[k];
const v = (k) => (toks[k] ? toks[k].v : undefined);
// 动态 import 可能在任意嵌套里,且可能出现在被整段跳过的语句中,
// 所以单独做一次全量预扫(廉价,只找 import 后跟括号且非 .meta)。
for (let k = 0; k < toks.length; k++) {
const tk = toks[k];
if (tk.t !== 'id' || tk.v !== 'import') continue;
const nx = toks[k + 1];
if (!nx || nx.t !== 'punc' || nx.v !== '(') continue;
const arg = toks[k + 2];
if (arg && arg.t === 'str') dynamicImports.push(unquote(arg.v));
else dynamicImports.push(null); // 非字面量:真实构建里无法静态分析,只能单独成块
}
const pushStmt = (kind, sIdx, eIdx, names = []) => {
const a = toks[sIdx], b = toks[eIdx];
if (!a || !b) return;
statements.push({ kind, start: a.s, end: b.e, names });
};
// 从 k 开始找语句结束。
// 关键坑:`export function f(){}` 没有分号,若只找 ';' 会一路吞掉后面整段代码。
// 所以要记录"语句内是否开启过顶层花括号",一旦花括号回落到 0 就视为结束。
// groupAt:import/export 的 { ... } 说明符列表不是语句块,必须排除,
// 否则 `import {a} from 'x'` 会被误判为在 '}' 处结束(真实踩过的坑)。
const stmtEnd = (k, groupAt = -1) => {
let d = 0;
let openedBlock = false;
for (let m = k; m < toks.length; m++) {
const t = toks[m];
if (t.t !== 'punc') continue;
if (t.v === '(' || t.v === '[') d++;
else if (t.v === ')' || t.v === ']') { d--; if (d < 0) return m; }
else if (t.v === '{') { if (m !== groupAt && d === 0) openedBlock = true; d++; }
else if (t.v === '}') {
d--;
if (d < 0) return m;
if (d === 0 && openedBlock) return m;
} else if (t.v === ';' && d === 0) return m;
}
return toks.length - 1;
};
// 解析 { a, b as c } → [[imported, local], ...]
const parseSpecList = (k) => {
// k 指向 {
const out = [];
let m = k + 1;
while (m < toks.length && v(m) !== '}') {
if (at(m).t === 'id') {
const imported = at(m).v;
let local = imported;
if (v(m + 1) === 'as' && at(m + 2)) { local = at(m + 2).v; m += 2; }
out.push({ imported, local });
}
m++;
}
return { list: out, next: m + 1 };
};
while (i < toks.length) {
const t = toks[i];
if (t.t === 'punc') {
if ('({['.includes(t.v)) depth++;
else if (')}]'.includes(t.v)) depth--;
i++;
continue;
}
// 动态 import(...) 任意深度都要抓
if (t.t === 'id' && t.v === 'import' && v(i + 1) === '(') {
const arg = at(i + 2);
// import.meta 不是动态导入
if (v(i + 1) === '.' && v(i + 2) === 'meta') { i += 3; continue; }
if (arg && arg.t === 'str') dynamicImports.push(unquote(arg.v));
i += 2;
continue;
}
if (depth !== 0) { i++; continue; }
// ---- 顶层 import ----
if (t.t === 'id' && t.v === 'import' && v(i + 1) !== '.' && v(i + 1) !== '(') {
const start = i;
let m = i + 1;
const specifiers = [];
let braceAt = -1;
// import 'x'
if (at(m) && at(m).t === 'str') {
imports.push({ source: unquote(at(m).v), specifiers: [], sideEffectOnly: true });
pushStmt('import', start, stmtEnd(start));
i = stmtEnd(start) + 1;
continue;
}
// 默认导入
if (at(m) && at(m).t === 'id' && at(m).v !== 'from') {
specifiers.push({ imported: 'default', local: at(m).v });
m++;
if (v(m) === ',') m++;
} else if (at(m) && at(m).t === 'id' && at(m).v === 'from') {
// 不该发生
}
// 命名空间或具名
if (v(m) === '*') {
const asName = v(m + 2);
specifiers.push({ imported: '*', local: asName });
m += 3;
} else if (v(m) === '{') {
braceAt = m;
const { list, next } = parseSpecList(m);
specifiers.push(...list.map((x) => ({ imported: x.imported, local: x.local })));
m = next;
}
const end = stmtEnd(start, braceAt);
// from 'x'
while (m <= end && v(m) !== 'from') m++;
const srcTok = at(m + 1);
if (srcTok && srcTok.t === 'str') {
imports.push({ source: unquote(srcTok.v), specifiers, sideEffectOnly: false });
}
pushStmt('import', start, end);
i = end + 1;
continue;
}
// ---- 顶层 export ----
if (t.t === 'id' && t.v === 'export') {
let m = i + 1;
// export default
if (v(m) === 'default') {
let defaultLocal = null;
const end = stmtEnd(i);
// export default function foo(){} / class Foo{}
const nx = at(m + 1);
if (nx && nx.t === 'id' && (nx.v === 'function' || nx.v === 'class')) {
let p = m + 2;
if (v(p) === '*') p++;
if (at(p) && at(p).t === 'id') {
decls.push(at(p).v);
exports.push({ exported: 'default', local: at(p).v });
defaultLocal = at(p).v;
} else {
// 匿名默认导出:合成一个本地名,链接期才好引用
decls.push('__default');
exports.push({ exported: 'default', local: '__default', anonymous: true });
defaultLocal = '__default';
}
} else if (nx && nx.t === 'id' && nx.v === 'async') {
let p = m + 3;
if (at(p) && at(p).t === 'id') {
decls.push(at(p).v);
exports.push({ exported: 'default', local: at(p).v });
defaultLocal = at(p).v;
} else { decls.push('__default'); exports.push({ exported: 'default', local: '__default', anonymous: true }); }
} else {
exports.push({ exported: 'default', local: '__default', expression: toks.slice(m + 1, end).map((x) => x.v).join(' ') });
decls.push('__default');
defaultLocal = '__default';
}
pushStmt('export-default', i, end, [defaultLocal ?? '__default']);
i = end + 1;
continue;
}
// export * from 'x' / export * as ns from 'x'
if (v(m) === '*') {
if (v(m + 1) === 'as') {
const ns = v(m + 2);
const end = stmtEnd(i);
let p = m + 3;
while (p <= end && v(p) !== 'from') p++;
const s = at(p + 1);
if (s && s.t === 'str') exports.push({ exported: ns, source: unquote(s.v), imported: '*' });
pushStmt('export-star', i, end, [ns]);
i = end + 1;
continue;
}
const end = stmtEnd(i);
const s = at(m + 2);
if (s && s.t === 'str') exports.push({ exported: '*', source: unquote(s.v), imported: '*' });
pushStmt('export-star', i, end, ['*']);
i = end + 1;
continue;
}
// export { ... } [from 'x']
if (v(m) === '{') {
const braceAt = m;
const { list, next } = parseSpecList(m);
const end = stmtEnd(i, braceAt);
let p = next;
while (p <= end && v(p) !== 'from') p++;
if (v(p) === 'from') {
const s = at(p + 1);
const source = s && s.t === 'str' ? unquote(s.v) : null;
for (const x of list) exports.push({ exported: x.local, imported: x.imported, source });
} else {
for (const x of list) exports.push({ exported: x.local, local: x.imported });
}
pushStmt('export-list', i, end, list.map((x) => x.imported));
i = end + 1;
continue;
}
// export const/let/var/function/class/async function
const kw = v(m);
if (kw === 'const' || kw === 'let' || kw === 'var') {
// 只取第一个/最简单的标识符;解构声明交给 AST 才能准确,这里记录能力边界
let p = m + 1;
const names = [];
let d = 0;
const end = stmtEnd(i);
while (p <= end) {
const tk = at(p);
if (tk.t === 'punc') {
if ('([{'.includes(tk.v)) d++;
else if (')]}'.includes(tk.v)) d--;
} else if (tk.t === 'id' && d === 0) {
// declarator 起始位置的标识符
const prevV = v(p - 1);
if (p === m + 1 || prevV === ',') names.push(tk.v);
}
p++;
}
for (const nm of names) {
decls.push(nm);
exports.push({ exported: nm, local: nm });
}
pushStmt('export-decl', i, end, names);
i = end + 1;
continue;
}
if (kw === 'function' || kw === 'class') {
let p = m + 1;
if (v(p) === '*') p++;
if (at(p) && at(p).t === 'id') {
decls.push(at(p).v);
exports.push({ exported: at(p).v, local: at(p).v });
}
const end = stmtEnd(i);
pushStmt('export-decl', i, end, at(p) && at(p).t === 'id' ? [at(p).v] : []);
i = end + 1;
continue;
}
if (kw === 'async') {
let p = m + 3;
if (at(p) && at(p).t === 'id') {
decls.push(at(p).v);
exports.push({ exported: at(p).v, local: at(p).v });
}
const end = stmtEnd(i);
i = end + 1;
continue;
}
i = m;
continue;
}
// ---- 顶层声明(非 export) ----
if (t.t === 'id' && (t.v === 'function' || t.v === 'class')) {
let p = i + 1;
if (v(p) === '*') p++;
if (at(p) && at(p).t === 'id') decls.push(at(p).v);
i++;
continue;
}
if (t.t === 'id' && t.v === 'async' && v(i + 1) === 'function') {
let p = i + 2;
if (v(p) === '*') p++;
if (at(p) && at(p).t === 'id') decls.push(at(p).v);
i += 2;
continue;
}
if (t.t === 'id' && (t.v === 'const' || t.v === 'let' || t.v === 'var')) {
const end = stmtEnd(i);
let p = i + 1;
let d = 0;
while (p <= end) {
const tk = at(p);
if (tk.t === 'punc') {
if ('([{'.includes(tk.v)) d++;
else if (')]}'.includes(tk.v)) d--;
} else if (tk.t === 'id' && d === 0) {
const prevV = v(p - 1);
if (p === i + 1 || prevV === ',') decls.push(tk.v);
}
p++;
}
i = end + 1;
continue;
}
// 顶层其他语句 → 副作用标记 + 登记为 statement(必须 emit,否则副作用丢失)
topLevelStmts.push(t ? t.v : '');
{
const end = stmtEnd(i);
pushStmt('statement', i, end);
i = end + 1;
}
}
// 本模块内被引用到的标识符(不含声明位),tree-shaking 判存活要用。
// 两个坑:
// 1) 模板字符串的 ${...} 是嵌套 token 数组,扁平遍历会漏掉里面的引用;
// 2) 声明位(const x / function f / 解构绑定)不是引用。
// 这里刻意做得"保守"(多算引用 = 少删代码 = 不会删错)。
const referenced = new Set();
{
const flatten = (list, out = []) => {
for (const tk of list) {
if (tk.t === 'tpl' && tk.parts) {
for (const pt of tk.parts) if (pt.expr) flatten(pt.expr, out);
continue;
}
out.push(tk);
}
return out;
};
const flat = flatten(toks);
const DECL_KW = new Set(['const', 'let', 'var']);
let inDeclarator = false;
for (let k = 0; k < flat.length; k++) {
const t = flat[k];
if (t.t === 'punc' && t.v === ';') { inDeclarator = false; continue; }
if (t.t !== 'id') continue;
const prevV = flat[k - 1] ? flat[k - 1].v : undefined;
const nextV = flat[k + 1] ? flat[k + 1].v : undefined;
if (DECL_KW.has(t.v)) { inDeclarator = true; continue; }
if (prevV === '.' || prevV === '?.') continue; // 属性访问不是引用
if (nextV === ':' && (prevV === '{' || prevV === ',')) continue; // 对象字面量的键
const isDeclName = prevV === 'function' || prevV === 'class' ||
prevV === 'async' || prevV === '*' ||
(inDeclarator && (prevV === ',' || DECL_KW.has(prevV ?? '') || flat[k - 1]?.t === 'id'));
if (isDeclName) continue;
referenced.add(t.v);
}
}
return { toks, imports, exports, decls, dynamicImports, topLevelStmts, statements, referenced };
}
function unquote(s) {
// 只处理无转义的常见情况,其余交给 JSON/原生解析
if (s[0] === '"' || s[0] === "'") {
const body = s.slice(1, -1);
if (!body.includes('\\')) return body;
return body.replace(/\\(.)/g, (_, c) => ({ n: '\n', t: '\t', r: '\r' }[c] ?? c));
}
return s;
}
/* ============ 3. 顶层副作用判定 ============ */
const BENIGN_TOP = new Set([';', 'const', 'let', 'var', 'function', 'class', 'async', 'import', 'export']);
/**
* 模块是否有顶层副作用(用于 tree-shaking 是否可整块丢弃)。
* 真实世界里这等价于 package.json 的 sideEffects 字段,但可以由源码推导。
*/
export function hasSideEffects(scan) {
for (const s of scan.topLevelStmts) {
if (!BENIGN_TOP.has(s)) return true;
}
return false;
}
fixtures.mjs —— 测试夹具(多个模块图场景)
// fixtures.mjs —— 夹具生成器:确定性合成项目 + 落盘真实 ESM(用于和原生 import 做差分对照)
export function mulberry32(seed) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* 合成项目:
* - N 个模块,每个导出 live_<i> / dead_<i>
* - 入口只引用其中一部分 → 其余应该被 tree-shaking 掉
* - 依赖指向比自身编号小的模块(保证无环,可选制造环)
*/
export function genProject({ modules = 100, liveRatio = 0.3, withCycles = false, seed = 7 } = {}) {
const rnd = mulberry32(seed);
const files = {};
const liveCount = Math.max(1, Math.round(modules * liveRatio));
for (let i = 0; i < modules; i++) {
const deps = [];
// 每个模块引用 1~3 个更小的模块
const fanout = 1 + Math.floor(rnd() * 3);
for (let k = 0; k < fanout && i > 0; k++) {
const target = Math.floor(rnd() * i);
if (!deps.includes(target)) deps.push(target);
}
const lines = [];
for (const d of deps) lines.push(`import { live_${d} } from './m${d}.mjs';`);
lines.push(`export function live_${i}(x){`);
if (deps.length) {
lines.push(` let acc = x;`);
for (const d of deps) lines.push(` acc = acc + live_${d}(x | ${d});`);
lines.push(` return acc + ${i};`);
} else {
lines.push(` return x + ${i};`);
}
lines.push(`}`);
// 死代码:一个从不被引用的导出,且体积不小
lines.push(`export function dead_${i}(x){`);
lines.push(` // 这一段在打包后应该消失`);
lines.push(` const table = [${Array.from({ length: 8 }, (_, k) => `${i * 31 + k}`).join(', ')}];`);
lines.push(` return table.map((v) => v * x + ${i}).reduce((a, b) => a + b, 0);`);
lines.push(`}`);
files[`src/m${i}.mjs`] = lines.join('\n');
}
// 入口:只引用前 liveCount 个模块
const entry = [];
const used = [];
for (let i = 0; i < liveCount; i++) {
entry.push(`import { live_${i} } from './m${i}.mjs';`);
used.push(i);
}
entry.push(`export const total = [${used.map((i) => `live_${i}(${i})`).join(', ')}].reduce((a, b) => a + b, 0);`);
files['src/main.mjs'] = entry.join('\n');
if (withCycles) {
// a ↔ b 互引:函数声明可提升,运行期合法
files['src/cyc/a.mjs'] = `import { b } from './b.mjs';\nexport function a(n){ return n <= 0 ? 'a' : 'a' + b(n - 1); }`;
files['src/cyc/b.mjs'] = `import { a } from './a.mjs';\nexport function b(n){ return n <= 0 ? 'b' : 'b' + a(n - 1); }`;
}
return files;
}
/** 小型"真实形状"项目:用来做语义差分对照 */
export function genSemanticProject() {
return {
'src/main.mjs': `
import { add, mul, ZERO } from './math.mjs';
import { label, PREFIX } from './str.mjs';
import * as all from './math.mjs';
import fmt from './fmt.mjs';
import './effect.mjs';
const composed = add(mul(2, 3), ZERO);
export const out1 = composed;
export const out2 = label(composed);
export const out3 = \`\${PREFIX}:\${fmt(composed)}\`;
export const out4 = all.mul(4, 5);
export const out5 = (() => {
// 影子变量:内层 value 遮蔽同名导入
const value = 999;
return value;
})();
export const out6 = add(value0(), ZERO);
function value0(){ return 41; }
`,
'src/math.mjs': `
export const ZERO = 0;
export function add(a, b){ return a + b; }
export function mul(a, b){ return a * b; }
export function dead(){ return 'should be shaken'; }
`,
'src/str.mjs': `
import { add } from './math.mjs';
export const PREFIX = 'v';
export function label(n){
const { a, b } = { a: n, b: add(n, 1) };
return \`\${PREFIX}-\${a}-\${b}\`;
}
export function unusedLabel(){ return 'nope'; }
`,
'src/fmt.mjs': `
export default function fmt(n){ return '[' + n + ']'; }
`,
'src/effect.mjs': `
export const ignored = 1;
globalThis.__EFFECT_RAN__ = (globalThis.__EFFECT_RAN__ ?? 0) + 1;
`,
};
}
/** 复导出链:export {} from / export * from 穿透 */
export function genReexportProject() {
return {
'src/main.mjs': `
import { add, real, deep } from './barrel.mjs';
export const r1 = add(1, 2);
export const r2 = real;
export const r3 = deep();
`,
'src/barrel.mjs': `
export { add } from './core.mjs';
export * from './extra.mjs';
export { deep } from './depth1.mjs';
`,
'src/core.mjs': `export function add(a, b){ return a + b; }`,
'src/extra.mjs': `export const real = 'REAL'; export const nope = 'NOPE';`,
'src/depth1.mjs': `export * from './depth2.mjs';`,
'src/depth2.mjs': `export function deep(){ return 'deep-value'; }`,
};
}
/** 只含副作用的模块:必须保留其执行顺序 */
export function genSideEffectProject() {
return {
'src/main.mjs': `
import './a.mjs';
import './b.mjs';
import { read } from './store.mjs';
export const order = read();
`,
'src/a.mjs': `import { push } from './store.mjs';\npush('a');`,
'src/b.mjs': `import { push } from './store.mjs';\npush('b');`,
'src/store.mjs': `
const log = [];
export function push(x){ log.push(x); }
export function read(){ return log.join(','); }
`,
};
}
/** 各种默认导出形态 + 动态 import */
export function genDefaultFormsProject() {
return {
'src/main.mjs': `
import expr, { cls, named, viaList } from './forms.mjs';
export const d1 = expr;
export const d2 = new cls(3).get();
export const d3 = named();
export const d4 = viaList;
`,
'src/forms.mjs': `
const base = { cls: class C { constructor(n){ this.n = n; } get(){ return this.n * 2; } } };
export default 6 * 7;
export const cls = base.cls;
export function named(){ return 'named-value'; }
function hidden(){ return 'via-list'; }
export { hidden as viaList };
`,
};
}
/** 落盘成真实文件树,供原生 import 使用 */
export async function materialize(files, fsMod, pathMod, osMod) {
const dir = await fsMod.promises.mkdtemp(pathMod.join(osMod.tmpdir(), 'day35-'));
for (const [p, content] of Object.entries(files)) {
const full = pathMod.join(dir, p);
await fsMod.promises.mkdir(pathMod.dirname(full), { recursive: true });
await fsMod.promises.writeFile(full, content, 'utf8');
}
return dir;
}
/** 把虚拟 FS 的写盘顺序打乱(模拟 readdir 顺序不可控) */
export function shuffleEntries(files, seed = 1) {
const rnd = mulberry32(seed);
const arr = Object.entries(files);
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(rnd() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return Object.fromEntries(arr);
}
test-bundler.mjs —— 构建器断言(产物语义等价、tree-shaking 正确性)
// test-bundler.mjs —— 断言 + 与原生 ESM 的差分对照
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { pathToFileURL } from 'node:url';
import { tokenize, scanModule, hasSideEffects } from './scan.mjs';
import {
build, buildGraph, treeShake, planChunks, contentHash, createFS, resolveSpec, findCycles, topoOrder,
} from './build.mjs';
import {
genProject, genSemanticProject, genReexportProject, genSideEffectProject,
genDefaultFormsProject, materialize, shuffleEntries,
} from './fixtures.mjs';
let pass = 0, fail = 0;
const t = (name, fn) => {
try { fn(); pass++; }
catch (e) { fail++; console.error(`✗ ${name}\n ${e.message}`); }
};
const ta = async (name, fn) => {
try { await fn(); pass++; }
catch (e) { fail++; console.error(`✗ ${name}\n ${e.message}`); }
};
/* ---------- §1 词法器 ---------- */
t('正则 vs 除法:if 条件括号后的 / 是正则', () => {
const tk = tokenize('if (a) /x/.test(y)');
assert.ok(tk.some((x) => x.t === 're' && x.v === '/x/'));
});
t('正则 vs 除法:普通括号后的 / 是除法', () => {
const tk = tokenize('const r = (a + b) / 2 / c');
assert.ok(!tk.some((x) => x.t === 're'));
});
t('正则里的 } 不破坏注释/字符串切分', () => {
const tk = tokenize('function f(){ return /}/.test("}") }');
assert.equal(tk.filter((x) => x.t === 're').length, 1);
});
t('模板嵌套插值被完整收集', () => {
const tk = tokenize('`a${ `b${c}` }d`');
const tpl = tk.find((x) => x.t === 'tpl');
assert.ok(tpl, '未识别为 tpl');
// 外层 1 个插值 + 插值里再嵌套 1 个
const count = (list) => list.reduce((n, tk) => {
if (tk.t !== 'tpl' || !tk.parts) return n;
return n + tk.parts.filter((p) => p.expr).length +
tk.parts.reduce((m, p) => m + (p.expr ? count(p.expr) : 0), 0);
}, 0);
assert.equal(count(tk), 2);
});
t('bigint / 数字分隔符', () => {
assert.equal(tokenize('1_000_000n')[0].v, '1_000_000n');
});
t('除号后面跟 / 注释不误判成正则', () => {
const tk = tokenize('const a = b / c; // 注释\n');
assert.equal(tk.filter((x) => x.t === 're').length, 0);
});
/* ---------- §2 模块语法扫描 ---------- */
t('import 六种形态', () => {
const m = scanModule(`
import d from './a.mjs';
import * as ns from './b.mjs';
import { x, y as z } from './c.mjs';
import d2, { p } from './d.mjs';
import d3, * as ns2 from './e.mjs';
import './f.mjs';`);
assert.equal(m.imports.length, 6);
assert.deepEqual(m.imports[0].specifiers, [{ imported: 'default', local: 'd' }]);
assert.deepEqual(m.imports[1].specifiers, [{ imported: '*', local: 'ns' }]);
assert.equal(m.imports[2].specifiers.length, 2);
assert.equal(m.imports[3].specifiers.length, 2);
assert.equal(m.imports[4].specifiers.length, 2);
assert.equal(m.imports[5].sideEffectOnly, true);
});
t('export 七种形态', () => {
const m = scanModule(`
export const a = 1, b = 2;
export function f(){}
export class C {}
export default function g(){}
export { x as y };
export * from './r.mjs';
export * as ns from './s.mjs';`);
const names = m.exports.map((e) => e.exported);
assert.ok(names.includes('a') && names.includes('b'));
assert.ok(names.includes('f') && names.includes('C'));
assert.ok(names.includes('default') && names.includes('y'));
assert.ok(names.includes('*') && names.includes('ns'));
});
t('无分号的 export function 不会吞掉后续语句', () => {
const m = scanModule("export function f(){}\nexport const K = 1;");
assert.ok(m.exports.some((e) => e.exported === 'K'), 'K 丢失 → stmtEnd 把 {} 当成了语句块');
});
t('import {a} from 的 {} 不被当成语句块', () => {
const m = scanModule("import { a } from './x.mjs';\nexport const K = 1;");
assert.equal(m.topLevelStmts.length, 0, `topLevelStmts=${JSON.stringify(m.topLevelStmts)}`);
});
t('动态 import 任意深度都能抓到', () => {
const m = scanModule(`function f(){ return import('./lazy.mjs'); }`);
assert.deepEqual(m.dynamicImports, ['./lazy.mjs']);
});
t('import.meta 不算动态导入', () => {
const m = scanModule(`const u = import.meta.url;`);
assert.equal(m.dynamicImports.length, 0);
});
t('副作用判定:赋值语句算副作用', () => {
assert.equal(hasSideEffects(scanModule('globalThis.x = 1;')), true);
assert.equal(hasSideEffects(scanModule('export const x = 1;')), false);
});
/* ---------- §3 解析器 ---------- */
t('扩展名探测 + 目录 index', () => {
const fsm = createFS({ 'a/b.mjs': '', 'a/c/index.mjs': '', 'a/d.js': '' });
assert.equal(resolveSpec(fsm, 'a/main.mjs', './b'), 'a/b.mjs');
assert.equal(resolveSpec(fsm, 'a/main.mjs', './c'), 'a/c/index.mjs');
assert.equal(resolveSpec(fsm, 'a/main.mjs', './d'), 'a/d.js');
assert.equal(resolveSpec(fsm, 'a/main.mjs', './nope'), null);
});
t('.. 路径归一化', () => {
const fsm = createFS({ 'src/x.mjs': '' });
assert.equal(resolveSpec(fsm, 'src/deep/main.mjs', '../x.mjs'), 'src/x.mjs');
});
/* ---------- §4 环检测与执行顺序 ---------- */
t('Tarjan 找到 a↔b 环', () => {
const files = genProject({ modules: 5, withCycles: true });
files['src/main.mjs'] += `\nimport { a } from './cyc/a.mjs';\nexport const cyc = a(3);`;
const g = buildGraph(createFS(files), 'src/main.mjs');
assert.equal(g.cycles.length, 1);
assert.equal(g.cycles[0].length, 2);
});
t('无环项目:依赖排在依赖者前面', () => {
const g = buildGraph(createFS(genProject({ modules: 8 })), 'src/main.mjs');
const pos = new Map(g.order.map((id, i) => [id, i]));
for (const m of g.modules) {
for (const d of m.deps) {
assert.ok(pos.get(d.targetId) < pos.get(m.id), `${m.path} 应在 ${g.modules[d.targetId].path} 之后`);
}
}
});
t('自环也能被识别', () => {
const g = buildGraph(createFS({ 'a.mjs': `import { f } from './a.mjs'; export function f(){}` }), 'a.mjs');
assert.equal(g.cycles.length, 1);
});
/* ---------- §5 tree-shaking ---------- */
t('未引用的导出被删除', () => {
const r = build(genProject({ modules: 30, liveRatio: 0.2 }), 'src/main.mjs');
const code = r.chunks[0].code;
assert.ok(!/dead_0\b/.test(code), 'dead_0 应被删除');
assert.ok(/live_0/.test(code), 'live_0 应保留');
});
t('体积确实下降(>=30%)', () => {
const r = build(genProject({ modules: 60, liveRatio: 0.25 }), 'src/main.mjs');
const drop = 1 - r.shake.stats.bytesKept / r.shake.stats.bytesTotal;
assert.ok(drop > 0.3, `降幅仅 ${(drop * 100).toFixed(1)}%`);
});
t('副作用模块不能被摇掉', () => {
const r = build(genSideEffectProject(), 'src/main.mjs');
assert.ok(r.chunks[0].code.includes(`push('a')`), '副作用语句被删了');
});
/* ---------- §6 差分对照:原生 ESM vs 打包产物 ---------- */
async function differential(name, files, opts = {}) {
const dir = await materialize(files, fs, path, os);
const native = await import(pathToFileURL(path.join(dir, 'src/main.mjs')).href + `?v=${Math.random()}`);
const r = build(files, 'src/main.mjs', { strategy: 'single', expose: '__ex' });
const bundled = new Function('__ex', r.chunks[0].code + '\nreturn __ex;')({});
const keysNative = Object.keys(native).filter((k) => k !== 'default');
const keysBundle = Object.keys(bundled);
for (const k of keysNative) {
const nv = native[k], bv = bundled[k];
// 函数按引用比较无意义(两个 realm 里是两个对象),改比"零参调用的结果"
if (typeof nv === 'function') {
assert.equal(typeof bv, 'function', `导出 ${k} 应为函数`);
let nr, br, nErr = null, bErr = null;
try { nr = String(nv()); } catch (e) { nErr = e.constructor.name; }
try { br = String(bv()); } catch (e) { bErr = e.constructor.name; }
assert.equal(bErr, nErr, `导出 ${k} 抛错行为不一致`);
if (!nErr) assert.equal(br, nr, `导出 ${k}() 结果不一致: 打包=${br} 原生=${nr}`);
continue;
}
assert.deepEqual(bv, nv, `导出 ${k} 与原生不一致: 打包=${JSON.stringify(bv)} 原生=${JSON.stringify(nv)}`);
}
assert.deepEqual(keysBundle.sort(), keysNative.filter((k) => keysBundle.includes(k)).sort());
await fs.promises.rm(dir, { recursive: true, force: true });
void name; void opts;
return { native, bundled };
}
await ta('差分:常规项目(含影子变量/解构/模板/命名空间/副作用)', async () => {
await differential('semantic', genSemanticProject());
});
await ta('差分:复导出链(export{} from + export * 穿透两层)', async () => {
const { native, bundled } = await differential('reexport', genReexportProject());
assert.equal(bundled.r3, native.r3);
assert.equal(bundled.r3, 'deep-value');
});
await ta('差分:副作用顺序', async () => {
const { native, bundled } = await differential('side', genSideEffectProject());
assert.equal(bundled.order, native.order);
assert.equal(bundled.order, 'a,b');
});
await ta('差分:合成项目 120 模块', async () => {
const files = genProject({ modules: 120, liveRatio: 0.3 });
const dir = await materialize(files, fs, path, os);
const native = await import(pathToFileURL(path.join(dir, 'src/main.mjs')).href + `?v=${Math.random()}`);
const r = build(files, 'src/main.mjs', { strategy: 'single', expose: '__ex' });
const bundled = new Function('__ex', r.chunks[0].code + '\nreturn __ex;')({});
assert.equal(bundled.total, native.total);
await fs.promises.rm(dir, { recursive: true, force: true });
});
await ta('差分:默认导出的四种形态(表达式/具名类/具名函数/列表改名)', async () => {
const { native, bundled } = await differential('defaults', genDefaultFormsProject());
assert.equal(bundled.d1, 42);
assert.equal(bundled.d1, native.d1);
assert.equal(bundled.d2, 6);
assert.equal(bundled.d4(), 'via-list');
assert.equal(bundled.d3, 'named-value');
});
t('动态 import 被单独识别出来', () => {
const files = {
'src/main.mjs': `export const load = () => import('./lazy.mjs');`,
'src/lazy.mjs': `export const x = 1;`,
};
const r = build(files, 'src/main.mjs');
assert.deepEqual(r.graph.modules[0].scan.dynamicImports, ['./lazy.mjs']);
});
/* ---------- §7 确定性 ---------- */
t('打乱文件枚举顺序后产物哈希不变', () => {
const files = genProject({ modules: 40 });
const a = build(files, 'src/main.mjs', { strategy: 'single' });
const b = build(shuffleEntries(files, 99), 'src/main.mjs', { strategy: 'single' });
assert.equal(a.chunks[0].hash, b.chunks[0].hash, '输出不确定 → CI 里缓存会一直失效');
});
t('改一个字节,哈希必变(无碰撞)', () => {
const files = genProject({ modules: 10 });
const a = build(files, 'src/main.mjs');
const files2 = { ...files };
files2['src/m0.mjs'] = files2['src/m0.mjs'].replace('x + 0', 'x + 1');
const b = build(files2, 'src/main.mjs');
assert.notEqual(a.chunks[0].hash, b.chunks[0].hash);
});
/* ---------- §8 分块与缓存失效 ---------- */
t('per-module 策略:改一个模块只失效一个块', () => {
const files = genProject({ modules: 20, liveRatio: 1 });
const before = build(files, 'src/main.mjs', { strategy: 'per-module' });
const files2 = { ...files };
files2['src/m0.mjs'] = files2['src/m0.mjs'].replace('x + 0', 'x + 1000');
const after = build(files2, 'src/main.mjs', { strategy: 'per-module' });
const changed = before.chunks.filter((c, i) => c.hash !== after.chunks[i]?.hash).length;
assert.equal(changed, 1, `应只失效 1 块,实际 ${changed}`);
});
t('single 策略:改任何模块都让整块失效', () => {
const files = genProject({ modules: 20, liveRatio: 1 });
const before = build(files, 'src/main.mjs', { strategy: 'single' });
const files2 = { ...files };
files2['src/m0.mjs'] = files2['src/m0.mjs'].replace('x + 0', 'x + 1000');
const after = build(files2, 'src/main.mjs', { strategy: 'single' });
assert.notEqual(before.chunks[0].hash, after.chunks[0].hash);
});
/* ---------- §9 哈希本身 ---------- */
t('contentHash 已知值 + 长度', () => {
assert.equal(contentHash('').length, 16);
assert.equal(contentHash('abc'), contentHash('abc'));
assert.notEqual(contentHash('abc'), contentHash('abd'));
});
t('contentHash 逐字符敏感(FNV 双字节混合)', () => {
assert.notEqual(contentHash('a\u0100'), contentHash('a\u0001'));
});
console.log(`\n${fail === 0 ? '✅' : '❌'} ${pass} 通过 / ${fail} 失败`);
process.exit(fail === 0 ? 0 : 1);
bench.mjs —— 基准主程序(构建耗时/产物体积对比)
// bench.mjs —— 全部数字来源。纯本机实测(Node 22.23.3 / darwin-arm64)
import crypto from 'node:crypto';
import { tokenize, scanModule } from './scan.mjs';
import {
build, buildGraph, treeShake, link, planChunks, chunkCode,
contentHash, contentHashFast, createFS,
} from './build.mjs';
import { genProject, shuffleEntries, mulberry32 } from './fixtures.mjs';
const out = [];
const log = (s = '') => { out.push(s); console.log(s); };
const hr = () => Number(process.hrtime.bigint()) / 1e6;
function benchMs(fn, { warm = 3, runs = 9 } = {}) {
for (let i = 0; i < warm; i++) fn();
const xs = [];
for (let i = 0; i < runs; i++) { const t0 = hr(); fn(); xs.push(hr() - t0); }
xs.sort((a, b) => a - b);
return xs[Math.floor(xs.length / 2)];
}
const fmtB = (n) => (n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(2)}MB` : n >= 1024 ? `${(n / 1024).toFixed(1)}KB` : `${n}B`);
const pct = (a, b) => (b === 0 ? 'n/a' : `${((a / b) * 100).toFixed(1)}%`);
const mbps = (bytes, ms) => (ms === 0 ? 'inf' : (bytes / 1048576 / (ms / 1000)).toFixed(2));
const sizeOf = (files) => Object.values(files).reduce((a, s) => a + s.length, 0);
log(`# Day35 构建工具 · 实测数据`);
log(`环境: Node ${process.version} / ${process.platform}-${process.arch} / V8 ${process.versions.v8}`);
log('');
/* ================= §1 扫描:真词法器 vs 正则近似 ================= */
log('## §1 扫描吞吐与准确率:真词法器 vs 正则近似');
function regexScan(src) {
const imports = [];
const re = /(?:^|\n)\s*import\s+([^'"`]*?)from\s*['"]([^'"]+)['"]/g;
let m;
while ((m = re.exec(src))) imports.push(m[2]);
const din = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
while ((m = din.exec(src))) imports.push(m[1]);
return { imports };
}
// 伪装样本:假 import 出现在"行首",正则若无状态就会当真
const tricky = [
["字符串里(多行模板)", "const t = `\nimport x from './fake-tpl.mjs'\n`;"],
["块注释里", "/*\nimport y from './fake-block.mjs'\n*/"],
["正则字面量里", "const re = /\nimport z from '\\/fake-re.mjs'\n/;"],
].map(([label, src]) => [label, src]);
let trickyRegex = 0, trickyTok = 0, trickyTotal = 0;
for (const [label, src] of tricky) {
const r = regexScan(src).imports.length;
const t = scanModule(src).imports.length;
trickyRegex += r; trickyTok += t; trickyTotal++;
log(` ${label.padEnd(18)} 正则报 ${r} 条假边,词法器报 ${t} 条`);
}
const realSrc = `import { a } from './a.mjs';\nexport function f(){ return a; }`;
log(` 真实样本 正则 ${regexScan(realSrc).imports.length} 条,词法器 ${scanModule(realSrc).imports.length} 条(都正确)`);
log(` → 正则的失败模式不是"慢",是"把注释/字符串/正则里的文本当真边",且 ${trickyRegex}/${trickyTotal} 个样本全中`);
log('');
const proj500 = genProject({ modules: 500, liveRatio: 0.3 });
const bytes500 = sizeOf(proj500);
const nMod = Object.keys(proj500).length;
const tTok = benchMs(() => { for (const src of Object.values(proj500)) tokenize(src); });
const tScan = benchMs(() => { for (const src of Object.values(proj500)) scanModule(src); });
const tRegex = benchMs(() => { for (const src of Object.values(proj500)) regexScan(src); });
log(` 500 模块 / ${fmtB(bytes500)}:`);
log(` tokenize ${tTok.toFixed(2)}ms ${mbps(bytes500, tTok)} MB/s ${(nMod / (tTok / 1000)).toFixed(0)} 模块/s`);
log(` scanModule ${tScan.toFixed(2)}ms ${mbps(bytes500, tScan)} MB/s ${(nMod / (tScan / 1000)).toFixed(0)} 模块/s`);
log(` 正则近似 ${tRegex.toFixed(2)}ms ${mbps(bytes500, tRegex)} MB/s (快 ${(tScan / tRegex).toFixed(1)}x,代价见上)`);
log('');
/* ---- §1b 词法器真正的热路径:字符分类 ---- */
log('## §1b 词法器的热路径:正则 .test() vs charCode 比较');
{
const N = 2_000_000;
const sample = 'const abcdefg_123 = foo.bar(baz, 42);'.repeat(Math.ceil(N / 39)).slice(0, N);
const isIdPartRe = (c) => /[A-Za-z0-9_$]/.test(c);
const isIdPartCode = (c) => {
const x = c.charCodeAt(0);
return (x >= 48 && x <= 57) || (x >= 65 && x <= 90) || (x >= 97 && x <= 122) || x === 95 || x === 36;
};
const tRe = benchMs(() => { let k = 0; for (let i = 0; i < sample.length; i++) if (isIdPartRe(sample[i])) k++; return k; });
const tCode = benchMs(() => { let k = 0; for (let i = 0; i < sample.length; i++) if (isIdPartCode(sample[i])) k++; return k; });
log(` ${(N / 1e6).toFixed(0)}M 次字符判断:正则 .test() ${tRe.toFixed(1)}ms,charCode 比较 ${tCode.toFixed(1)}ms → 快 ${(tRe / tCode).toFixed(1)}x`);
log(` → 词法器 16 MB/s 的瓶颈就在这里:每个字符都新建一次正则匹配上下文`);
}
log('');
/* ================= §2 模块图规模 ================= */
log('## §2 模块图构建随规模的变化');
const graphRows = [];
for (const n of [200, 1000, 3000]) {
const files = genProject({ modules: n, liveRatio: 0.3 });
const bytes = sizeOf(files);
const ms = benchMs(() => buildGraph(createFS(files), 'src/main.mjs'), { warm: 1, runs: 5 });
const g = buildGraph(createFS(files), 'src/main.mjs');
const edges = g.modules.reduce((a, m) => a + m.deps.length, 0);
graphRows.push({ n, ms });
log(` ${String(n).padStart(4)} 模块 / ${fmtB(bytes).padStart(8)}:建图 ${ms.toFixed(1)}ms,边 ${edges},环 ${g.cycles.length}`);
}
const per = graphRows.map((r) => r.ms / r.n);
log(` 单模块摊薄 ${per.map((x) => x.toFixed(3)).join(' / ')} ms → ${per[2] < per[0] * 1.8 ? '近似线性(哈希表 + 单次 DFS)' : '超线性'}`);
log('');
/* ================= §3 tree-shaking 收益 ================= */
log('## §3 tree-shaking:删掉多少');
for (const ratio of [0.1, 0.3, 0.6, 1.0]) {
const files = genProject({ modules: 300, liveRatio: ratio });
const r = build(files, 'src/main.mjs', { strategy: 'single' });
const naive = sizeOf(files);
const reach = r.graph.modules.length;
const present = Object.keys(files).filter((k) => k.startsWith('src/m')).length;
log(` liveRatio=${ratio.toFixed(1)} 磁盘源码 ${fmtB(naive).padStart(8)} → 产物 ${fmtB(r.chunks[0].code.length).padStart(7)} 降幅 ${(100 - (r.chunks[0].code.length / naive) * 100).toFixed(1)}% (可达 ${reach}/${present} 模块,未进图 ${present - reach})`);
}
log('');
/* ================= §4 分块策略与缓存失效爆炸半径 ================= */
log('## §4 改一行"活代码",会让多少字节的缓存失效');
const baseFiles = genProject({ modules: 200, liveRatio: 0.7 });
const mutatedFiles = { ...baseFiles };
// 必须改"会输出的活代码":之前误改了 dead_3 里的字面量,产物不变,
// 所有策略都显示 0B 失效 —— 这类"看起来在测其实没测到"的坑最容易被写进文章
mutatedFiles['src/m3.mjs'] = mutatedFiles['src/m3.mjs'].replace('return acc + 3;', 'return acc + 3000;');
if (mutatedFiles['src/m3.mjs'] === baseFiles['src/m3.mjs']) throw new Error('变异没生效');
for (const strategy of ['single', 'vendor', 'shared', 'per-module']) {
const a = build(baseFiles, 'src/main.mjs', { strategy });
const b = build(mutatedFiles, 'src/main.mjs', { strategy });
const total = a.chunks.reduce((x, c) => x + c.bytes, 0);
const byName = new Map(b.chunks.map((c) => [c.name, c]));
let invalidated = 0;
for (const c of a.chunks) {
const nc = byName.get(c.name);
if (!nc || nc.hash !== c.hash) invalidated += c.bytes;
}
log(` ${strategy.padEnd(11)} 块数 ${String(a.chunks.length).padStart(3)} 总 ${fmtB(total).padStart(8)} 需重新下载 ${fmtB(invalidated).padStart(8)} = ${pct(invalidated, total)}`);
}
log('');
/* ================= §5 哈希链传播 ================= */
log('## §5 哈希要不要链上依赖?(own-content vs chained)');
{
const a = build(baseFiles, 'src/main.mjs', { strategy: 'per-module' });
const b = build(mutatedFiles, 'src/main.mjs', { strategy: 'per-module' });
let ownBytes = 0;
for (let i = 0; i < a.chunks.length; i++) if (a.chunks[i].hash !== b.chunks[i]?.hash) ownBytes += a.chunks[i].bytes;
const gA = buildGraph(createFS(baseFiles), 'src/main.mjs');
const gB = buildGraph(createFS(mutatedFiles), 'src/main.mjs');
const chained = (chunks, mods) => {
const idx = new Map(chunks.map((c) => [Number(c.name.slice(1)), c]));
const memo = new Map();
const h = (id) => {
if (memo.has(id)) return memo.get(id);
const c = idx.get(id);
if (!c) return '';
const deps = [...new Set((mods[id]?.deps ?? []).map((d) => d.targetId).filter((x) => x != null))].sort((x, y) => x - y);
const v = contentHash(c.code + '|' + deps.map(h).join(''));
memo.set(id, v);
return v;
};
return h;
};
const hA = chained(a.chunks, gA.modules);
const hB = chained(b.chunks, gB.modules);
let chainBytes = 0;
const affected = [];
for (const c of a.chunks) {
const id = Number(c.name.slice(1));
if (hA(id) !== hB(id)) { chainBytes += c.bytes; affected.push(id); }
}
const total = a.chunks.reduce((x, c) => x + c.bytes, 0);
log(` 改 1 个叶子 m3 → 只哈希自身:失效 ${fmtB(ownBytes)} (${pct(ownBytes, total)})`);
log(` 链式哈希:失效 ${fmtB(chainBytes)} (${pct(chainBytes, total)}),波及 ${affected.length} 个块`);
log(` 爆炸半径放大 ${ownBytes ? (chainBytes / ownBytes).toFixed(2) : 'n/a'}x —— 链式哈希买的是"绝不拿到旧产物",代价是缓存命中率`);
}
log('');
/* ================= §6 确定性 ================= */
log('## §6 确定性:枚举顺序会不会改产物');
{
const files = genProject({ modules: 200, liveRatio: 0.7 });
const runs = [1, 2, 3, 4, 5].map((seed) => build(shuffleEntries(files, seed), 'src/main.mjs').chunks[0].hash);
log(` 本实现(拓扑序)打乱 5 次:${new Set(runs).size === 1 ? '哈希完全一致 ✅' : '哈希不一致 ❌'} ${runs[0]}`);
// 反例:按"文件枚举顺序"拼(很多手写构建脚本就是这么写的)
const naive = (files2) => {
const fsx = createFS(files2);
const g = buildGraph(fsx, 'src/main.mjs');
const s = treeShake(g);
const l = link(g, s);
// 注意这里用 fsx.keys()(= 调用方给的插入顺序)而不是 g.order
const order = fsx.keys().map((p) => g.byPath.get(p)?.id).filter((id) => id != null && s.included.has(id));
return contentHash(order.map((id) => l.pieces.find((p) => p.modId === id)?.code ?? '').join('\n'));
};
const nruns = [1, 2, 3, 4, 5].map((seed) => naive(shuffleEntries(files, seed)));
log(` 反例(按枚举顺序拼):${new Set(nruns).size} 种不同哈希 → 内容没变但文件名全变,CDN 缓存全废`);
}
log('');
/* ================= §7 词法改名的坑:模板插值 ================= */
log('## §7 词法改名:不递归模板插值会坏掉多少');
{
const rnd = mulberry32(42);
const N = 400;
const files = {};
let withTpl = 0;
for (let i = 0; i < N; i++) {
const useTpl = rnd() < 0.5;
if (useTpl) withTpl++;
files[`src/m${i}.mjs`] = [
`export const name_${i} = 'v${i}';`,
useTpl
? `export function fmt_${i}(){ return \`<\${name_${i}}>\`; }`
: `export function fmt_${i}(){ return '<' + name_${i} + '>'; }`,
].join('\n');
}
const entry = [];
for (let i = 0; i < N; i++) entry.push(`import { fmt_${i} } from './m${i}.mjs';`);
entry.push(`export const all = [${Array.from({ length: N }, (_, i) => `fmt_${i}()`).join(', ')}];`);
files['src/main.mjs'] = entry.join('\n');
const r = build(files, 'src/main.mjs', { expose: '__ex' });
const ok = new Function('__ex', r.chunks[0].code + '\nreturn __ex;')({});
const good = ok.all.filter((s, i) => s === `<v${i}>`).length;
const brokenCode = r.chunks[0].code.replace(/\$\{m\d+\$(name_\d+)\}/g, '${$1}');
let brokenOk = 0, brokenErr = '';
try {
const b = new Function('__ex', brokenCode + '\nreturn __ex;')({});
brokenOk = b.all.filter((s, i) => s === `<v${i}>`).length;
} catch (e) { brokenOk = -1; brokenErr = e.constructor.name; }
log(` ${N} 模块中 ${withTpl} 个用模板插值:正确实现 ${good}/${N} 通过`);
log(` 不递归插值(\${name} 留原名)→ ${brokenOk === -1 ? `整个产物抛 ${brokenErr}(0/${N})` : `${brokenOk}/${N} 通过`}`);
log(` → 一个 40 行函数级别的疏漏,在 ${withTpl} 个模块上同时爆发,且只在运行时暴露`);
}
log('');
/* ================= §8 哈希算法与速度 ================= */
log('## §8 内容哈希:三种实现');
{
const payloads = Array.from({ length: 200 }, (_, i) => 'x'.repeat(1024) + i);
const bytes = payloads.reduce((a, p) => a + p.length, 0);
const tFnvBig = benchMs(() => { for (const p of payloads) contentHash(p); });
const tFnvFast = benchMs(() => { for (const p of payloads) contentHashFast(p); });
const tSha = benchMs(() => { for (const p of payloads) crypto.createHash('sha256').update(p).digest('hex'); });
log(` ${(bytes / 1024).toFixed(0)}KB / 200 块:`);
log(` FNV-1a 64(BigInt 逐字符) ${tFnvBig.toFixed(2)}ms ${mbps(bytes, tFnvBig)} MB/s`);
log(` FNV-1a ×2 32(Math.imul) ${tFnvFast.toFixed(2)}ms ${mbps(bytes, tFnvFast)} MB/s ← 快 ${(tFnvBig / tFnvFast).toFixed(1)}x`);
log(` sha256(原生 OpenSSL) ${tSha.toFixed(2)}ms ${mbps(bytes, tSha)} MB/s ← 比 imul 版再快 ${(tFnvFast / tSha).toFixed(1)}x`);
log(` → 手写 BigInt 哈希比原生 sha256 慢 ${(tFnvBig / tSha).toFixed(1)}x。"自己写的更快"是错觉`);
const base = 'a'.repeat(20000);
const variants = Array.from({ length: 2000 }, (_, i) => base + String.fromCharCode(97 + (i % 26)) + i);
log(` 2000 个只差末尾的变体:BigInt 版 ${new Set(variants.map(contentHash)).size} 个不同哈希,imul 版 ${new Set(variants.map(contentHashFast)).size} 个`);
}
log('');
/* ================= §9 全链路耗时拆分 ================= */
log('## §9 全链路耗时拆分(3000 模块)');
{
const files = genProject({ modules: 3000, liveRatio: 0.4 });
const bytes = sizeOf(files);
// 3000 模块单次 ~170ms,必须给足 warm 与 runs,否则中位数会被 GC 抖动污染
const OPT = { warm: 3, runs: 15 };
const reachable = (() => {
const g0 = buildGraph(createFS(files), 'src/main.mjs');
return g0.modules.map((m) => m.source);
})();
// 口径对齐:词法基准只跑"可达模块",否则拿全量 3001 个文件和 1201 个比没有意义
const tLex = benchMs(() => { for (const src of reachable) tokenize(src); }, OPT);
const tGraph = benchMs(() => buildGraph(createFS(files), 'src/main.mjs'), OPT);
const g = buildGraph(createFS(files), 'src/main.mjs');
const tShake = benchMs(() => treeShake(g), OPT);
const s = treeShake(g);
const tLink = benchMs(() => link(g, s), OPT);
const l = link(g, s);
const tHash = benchMs(() => contentHash(l.code), OPT);
const total = tGraph + tShake + tLink + tHash;
log(` 源码 ${fmtB(bytes)} → 产物 ${fmtB(l.stats.bytes)}`);
log(` 建图(含扫描) ${tGraph.toFixed(1)}ms (${pct(tGraph, total)}) 摇树 ${tShake.toFixed(1)}ms (${pct(tShake, total)}) 链接 ${tLink.toFixed(1)}ms (${pct(tLink, total)}) 哈希 ${tHash.toFixed(2)}ms (${pct(tHash, total)})`);
log(` 合计 ${total.toFixed(1)}ms;其中纯词法切分 ${tLex.toFixed(1)}ms = ${pct(tLex, total)} 的整条管道`);
log(` 口径:入口只引用 40% 模块,所以磁盘上 ${Object.keys(files).length} 个文件里只有 ${reachable.length} 个进了图、被解析`);
log(` 自洽性检查(同一批 ${reachable.length} 个模块):建图 ${tGraph.toFixed(1)}ms >= 纯词法 ${tLex.toFixed(1)}ms ? ${tGraph >= tLex ? '✅ 建图 = 词法 + 解析 + 解析依赖' : '❌ 采样不稳'}`);
log(` → 词法/解析是构建的下限;换更快的 parser 才有量级收益`);
}
log('');
/* ---- §9b 链接阶段:一次 O(语句×token) 改 O(token) 的过程 ---- */
log('## §9b 链接阶段:语句×token 双循环 vs 单遍扫描');
{
const files = genProject({ modules: 3000, liveRatio: 0.4 });
const g = buildGraph(createFS(files), 'src/main.mjs');
const sh = treeShake(g);
const OPT2 = { warm: 2, runs: 11 };
const tFast = benchMs(() => link(g, sh), OPT2);
const tNaive = benchMs(() => link(g, sh, { emitter: 'naive' }), OPT2);
const lF = link(g, sh), lN = link(g, sh, { emitter: 'naive' });
log(` 单遍扫描(现版本) ${tFast.toFixed(1)}ms`);
log(` 语句×token 双循环 ${tNaive.toFixed(1)}ms → 慢 ${(tNaive / tFast).toFixed(2)}x`);
log(` 两者输出一致?${lF.code === lN.code ? '✅ 逐字节相同' : '❌ 不一致(说明优化改变了行为,不可接受)'}`);
log(` → 语义完全相同,只因"对每条语句重扫一遍全部 token",就从 ${tFast.toFixed(0)}ms 变成 ${tNaive.toFixed(0)}ms`);
}
log('');
/* ================= §10 增量重建 ================= */
log('## §10 增量:改 1 个文件,全量重扫 vs 扫描缓存');
{
const N = 3000;
const files = genProject({ modules: N, liveRatio: 0.4 });
const cached = new Map();
const files2 = { ...files };
files2['src/m3.mjs'] = files2['src/m3.mjs'].replace('return acc + 3;', 'return acc + 3000;');
// 冷启动:缓存为空 → 全部重扫
const coldMs = benchMs(() => {
const c = new Map();
build(files2, 'src/main.mjs', { cache: c });
}, { warm: 2, runs: 7 });
// 增量:缓存预热到"只有 m3 变了"
const warmMs = benchMs(() => {
const c = new Map();
build(files, 'src/main.mjs', { cache: c }); // 预热
build(files2, 'src/main.mjs', { cache: c }); // 只重扫 m3
}, { warm: 1, runs: 7 }) - coldMs;
const coldStat = (() => { const c = new Map(); return build(files2, 'src/main.mjs', { cache: c }).graph.scanStats; })();
const warmStat = (() => { const c = new Map(); build(files, 'src/main.mjs', { cache: c }); return build(files2, 'src/main.mjs', { cache: c }).graph.scanStats; })();
log(` 冷启动 ${coldMs.toFixed(0)}ms(扫 ${coldStat.cacheMiss} 个模块,命中 ${coldStat.cacheHit})`);
log(` 增量 ${warmMs.toFixed(0)}ms(扫 ${warmStat.cacheMiss} 个模块,命中 ${warmStat.cacheHit})`);
log(` 提速 ${(coldMs / warmMs).toFixed(1)}x —— 但注意剩余时间并没有归零`);
log(` 剩余 ${warmMs.toFixed(0)}ms 全花在"遍历全图做摇树 + 重新拼接 + 重新哈希"上:模块级缓存能省掉解析,省不掉图遍历`);
log(` → 真正要把增量做到 O(变更),得连"链接"也要能增量(Vite 靠 ESM 天然免打包,Turbopack 靠 chunk 级增量)`);
void N;
}
log('');
/* ================= §11 副作用的代价 ================= */
log('## §11 有副作用的模块:保守判定的代价');
{
const mk = (withEffect) => {
const files2 = genProject({ modules: 100, liveRatio: 0.3 });
if (withEffect) {
for (const k of Object.keys(files2)) {
if (k === 'src/main.mjs') continue;
files2[k] += `\nglobalThis.__t = (globalThis.__t ?? 0) + 1;`;
}
}
return files2;
};
const clean = build(mk(false), 'src/main.mjs');
const dirty = build(mk(true), 'src/main.mjs');
log(` 干净模块(可整块摇掉)产物 ${fmtB(clean.chunks[0].bytes)}`);
log(` 每个模块都加一条顶层副作用 → 产物 ${fmtB(dirty.chunks[0].bytes)},放大 ${(dirty.chunks[0].bytes / clean.chunks[0].bytes).toFixed(2)}x`);
log(` → 这就是 package.json "sideEffects": false 的真实价值,也是它配错会造成线上故障的原因`);
}
log('');
log('---');
log(`共 ${out.length} 行。以上每个数字均由本文件现场跑出,无手填。`);
extract-and-test.mjs —— 从文章提取代码块并自检
// extract-and-test.mjs —— 把正文里的 ```js 代码块抽出来逐个做语法校验
// 目的:文章里的代码不能是"看起来像代码"的东西。抽出来的每一块都必须能被 Node 解析。
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { execFileSync } from 'node:child_process';
// 兼容两种布局:md 与本脚本同目录(开发期),或 md 在上一级(落地后 md 与 day35-assets/ 分开放)
const here = path.dirname(new URL(import.meta.url).pathname);
const candidates = [
path.join(here, 'Day35-构建工具.md'),
path.join(here, '..', 'Day35-构建工具.md'),
process.argv[2],
].filter(Boolean);
const mdPath = candidates.find((p) => fs.existsSync(p));
if (!mdPath) {
console.error('找不到 Day35-构建工具.md,试过:', candidates.join(', '));
process.exit(1);
}
const md = fs.readFileSync(mdPath, 'utf8');
console.log(`正文:${mdPath}`);
// 抽 ```js / ```javascript 围栏
const blocks = [];
const re = /```(?:js|javascript)\n([\s\S]*?)```/g;
let m;
while ((m = re.exec(md))) {
const before = md.slice(0, m.index);
const line = before.split('\n').length;
blocks.push({ code: m[1], line });
}
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'day35-extract-'));
let ok = 0, bad = 0;
blocks.forEach((b, i) => {
const file = path.join(dir, `block${i}.mjs`);
fs.writeFileSync(file, b.code, 'utf8');
try {
execFileSync(process.execPath, ['--check', file], { stdio: 'pipe' });
ok++;
console.log(`✓ block#${i} (正文第 ${b.line} 行, ${b.code.split('\n').length} 行代码)`);
} catch (e) {
bad++;
console.error(`✗ block#${i} (正文第 ${b.line} 行)`);
console.error(String(e.stderr ?? e.message).split('\n').slice(0, 6).join('\n'));
}
});
fs.rmSync(dir, { recursive: true, force: true });
// 顺手校验正文自报的数字口径
const zh = (md.match(/[\u4e00-\u9fa5]/g) ?? []).length;
const tables = md.split('\n').filter((l) => l.trim().startsWith('|')).length;
console.log('');
console.log(`正文:${zh} 汉字 / ${md.length} 字节 / ${md.split('\n').length} 行 / ${blocks.length} 个 js 代码块 / ${tables} 行表格`);
console.log(`代码块语法校验:${ok} 通过 / ${bad} 失败`);
console.log(zh >= 1500 ? `✅ 正文达到 1500 字要求(${zh})` : `❌ 正文不足 1500 字(${zh})`);
process.exit(bad === 0 && zh >= 1500 ? 0 : 1);