深入 Next.js 中 Turbopack Tree Shaker 分析器:从一份测试快照看懂依赖图构建与模块切分全流程
【免费下载链接】next.jsThe React Framework项目地址: https://gitcode.com/GitHub_Trending/next/next.js
本文以 Turbopack 的 ECMAScript 模块处理 crate(turbopack-ecmascript)中 tree-shaker 分析器的一个测试快照为对象,完整还原"一段 17 条语句的 ES 模块"是如何被拆解为 17 个分析项、经过提升/立即求值/最终求值/导出处理等阶段构建出强/弱依赖图,再被切分为 13 个模块 Part 并合并出入口的。读完本文,你可以看懂 Turbopack tree-shaking 快照的每一个字段与每一种边,并理解 dev 与 prod 两种模式下弱依赖差异处理的实际效果。
这份文档是什么:一个分析器的全链路快照
关联文档 output.md 并不是人写的说明文档,而是 Turbopack tree-shaker 分析器针对测试用例 1/input.js 自动生成的参考快照。它由 fixture 测试驱动:
- 测试入口位于 tests.rs,通过
#[fixture("tests/tree-shaker/analyzer/**/input.js")]宏扫描整个tests/tree-shaker/analyzer/目录,用例 1 只是其中 38 个用例之一(同目录还有shared-and-side-effects、next-response、template-pages等针对真实场景的用例)。 - 每个用例的
input.js被 swc 解析为模块 AST(并先运行resolverpass 做变量解析),随后交给分析器;tests.rs 还支持读取同目录下的config.json指定"仅拉取哪些导出"来测试(部分用例如test-config-1附带该配置),用例 1 无配置,走默认全量分析。 - 最终把每一步的中间产物序列化为 Markdown,用
NormalizedOutput::compare_to_file与output.md做快照比对(见 tests.rs),因此该文件是测试的期望输出,也是理解分析器行为最完整的"执行日志"。
用例 1 的输入模块只有 18 行,却刻意覆盖了提升(hoisting)、副作用、闭包内的最终读/写、导出分组等所有难点:
// tests/tree-shaker/analyzer/1/input.js import { upper } from "module"; export let foobar = "foo"; export const foo = foobar; const bar = "bar"; foobar += bar; let foobarCopy = foobar; foobar += "foo"; console.log(foobarCopy); foobarCopy += "Unused"; function internal() { return upper(foobar); } export function external1() { return internal() + foobar; } export function external2() { foobar += "."; }分析器的主流程在 tests.rs 中按固定顺序执行:
g.init(...):遍历 AST,抽取全部分析项(Items);analyzer.hoist_vars_and_bindings():处理变量/函数声明提升;analyzer.evaluate_immediate(...):求值顶层立即执行的语句,建立语句间依赖;analyzer.evaluate_eventual(...):处理提升函数体中的"最终"(调用时才会发生)读写;analyzer.handle_exports(...):为每个导出建立分组节点;analyzer.handle_explicit_deps():处理显式依赖;g.finalize(...):把强连通的项压缩成最终节点;g.handle_weak(Mode)+g.split_module(...):按 dev/prod 模式弱化弱依赖,并切分出模块 Part 与入口映射。
下面按照快照的章节逐段解读。
第一步:Items——把 AST 语句拆成 17 个分析项
快照开头的 "Items / Count: 17" 段列出了全部 17 个项。每项由ItemId::Item { index, kind }标识(语句在模块体中的序号 + 种类),种类有四种:
ImportOfModule:带模块副作用的裸导入(import "module"中必须求值的模块部分);ImportBinding(0):从该模块导入的具体绑定(upper);VarDeclarator(0):顶层变量声明语句(let/const);Normal:普通语句或函数声明。
每个项附带一组属性(字段定义可见 tests.rs 的打印逻辑,结构体位于 module_fragments/mod.rs):
| 属性 | 含义 |
|---|---|
Hoisted | 声明可提升,定义可被延迟到真正使用时加载 |
Side effects | 求值有副作用,不可随意删除 |
Declares | 声明的顶层绑定 |
Reads/Reads (eventual) | 立即读 / 最终读(函数被调用时才发生) |
Write/Write (eventual) | 立即写 / 最终写 |
17 个项逐一对照输入代码:
| # | 语句 | 种类 | 关键属性 |
|---|---|---|---|
| 1 | Stmt 0import { upper } from "module"; | ImportOfModule | Hoisted;Side effects(导入模块本身有求值副作用) |
| 2 | Stmt 0import { upper } from "module"; | ImportBinding(0) | Hoisted;Declares:upper |
| 3 | Stmt 1export let foobar = "foo"; | VarDeclarator(0) | Declares:foobar;Write:foobar |
| 4 | Stmt 2export const foo = foobar; | VarDeclarator(0) | Declares:foo;Reads:foobar;Write:foo |
| 5 | Stmt 3const bar = "bar"; | VarDeclarator(0) | Declares:bar;Write:bar |
| 6 | Stmt 4foobar += bar; | Normal | Reads:bar,foobar;Write:foobar |
| 7 | Stmt 5let foobarCopy = foobar; | VarDeclarator(0) | Declares:foobarCopy;Reads:foobar;Write:foobarCopy |
| 8 | Stmt 6foobar += "foo"; | Normal | Reads:foobar;Write:foobar |
| 9 | Stmt 7console.log(foobarCopy); | Normal | Side effects;Reads:foobarCopy |
| 10 | Stmt 8foobarCopy += "Unused"; | Normal | Reads:foobarCopy;Write:foobarCopy |
| 11 | Stmt 9function internal() { ... } | Normal | Hoisted;Declares:internal;Reads (eventual):upper,foobar;Write:internal |
| 12 | Stmt 10export function external1() { ... } | Normal | Hoisted;Declares:external1;Reads (eventual):internal,foobar;Write:external1 |
| 13 | Stmt 11export function external2() { ... } | Normal | Hoisted;Declares:external2;Write:external2;Write (eventual):foobar |
| 14 | 导出分组 | Group | 标注为export foobar |
| 15 | 导出分组 | Group | 标注为export foo |
| 16 | 导出分组 | Group | 标注为export external1 |
| 17 | 导出分组 | Group | 标注为export external2 |
三个值得注意的细节:
- 一条导入语句被拆成两个项。Item 1(
ImportOfModule)代表模块求值的副作用,Item 2(ImportBinding(0))代表对upper这个具体绑定的使用——这正是后续 dev/prod 能做出不同加载策略的基础。 eventual前缀区分"现在发生"与"将来发生"。internal函数声明本身立即执行(只是注册一个函数对象),但它对upper、foobar的读取要等函数被调用时才发生,所以标为Reads (eventual);同理external2对foobar的写入是Write (eventual)。foo与foobar是别名关系。const foo = foobar之后两者指向同一绑定,这一点会体现在后文 Item 6 到 Item 4 的弱边(Item6 -.-> Item4)中。
Phase 1–4:依赖图的四次演进
快照的 Phase 1 到 Phase 4 各给出一张 Mermaid 有向图。边分两种:实线A --> B是强依赖(Dependency::Strong,B 必须先于 A 执行),虚线A -.-> B是弱依赖(Dependency::Weak,在满足条件的模式下可被延迟或跳过)。边方向语义为"前者依赖后者",渲染逻辑见 tests.rs。
Phase 1(提升之后):还没有任何边
hoist_vars_and_bindings只标记了哪些项可提升(Item 1、2、11、12、13),语句间的依赖尚未分析,图为 17 个孤立节点。
Phase 2(立即求值之后):顶层执行顺序成边
这一阶段为顶层语句建立了按执行顺序的读写边,并为四个导出分组各连了一条边:
Item4 --> Item3:const foo = foobar读取foobar的声明;Item6 --> Item5、Item6 --> Item3:foobar += bar依赖bar与foobar的声明;Item6 -.-> Item4:因为foo是foobar的别名,对foobar的写与foo之间只是弱关联;Item7 --> Item6、Item7 --> Item3:let foobarCopy = foobar的读取先于本次+=的结果;Item8 --> Item6、Item8 --> Item3、Item8 -.-> Item7:foobar += "foo"的执行顺序依赖;Item9 --> Item7、Item9 --> Item1、Item9 --> Item8:console.log(foobarCopy)有副作用,必须执行,且要求模块导入(Item 1)已完成、foobarCopy已被赋值;其中Item9 -.-> Item2(对upper绑定的依赖)与Item9 -.-> Item11(可能间接涉及internal)是弱边;Item10 --> Item7、Item10 -.-> Item9:foobarCopy += "Unused"依赖拷贝值,但对console.log的依赖是弱的;- 导出分组边:
Item14 --> Item8、Item14 --> Item3(导出foobar依赖其全部立即写)、Item15 --> Item4(导出foo)、Item16 --> Item12(导出external1)、Item17 --> Item13(导出external2)。
Phase 3(最终求值之后):提升函数补上"调用时"的边
这是图中变化最大的一步,全部来自三个提升函数的闭包分析:
Item11 --> Item2:internal调用upper,需要导入绑定;Item11 --> Item8、Item11 --> Item3:internal最终读取的foobar必须包含foobar += "foo"(Item 8)之后的值,且依赖声明(Item 3);Item12 --> Item11、Item12 --> Item8、Item12 --> Item3:external1依赖internal及foobar的最终状态;Item13 -.-> Item14:external2对foobar的最终写与foobar的导出分组之间是弱依赖——导出方拿到的是写入前的绑定;Item13 --> Item3:external2依赖foobar的声明。
Phase 4 与 Final:显式依赖与连通压缩
本用例没有显式依赖(handle_explicit_deps无新增边),Phase 4 与 Phase 3 完全相同。随后finalize把强连通的项压缩为 12 个最终节点:
N0=[Item(0, ImportOfModule)],N1=[Item(0, ImportBinding(0))](即裸导入与upper绑定被拆成两个独立可加载单元);N4=[Item(3), Item(4)]:const bar与foobar += bar合并(执行紧邻、强连通);N7=[Item(7), Item(8)]:console.log与foobarCopy += "Unused"同节点;N9=[Item(10), Export("external1")]:函数声明与其导出分组合并;N10=[Item(11), Export("external2"), Export("foobar")]:external2声明与两个导出分组同节点——因为external2持有对foobar的最终写,导出foobar必须与它共存;N11=[Export("foo")]。
最终图中的弱边(N7 -.-> N8、N7 -.-> N1、N6 -.-> N5、N4 -.-> N3)正是后面 prod 模式可以"偷懒"的地方。
Entrypoints:入口键到 Part 的映射
切分完成后,split_module返回SplitModuleResult { modules, entrypoints, .. }(结构定义在 graph.rs),entrypoints是一张"入口键 → Part 下标"的映射,消费方按它按需拉取代码。dev 与 prod 的入口映射分别是:
# dev { ModuleEvaluation: 7, Export("external1"): 9, Export("external2"): 10, Export("foo"): 11, Export("foobar"): 10, Exports: 12 } # prod { ModuleEvaluation: 7, Export("external1"): 9, Export("external2"): 10, Export("foo"): 3, Export("foobar"): 11, Exports: 12 }解读:
ModuleEvaluation: 7表示"模块求值"入口在 Part 7,即模块顶层语句的集合;- 每个
Export(name)键指向持有该导出的 Part;Exports: 12指向 Part 12 的"导出门面"(统一 re-export 所有导出); - 两模式的差异在于
foo与foobar:dev 下foo的导出被拆到独立的 Part 11、foobar的导出与external2合并在 Part 10;prod 下foo直接由声明它的 Part 3 导出,foobar则被挪到专门的 Part 11。这正是handle_weak(Mode::Production)(见 tests.rs)弱化弱依赖后重新切分的结果。
Modules (dev):13 个 Part 的拆分细节
dev 模式的输出展示了树摇后的代码形态。所有 Part 间引用都通过两条 import assertion 表达:
import ... from "__TURBOPACK_PART__" assert { __turbopack_part__: N }:加载第 N 个 Part。正数(如3)表示"第 3 个 Part 的顶层代码",负数(如-2)表示"第 2 个 Part 导出的活绑定变量";export { x as v } from "__TURBOPACK_VAR__" assert { __turbopack_var__: true }:把let/const声明导出为跨 Part 的活绑定(其他 Part 通过import { v as x } from ... -2拿到同一变量,赋值依然可见)。字符串形式的值(如"export external1")则指向对应的入口。
关键 Part 逐一说明(其余 Part 遵循相同模式):
// Part 0:裸导入的副作用载体 import "module"; // Part 2:foobar 的声明(活绑定 a) let foobar = "foo"; export { foobar as a } from "__TURBOPACK_VAR__" assert { __turbopack_var__: true }; // Part 3:foo 的声明,通过 -2 拿到 foobar 的活绑定 import { a as foobar } from "__TURBOPACK_PART__" assert { __turbopack_part__: -2 }; const foo = foobar; export { foo as b } from "__TURBOPACK_VAR__" assert { __turbopack_var__: true }; // Part 4:bar 声明 + foobar += bar,并把 part 3 作为前置依赖拉进来 import { a as foobar } from "__TURBOPACK_PART__" assert { __turbopack_part__: -2 }; import "__TURBOPACK_PART__" assert { __turbopack_part__: 3 }; const bar = "bar"; foobar += bar; export { bar as c } from "__TURBOPACK_VAR__" assert { __turbopack_var__: true }; // Part 7(模块求值入口):顶层副作用语句的集合 import { d as foobarCopy } from "__TURBOPACK_PART__" assert { __turbopack_part__: -5 }; import "__TURBOPACK_PART__" assert { __turbopack_part__: 8 }; // 拉入 internal(强依赖) import "__TURBOPACK_PART__" assert { __turbopack_part__: 6 }; // foobar += "foo" import "__TURBOPACK_PART__" assert { __turbopack_part__: 1 }; // upper 绑定包装 import "__TURBOPACK_PART__" assert { __turbopack_part__: 0 }; // import "module" console.log(foobarCopy); foobarCopy += "Unused"; export { }; // Part 9 / Part 10:导出函数,各自携带 Export 分组 function external1() { return internal() + foobar; } export { external1 }; export { external1 as f } from "__TURBOPACK_VAR__" assert { __turbopack_var__: true }; function external2() { foobar += "."; } export { external2 }; export { foobar }; export { external2 as g } from "__TURBOPACK_VAR__" assert { __turbopack_var__: true }; // Part 11(dev 下 Export("foo") 入口):从 Part 3 的活绑定 re-export import { b as foo } from "__TURBOPACK_PART__" assert { __turbopack_part__: -3 }; export { foo }; // Part 12(Exports 门面):按入口名字符串统一转发 export { external1 } from "__TURBOPACK_PART__" assert { __turbopack_part__: "export external1" }; export { external2 } from "__TURBOPACK_PART__" assert { __turbopack_part__: "export external2" }; export { foobar } from "__TURBOPACK_PART__" assert { __turbopack_part__: "export foobar" }; export { foo } from "__TURBOPACK_PART__" assert { __turbopack_part__: "export foo" };最后还有一段 "Merged (module eval)",测试用 Merger(merge_recursively)把ModuleEvaluation入口的 Part 及其强依赖递归合并成一个模块,模拟运行时"执行一次模块顶层"实际会跑到的全部代码:dev 下合并结果为import { d as foobarCopy } from -5+ 拉取 Part 8、6、1、0 +console.log(foobarCopy); foobarCopy += "Unused";。
Modules (prod):弱依赖弱化带来的三个关键变化
prod 模式先调用handle_weak(Mode::Production),与 dev 相比输出有三处显著不同,均可在快照中直接验证:
- 死写入被拆成惰性 Part。dev 里
foobarCopy += "Unused"与console.log同在 Part 7;prod 里 Part 7 只剩console.log(foobarCopy)(并直接import part 0代替 dev 的 part 1 包装),而那条没有任何读者消费其结果的写入被单独移到惰性 Part 8:
// prod Part 7 import { d as foobarCopy } from "__TURBOPACK_PART__" assert { __turbopack_part__: -5 }; import "__TURBOPACK_PART__" assert { __turbopack_part__: 0 }; import "__TURBOPACK_PART__" assert { __turbopack_part__: 6 }; console.log(foobarCopy); export { }; // prod Part 8(惰性) import { d as foobarCopy } from "__TURBOPACK_PART__" assert { __turbopack_part__: -5 }; foobarCopy += "Unused";可提升函数被进一步合并。dev 下
internal独占 Part 8、external1独占 Part 9;prod 下两者连同import { upper }合并进同一个 Part 9(function internal() {...} function external1() {...}),因为它们都是 Hoisted 声明,定义何时加载不影响正确性,合并减少了 Part 数量。导出直接贴在声明 Part 上。prod Part 3 直接
export { foo }(入口映射Export("foo"): 3),不再需要 dev 那个专门 re-export 的 Part 11;foobar的导出则落在新 Part 11,该 Part 会先import part 6(foobar += "foo")再export { foobar },保证消费者读到的是求值完成后的值。
从源码结构看,这三处差异正是handle_weak对不同Mode采用不同弱化策略的体现:dev 模式保守(保留更多前置依赖,保证断点与调试语义),prod 模式激进(把弱边拆成惰性 Part、合并提升声明)。
如何使用这份快照与测试机制
如果你要在本地验证或扩展这套分析测试:
- 查看输入:1/input.js;查看期望快照:1/output.md;
- 测试运行于
turbopack-ecmascriptcrate 内,可通过cargo test(在 turbopack/Cargo.toml 定义的工作区根目录执行,按需以-p turbopack-ecmascript指定包)运行;fixture 机制会自动把每个input.js的输出与output.md比对,不一致即测试失败。注意快照是"期望值"文件,正常情况下不应手工修改; - 想测试"只消费某个导出"的场景,可参考带
config.json的用例(如test-config-1):exports字段是Vec<Vec<String>>,每一组会额外输出一段以导出名命名的 "Merged (...)" 与入口映射(见 tests.rs 与 tests.rs)。
小结
output.md 用一份 18 行输入,完整记录了 Turbopack tree-shaker 的五个关键产物:17 个带提升/副作用/读写属性的分析项、四阶段演进的强/弱依赖图、连通压缩后的最终节点、dev/prod 两套入口映射,以及基于__TURBOPACK_PART__/__TURBOPACK_VAR__断言的 13 个模块 Part 与合并结果。它同时回答了三个工程问题:语句级树摇如何在"立即语义"与"最终语义"之间做区分(Reads/Reads (eventual))、活绑定如何跨 Part 传递(__turbopack_var__导出)、以及 prod 模式如何利用弱依赖把死写入与提升声明拆成惰性/合并的 Part。对于需要理解 Next.js Turbopack 构建产物形态、或排查按需加载行为异常的开发者和 Agent 来说,这份快照与其驱动的 fixture 测试 是可复现、可验证的第一手材料。
【免费下载链接】next.jsThe React Framework项目地址: https://gitcode.com/GitHub_Trending/next/next.js
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考