Next.js Turbopack 模块切分快照解析:以 grouping 用例理解 tree-shaker 分析器的 Item 分解、依赖图阶段与 Part 切分
【免费下载链接】next.jsThe React Framework项目地址: https://gitcode.com/GitHub_Trending/next/next.js
本文以 Next.js 仓库中 Turbopack 的 tree-shaker 测试快照 grouping/output.md 为主体,结合其输入文件 grouping/input.js 与测试驱动源码 tests.rs,完整还原一条“从 14 行源码到多个可独立加载的 Part 模块”的切分过程。读完后,你将理解 Turbopack 如何将模块顶层语句分解为带读/写变量标注的 Item、如何经过 Phase 1–4 逐步构建依赖图,以及split_module最终如何利用__TURBOPACK_PART__/__TURBOPACK_VAR__占位导入把切分结果接线成可运行的模块碎片,并能自行对照仓库验证 dev 与 prod 两种模式的切分差异。
1. 快照的来源:一个 fixture 驱动的测试用例
这个output.md并不是手写文档,而是测试夹具(fixture)的期望输出快照。测试入口定义在 tests.rs:
#[fixture("tests/tree-shaker/analyzer/**/input.js")] fn test_fixture(input: PathBuf) { run(input); }任何tests/tree-shaker/analyzer/子目录下的input.js都会成为一个用例,本用例目录为 grouping/。run()的执行流程(见 tests.rs)为:
- 用 swc 解析
input.js(开启 JSX),并运行 resolver 标记顶层作用域; - 调用
DepGraph::init把模块拆解为若干 Item,逐条写出# Items段(源码打印Declares/Reads/Write/Side effects等元信息,对应 tests.rs); - 依次执行分析器各阶段,每步渲染一次 Mermaid 依赖图:
hoist_vars_and_bindings(Phase 1)→evaluate_immediate(Phase 2)→evaluate_eventual(Phase 3)→handle_exports(Phase 4)→handle_explicit_deps+finalize(Final,见 tests.rs); - 分别以 Development 与 Production 两种模式调用
g.split_module,打印各 Part 源码与Merged结果(见 tests.rs); - 将整份报告通过
NormalizedOutput::from(s).compare_to_file(input.with_file_name("output.md"))与快照比对(tests.rs)。
本用例没有config.json,因此默认exports: [],只测试ModuleEvaluation这一个入口的合并(tests.rs)。
1.1 输入代码
完整的输入仅 14 行(input.js):
let x = 1; x = 2; x = 3; console.log(x); x = 4; x = 5; x += 6; x += 7; x += 8; x += 9; export { x }; export const y = x;它刻意构造了一个“对同一变量反复写入、中间夹一个副作用读取、最后两个导出”的模式,用来考察分析器如何给一连串赋值语句建立顺序依赖。
2. Items:顶层语句被拆成了 13 个 Item
快照第一部分是# Items,Count: 13。这 13 个节点中 11 个是真实语句 Item,另外 2 个是导出分组节点(ItemId::Group),打印时被ItemId::Group(_) => continue跳过,所以在## Item N列表中只出现 11 条。Item 的类型与分组类型定义在 graph.rs:
pub(crate) enum ItemId { Item { index: usize, kind: ItemIdItemKind }, Group(ItemIdGroupKind), } pub(crate) enum ItemIdItemKind { Normal, ImportOfModule, ImportBinding(u32), ReexportBinding(u32), VarDeclarator(u32), }快照中的全部 Item 及其标注如下(编号对应stmt 序号,与input.js的行一一对应):
| Item | 语句 | 类型 | Declares | Reads | Write | Side effects |
|---|---|---|---|---|---|---|
| 1 | let x = 1; | VarDeclarator(0) | x | x | ||
| 2 | x = 2; | Normal | x | |||
| 3 | x = 3; | Normal | x | |||
| 4 | console.log(x); | Normal | x | 有 | ||
| 5 | x = 4; | Normal | x | |||
| 6 | x = 5; | Normal | x | |||
| 7 | x += 6; | Normal | x | x | ||
| 8 | x += 7; | Normal | x | x | ||
| 9 | x += 8; | Normal | x | x | ||
| 10 | x += 9; | Normal | x | x | ||
| 11 | export const y = x; | VarDeclarator(0) | y | x | y |
另有export { x }语句本身不产生语句 Item,而是生成两个导出分组节点(在图中记为 Item 12export x与 Item 13export y)。这些字段正是 graph.rs 中ItemData结构体的直接体现:var_decls(声明)、read_vars(即时读取)、write_vars(写入副作用)、side_effects(未知副作用,如console.log),以及本用例未触发的eventual_read_vars/eventual_write_vars(“最终会读/写”,用于函数体等需先触发副作用才执行的场景,源码注释见 graph.rs)。
值得注意的一个细节:console.log(x)被标记为 Side effects,这正是后面它被划入“模块求值”组(即必须最先执行的代码)的依据——有副作用且被读取的语句不能随无关导出被丢弃。
3. Phase 1–4 与 Final:依赖图的四次演进
快照随后用五张 Mermaid 图记录依赖图的演化。Phase 1 只有节点、没有边,是hoist_vars_and_bindings之后的初始状态:
evaluate_immediate之后(Phase 2),每个“写”节点连向其依赖来源。实线箭头表示由声明/读取建立的直接依赖,虚线箭头是附加在写操作上的顺序约束边。以x = 2;为例:它写x,必须排在let x = 1;(Item 1)之后,于是有Item2 --> Item1。完整的 Phase 2 图如下:
可以观察到几条关键依赖:
- 赋值链:
x = 2 → x = 3 → … → x += 9形成对声明节点 Item 1 的链式依赖(Item2 --> Item1、Item7 --> Item6、…、Item10 --> Item9),保证运行顺序与原模块一致; - 读取点:
console.log(x)(Item 4)依赖它读取时x的最新写入方x = 3(Item4 --> Item3),同时依赖声明(Item4 --> Item1); - 导出点:
export x(Item 12)依赖最后一次写入x += 9(Item12 --> Item10)与声明;export y(Item 13)依赖export const y = x;(Item 11),后者又依赖x += 9(Item11 --> Item10)——因为y的值取自求值时刻的x。
evaluate_eventual(Phase 3)与handle_exports(Phase 4)在本用例中没有新增边:本例不存在“最终读/写”(函数体等延迟读取),导出分组节点及其边在初始化阶段就已入图。因此 Phase 3、Phase 4 的图与 Phase 2逐边完全一致(快照原文如此),这本身也是快照的价值之一——它锁定了“这些阶段不应产生额外变化”这一行为。
finalize之后得到Final图:节点先被压缩为若干“组”(组内语句相互紧邻、可整体移动),边只剩组间关系:
六个组的语义:
- N0:
let x = 1;—— 变量声明,所有使用方的公共依赖; - N1:
x = 2;—— 独立小组(它的后续写入x = 3被副作用读取点截断成另一组); - N2:
x = 3; console.log(x); x = 4;—— 含副作用读取console.log的组,是“模块求值”的锚点; - N3:
x = 5; x += 6; x += 7; x += 8; x += 9;—— 纯赋值链,可整体移动; - N4:
export const y = x;+export y分组 ——y的定义与其导出同组; - N5:
export x分组 —— 单独成组,依赖 N3(最终值)与 N0(声明)。
4. Entrypoints:组到 Part 的映射
split_module(graph.rs)把 Final 图落盘为若干 Part 模块,并返回“入口键 → Part 序号”的映射。快照中出现了两版映射,分别来自 Development 与 Production 两次切分(测试通过g.handle_weak(if is_debug { Mode::Development } else { Mode::Production })切换,见 tests.rs):
# 切分一次(Development 模式)的 Entrypoints { ModuleEvaluation: 2, Export("x"): 5, Export("y"): 4, Exports: 6, }# 切分二次(Production 模式)的 Entrypoints { ModuleEvaluation: 2, Export("x"): 6, Export("y"): 5, Exports: 7, }键的含义与 tests.rs 一致:ModuleEvaluation指向“必须最先执行的组”(这里 N2,即console.log所在组),Export("x")/Export("y")指向各导出对应的 Part,Exports指向纯导出转发模块(只含export ... from,供外部导入方使用)。两版映射的序号差 1,说明 prod 模式多切出了一个 Part(下文 Part 3 的x = 4;)。
5. Modules (dev):Development 模式下的 7 个 Part
Development 模式切分结果为 7 个 Part(Part 0–6),快照原文如下。
Part 0(声明组 N0,并向全局变量表注册x):
let x = 1; export { x as a } from "__TURBOPACK_VAR__" assert { __turbopack_var__: true };Part 1(N1:x = 2):
import { a as x } from "__TURBOPACK_PART__" assert { __turbopack_part__: -0 }; x = 2;Part 2(N2:模块求值锚点,也是ModuleEvaluation入口指向的 Part):
import { a as x } from "__TURBOPACK_PART__" assert { __turbopack_part__: -0 }; x = 3; console.log(x); x = 4; export { };Part 3(N3:纯赋值链整体):
import { a as x } from "__TURBOPACK_PART__" assert { __turbopack_part__: -0 }; x = 5; x += 6; x += 7; x += 8; x += 9;Part 4(N4:y的定义 +export y):
import { a as x } from "__TURBOPACK_PART__" assert { __turbopack_part__: -0 }; import "__TURBOPACK_PART__" assert { __turbopack_part__: 3 }; const y = x; export { y }; export { y as b } from "__TURBOPACK_VAR__" assert { __turbopack_var__: true };Part 5(N5:export x):
import { a as x } from "__TURBOPACK_PART__" assert { __turbopack_part__: -0 }; import "__TURBOPACK_PART__" assert { __turbopack_part__: 3 }; export { x };Part 6(Exports入口:纯转发模块):
export { y } from "__TURBOPACK_PART__" assert { __turbopack_part__: "export y" }; export { x } from "__TURBOPACK_PART__" assert { __turbopack_part__: "export x" };5.1 占位导入的接线规则
从这些 Part 可以归纳出切分输出的接线规则:
- 跨 Part 共享变量:声明
x的 Part 0 通过export { x as a } from "__TURBOPACK_VAR__" assert { __turbopack_var__: true }把变量注册到虚拟模块__TURBOPACK_VAR__,其余 Part 用import { a as x } from "__TURBOPACK_PART__" assert { __turbopack_part__: -0 }从“Part 0”取回。a是对x的改名结果——split_module内部用 base54 编码器对跨 Part 变量统一改名(见 graph.rs 的mangle),本例中x → a、y → b。从源码结构看,这种“变量注册表”方式让 Part 4/5 能读x而不必反向 import 声明语句所在的真实模块,从而在 Part 之间避免循环依赖; - 组间执行顺序:Part 4/5 额外有一条
import "__TURBOPACK_PART__" assert { __turbopack_part__: 3 }副作用导入,对应 Final 图中N4 --> N3、N5 --> N3的边,确保赋值链(Part 3)在求值y、读取x导出值之前完成; - 命名导出:Part 6 中的字符串值
"export x"/"export y"指向带导出名的 Part(即 Part 5/4),使外部对原模块的import { x, y }只需 import 这个稳定的转发模块。
5.2 Merged (module eval)
测试还会把ModuleEvaluation入口对应的 Part 经Merger合并回去,验证合并后的语义等价性(tests.rs)。Development 模式下入口只有 Part 2 一个,合并结果为:
import { a as x } from "__TURBOPACK_PART__" assert { __turbopack_part__: -0 }; x = 3; console.log(x); x = 4; export { };保留export { }空导出语句是为了维持“这是一个 ES 模块”的语义。
6. Modules (prod):Production 模式进一步收紧
Production 模式切分出 8 个 Part(Part 0–7),与 dev 的差别集中在两点:
Part 0–1与 dev 完全相同。
Part 2(ModuleEvaluation入口)只剩两句,x = 4被移走:
import { a as x } from "__TURBOPACK_PART__" assert { __turbopack_part__: -0 }; x = 3; console.log(x); export { };Part 3(新增)只含被剥离出来的x = 4;:
import { a as x } from "__TURBOPACK_PART__" assert { __turbopack_part__: -0 }; x = 4;Part 4(原 dev 的 Part 3,赋值链):
import { a as x } from "__TURBOPACK_PART__" assert { __turbopack_part__: -0 }; x = 5; x += 6; x += 7; x += 8; x += 9;Part 5(N4:y的定义与导出,依赖从 Part 3 变为 Part 4):
import { a as x } from "__TURBOPACK_PART__" assert { __turbopack_part__: -0 }; import "__TURBOPACK_PART__" assert { __turbopack_part__: 4 }; const y = x; export { y }; export { y as b } from "__TURBOPACK_VAR__" assert { __turbopack_var__: true };Part 6(N5:export x):
import { a as x } from "__TURBOPACK_PART__" assert { __turbopack_part__: -0 }; import "__TURBOPACK_PART__" assert { __turbopack_part__: 4 }; export { x };Part 7(Exports转发模块)与 dev 的 Part 6 内容相同,只是序号后移:
export { y } from "__TURBOPACK_PART__" assert { __turbopack_part__: "export y" }; export { x } from "__TURBOPACK_PART__" assert { __turbopack_part__: "export x" };Merged (module eval)(prod)相应收短为:
import { a as x } from "__TURBOPACK_PART__" assert { __turbopack_part__: -0 }; x = 3; console.log(x); export { };从两套输出可以推断出 prod 与 dev 的差异所在:prod 模式下对“弱依赖”的处理(handle_weak(Mode::Production),在 tests.rs 中调用)把x = 4与console.log组的绑定拆开,使模块求值入口只保留x = 3; console.log(x);两个语句,而x = 4独立成 Part,仅在后续组(const y = x、export x)执行前被副作用导入触发。也就是说,prod 模式把“对导出结果无直接影响的写操作”从入口热路径上移了出去,让入口 Part 更小、更早可执行。这一行为差异正是该快照文件同时保留 dev/prod 两段输出的意义:任何修改handle_weak、finalize或split_module的改动,只要让任一侧输出变化,快照比对就会失败。
7. 如何在仓库中复现与验证
- 快照与输入:output.md、input.js;
- 测试驱动与报告生成逻辑:tests.rs(
run()、describe()、SingleModuleLoader与print()); - 图与切分核心:graph.rs 中的
ItemId/ItemIdGroupKind/ItemData、split_module; - 生产管线入口:
split_module在构建流程中的调用见 mod.rs(async fn split_module(asset: Vc<EcmascriptModuleAsset>))。
验证方式是运行该 crate 的 fixture 测试(在turbopack/crates/turbopack-ecmascript下执行cargo test,由swc_core::testing的 fixture 宏按tests/tree-shaker/analyzer/**/input.js自动收集用例)。若修改了分析器或切分逻辑,output.md会随实际输出变化而需要在本地更新快照——这也是阅读这类快照文件时的实用视角:它既是文档(完整记录了一个最小用例的切分全过程),也是回归测试的期望值。
8. 小结
- 该快照完整演示了 Turbopack tree-shaker 的四阶段分析流程:Item 分解(13 节点)→ 立即依赖求值 → 最终依赖求值 → 导出处理 → 组化压缩(6 组);
- 关键结论可从图中直接读出:副作用读取(
console.log(x))会把赋值链截断成“入口组 + 纯赋值组”,导出则依赖最后一次写入; split_module的产物是多个 Part,通过__TURBOPACK_PART__(组间顺序/跨组引用,-0表示声明组、数字表示 Part 序号、字符串表示命名导出 Part)与__TURBOPACK_VAR__(跨 Part 共享变量的改名注册表)两类占位导入完成接线;- dev 与 prod 的切分差异(
x = 4是否并入入口组)体现了生产模式对模块求值入口的进一步收紧,是理解 Turbopack 模块碎片化加载策略的一个最小而完整的样本。
【免费下载链接】next.jsThe React Framework项目地址: https://gitcode.com/GitHub_Trending/next/next.js
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考