Next.js Turbopack 模块切分快照解析:以 grouping 用例理解 tree-shaker 分析器的 Item 分解、依赖图阶段与 Part 切分
2026/9/8 21:32:08 网站建设 项目流程

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)为:

  1. 用 swc 解析input.js(开启 JSX),并运行 resolver 标记顶层作用域;
  2. 调用DepGraph::init把模块拆解为若干 Item,逐条写出# Items段(源码打印Declares/Reads/Write/Side effects等元信息,对应 tests.rs);
  3. 依次执行分析器各阶段,每步渲染一次 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);
  4. 分别以 Development 与 Production 两种模式调用g.split_module,打印各 Part 源码与Merged结果(见 tests.rs);
  5. 将整份报告通过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

快照第一部分是# ItemsCount: 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语句类型DeclaresReadsWriteSide effects
1let x = 1;VarDeclarator(0)xx
2x = 2;Normalx
3x = 3;Normalx
4console.log(x);Normalx
5x = 4;Normalx
6x = 5;Normalx
7x += 6;Normalxx
8x += 7;Normalxx
9x += 8;Normalxx
10x += 9;Normalxx
11export const y = x;VarDeclarator(0)yxy

另有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 --> Item1Item7 --> Item6、…、Item10 --> Item9),保证运行顺序与原模块一致;
  • 读取点console.log(x)(Item 4)依赖它读取时x的最新写入方x = 3Item4 --> Item3),同时依赖声明(Item4 --> Item1);
  • 导出点export x(Item 12)依赖最后一次写入x += 9Item12 --> Item10)与声明;export y(Item 13)依赖export const y = x;(Item 11),后者又依赖x += 9Item11 --> Item10)——因为y的值取自求值时刻的x

evaluate_eventual(Phase 3)与handle_exports(Phase 4)在本用例中没有新增边:本例不存在“最终读/写”(函数体等延迟读取),导出分组节点及其边在初始化阶段就已入图。因此 Phase 3、Phase 4 的图与 Phase 2逐边完全一致(快照原文如此),这本身也是快照的价值之一——它锁定了“这些阶段不应产生额外变化”这一行为。

finalize之后得到Final图:节点先被压缩为若干“组”(组内语句相互紧邻、可整体移动),边只剩组间关系:

六个组的语义:

  • N0let x = 1;—— 变量声明,所有使用方的公共依赖;
  • N1x = 2;—— 独立小组(它的后续写入x = 3被副作用读取点截断成另一组);
  • N2x = 3; console.log(x); x = 4;—— 含副作用读取console.log的组,是“模块求值”的锚点;
  • N3x = 5; x += 6; x += 7; x += 8; x += 9;—— 纯赋值链,可整体移动;
  • N4export const y = x;+export y分组 ——y的定义与其导出同组;
  • N5export 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 6Exports入口:纯转发模块):

export { y } from "__TURBOPACK_PART__" assert { __turbopack_part__: "export y" }; export { x } from "__TURBOPACK_PART__" assert { __turbopack_part__: "export x" };

5.1 占位导入的接线规则

从这些 Part 可以归纳出切分输出的接线规则:

  1. 跨 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 → ay → b。从源码结构看,这种“变量注册表”方式让 Part 4/5 能读x而不必反向 import 声明语句所在的真实模块,从而在 Part 之间避免循环依赖;
  2. 组间执行顺序:Part 4/5 额外有一条import "__TURBOPACK_PART__" assert { __turbopack_part__: 3 }副作用导入,对应 Final 图中N4 --> N3N5 --> N3的边,确保赋值链(Part 3)在求值y、读取x导出值之前完成;
  3. 命名导出: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 2ModuleEvaluation入口)只剩两句,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 7Exports转发模块)与 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 = 4console.log组的绑定拆开,使模块求值入口只保留x = 3; console.log(x);两个语句,而x = 4独立成 Part,仅在后续组(const y = xexport x)执行前被副作用导入触发。也就是说,prod 模式把“对导出结果无直接影响的写操作”从入口热路径上移了出去,让入口 Part 更小、更早可执行。这一行为差异正是该快照文件同时保留 dev/prod 两段输出的意义:任何修改handle_weakfinalizesplit_module的改动,只要让任一侧输出变化,快照比对就会失败。

7. 如何在仓库中复现与验证

  • 快照与输入:output.md、input.js;
  • 测试驱动与报告生成逻辑:tests.rs(run()describe()SingleModuleLoaderprint());
  • 图与切分核心:graph.rs 中的ItemId/ItemIdGroupKind/ItemDatasplit_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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询