解读 Turbopack tree-shaker 分析快照:以 otel-core 用例拆解 Items、Phase 与模块划分
2026/9/10 12:02:07 网站建设 项目流程

解读 Turbopack tree-shaker 分析快照:以 otel-core 用例拆解 Items、Phase 与模块划分

【免费下载链接】next.jsThe React Framework项目地址: https://gitcode.com/GitHub_Trending/next/next.js

Turbopack 是 Next.js 仓库中负责模块打包与编译的 Rust 实现。它内置了一套 ECMAScript 的"摇树(tree-shaking)"分析器,而仓库 tests/tree-shaker/analyzer/otel-core/ 下的output.md正是该分析器针对一个真实 OpenTelemetry 模块(otel-core)生成的可读化快照:它把模块拆成最小单元(Items)、展示依赖图如何分阶段收敛(Phase)、最终如何划分为多个可独立加载的代码片段(Part),并分别给出 dev 与 prod 两种模式下的输出。读完本文,你将掌握如何阅读这类.md快照文件,理解 Turbopack 树摇分析中ImportOfModule/ImportBinding/Normal三类 Item、Phase 图的边是如何生长的、Entrypoints 编号的含义,以及__TURBOPACK_PART__/__TURBOPACK_VAR__伪导入的用途——这套能力同样适用于阅读该目录下其余几十个分析器用例。

这份 output.md 是什么,以及它是怎么产生的

output.md不是手写的说明文档,而是 Turbopack ECMAScript 分析器在跑完某个用例后按固定模板序列化出来的中间结果快照。它与同目录 input.js 一一对应:

  • 输入是 input.js(一个从 OpenTelemetry JS 生态"环境变量读取"模块中改编出的 ESM 文件);
  • 输出是该模块经过 import 收集、依赖图构建、条目(entrypoint)绑定、模块划分之后的文本化转储。

turbopack/crates/turbopack-ecmascript/tests/tree-shaker/analyzer/下存放着几十个同类用例(simpledcenanoidamphtml-documentapp-routeroute-handlernode-fetchnext-responsenextjs-tracer等),每个用例由input.jsoutput.md组成,个别用例还带config.json(如3/test-config-1/)来控制分析选项。因此这类文件本质上是测试基线与可视化产物,用于回归校验"同一输入必须得到同一分析结果"。

输入模块:一个"带默认值合并"的环境读取器

先看输入 input.js(文件头带有 The OpenTelemetry Authors 的 Apache-2.0 版权声明):

import { DEFAULT_ENVIRONMENT, parseEnvironment } from '../../utils/environment'; import { _globalThis } from './globalThis'; /** * Gets the environment variables */ export function getEnv() { var globalEnv = parseEnvironment(_globalThis); return Object.assign({}, DEFAULT_ENVIRONMENT, globalEnv); } export function getEnvWithoutDefaults() { return parseEnvironment(_globalThis); }

它的语义非常典型:

  • ../../utils/environment导入两份内容——默认环境对象DEFAULT_ENVIRONMENT,以及负责把宿主对象解析成环境变量的函数parseEnvironment
  • ./globalThis导入当前宿主全局对象_globalThis
  • getEnv()先用parseEnvironment(_globalThis)解析出真实环境,再通过Object.assign({}, DEFAULT_ENVIRONMENT, globalEnv)把"默认环境"与"真实环境"合并,真实值覆盖默认值;
  • getEnvWithoutDefaults()则不做默认值合并,直接返回解析结果。

对外仅暴露两个具名导出(getEnvgetEnvWithoutDefaults)。这样一个"小且依赖清晰"的模块,恰好用来观察摇树分析器对 import 语句、函数声明与导出条目的切分方式。

Items:模块被切分成的 9 个最小单元

快照第一部分是# Items,开头给出Count: 9,随后逐条列出其中 7 个"具名单元"。它的关键做法是:一条 import 语句并不会作为一个整体参与分析,而是会被拆成"导入该模块(含副作用)"与"逐一导入每个绑定"两类 Item

Item 1:Stmt 0 的ImportOfModule

import { DEFAULT_ENVIRONMENT, parseEnvironment } from '../../utils/environment';
  • Hoisted
  • Side effects

它是"导入../../utils/environment这个模块"本身。之所以标记Side effects,是因为被导入模块在加载时可能执行顶层副作用代码,而这段 import 不能仅因"没人用它的导出"就被无条件丢弃。Hoisted表示这类导入声明在语义上会被提升到模块最前执行。

Item 2 与 Item 3:Stmt 0 的两个ImportBinding

同一行 import 中每个名字各占一个 Item:

import { DEFAULT_ENVIRONMENT, parseEnvironment } from '../../utils/environment';
  • Item 2:ImportBinding(0)—— Hoisted,Declares:DEFAULT_ENVIRONMENT
  • Item 3:ImportBinding(1)—— Hoisted,Declares:parseEnvironment

ImportBinding(i)中的i是第几个绑定(从 0 起)。它只负责"声明名字",真正的"模块副作用"责任已经交给同语句的ImportOfModule,因此绑定项不再重复标注 Side effects。

Item 4 与 Item 5:Stmt 1 的导入

对第二行 import(./globalThis)同样拆成两个 Item:

import { _globalThis } from './globalThis';
  • Item 4:ImportOfModule—— Hoisted,Side effects
  • Item 5:ImportBinding(0)—— Hoisted,Declares:_globalThis

Item 6 与 Item 7:两个Normal导出函数

export function getEnv() { var globalEnv = parseEnvironment(_globalThis); return Object.assign({}, DEFAULT_ENVIRONMENT, globalEnv); }
  • Hoisted
  • Declares:getEnv
  • Reads (eventual):parseEnvironment,_globalThis,DEFAULT_ENVIRONMENT
  • Write:getEnv
export function getEnvWithoutDefaults() { return parseEnvironment(_globalThis); }
  • Hoisted
  • Declares:getEnvWithoutDefaults
  • Reads (eventual):parseEnvironment,_globalThis
  • Write:getEnvWithoutDefaults

这里最值得注意的属性是Reads (eventual)("最终/延迟读取")。函数声明体里的parseEnvironment_globalThisDEFAULT_ENVIRONMENT只有当函数真正被调用时才会读取,属于"可能发生"的延迟依赖——分析器必须在后续 Phase 中把这些读取关系作为依赖边补充进图里,而不是在函数定义处就当作立即求值。

需要补充的是:虽然Items明细只展开列了 7 项,Count: 9与后面的 Phase 图都表明分析还产生了另外两个条目Item 8(export getEnvItem 9(export getEnvWithoutDefaults。它们对应export出口本身,在图里以Item8["export getEnv"]Item9["export getEnvWithoutDefaults"]形式出现,并在最终阶段与各自的函数体 Item 合并成同一个节点。

从 Phase 1 到 Phase 4:依赖图如何逐步生长并收敛

第二部分展示分析器把 Item 之间的依赖关系分阶段叠加的过程(每个 Phase 都是一张 mermaid 依赖图)。图中同名的节点代表同一个 Item,箭头A --> B表示"A 依赖 B"。

Phase 1:只有默认绑定依赖模块导入

此时图中唯一的边是Item2 --> Item1:声明DEFAULT_ENVIRONMENT的绑定(Item 2)依赖"导入../../utils/environment模块"(Item 1)。注意 Item 3(parseEnvironment)虽然来自同一行 import,却尚未建立边——说明初始阶段并不是按语句整体建边,而是按 Item 粒度逐步推算。

Phase 2:导出条目挂到函数体上

新增两条边:Item8 --> Item6(导出getEnv依赖函数getEnv)与Item9 --> Item7(导出getEnvWithoutDefaults依赖对应函数)。至此"入口导出 → 被导出函数"的骨架已经成形。

Phase 3:函数体的延迟读取被展开

上一节标注的Reads (eventual)在这里全部落地为依赖边:

  • Item6 --> Item4Item6 --> Item5getEnv读取./globalThis的模块导入(Item 4)与其绑定_globalThis(Item 5);
  • Item6 --> Item3getEnv读取parseEnvironment绑定;
  • Item7 --> Item4Item7 --> Item5getEnvWithoutDefaults同样读取_globalThis的模块导入与绑定。

由此可以看到Object.assign({}, DEFAULT_ENVIRONMENT, ...)里的DEFAULT_ENVIRONMENT(Item 2/Item 1)只被getEnv依赖,而getEnvWithoutDefaults与默认环境完全无关——这正是后续"把getEnvgetEnvWithoutDefaults拆进不同 Part"的依据之一。

Phase 4:图收敛(不动点)

Phase 4 与 Phase 3 的边完全一致,说明依赖关系已进入不动点:没有新的依赖需要追加,分析结束。这种"逐轮把可传递依赖补进图中、直至不再变化"的策略,是分析器保证最终划分结果稳定的关键。

Final:按连通分量归并成节点

收敛后的图再按"可达/连通"关系把 Item 归并成若干节点(N0–N6),每个节点将对应一个独立的代码片段:

从节点内容可以看到归并规则:

  • N0 / N1 / N2都源自第 0 行 import:模块导入、DEFAULT_ENVIRONMENT绑定、parseEnvironment绑定被分到三个不同节点;
  • N3 / N4源自第 1 行 import(./globalThis的模块导入与其绑定);
  • N5=ItemId(2, Normal)getEnv函数体)∪Export(("getEnv", #2), "getEnv")——导出项与函数体被合并在同一节点,说明只要getEnv被需要,就能连带整个函数体一起交付;
  • N6同理承载getEnvWithoutDefaults及其导出。

节点间的边(如N5 --> N1N5 --> N4N5 --> N2)则表示生成该片段时仍需从其他节点拉取绑定/模块副作用。最终这张"节点图"就是后续 Part 划分的蓝图。

Entrypoints:谁被当作模块入口

快照随后给出两份完全相同的# Entrypoints区块(一份在模块片段展示前,一份在Modules (dev)之后),内容如下:

{ ModuleEvaluation: 3, Export( "getEnv", ): 5, Export( "getEnvWithoutDefaults", ): 6, Exports: 7, }

它的含义是:该模块有四个"入口点",各自落到对应的节点/Part 编号上:

  • ModuleEvaluation: 3——模块被求值(执行副作用)时的入口在 Part 3;
  • Export("getEnv"): 5——具名导出getEnv的入口在 Part 5;
  • Export("getEnvWithoutDefaults"): 6——具名导出getEnvWithoutDefaults的入口在 Part 6;
  • Exports: 7——汇总所有导出的聚合入口在 Part 7。

也就是说:消费者如果只 importgetEnv,就只需拉取 Part 5 及其依赖;如果只想让模块副作用被执行(例如import 'module'),则只需 Part 3。这正是"按入口切分、按需组合"的划分结果。

Modules(dev):Part 0–7 的代码形态

# Modules (dev)展示开发模式下划分出的 8 个 Part。它们是供读者理解划分结果的伪代码/中间形态,其中模块内部跨 Part 的引用统一使用带__turbopack_part__属性(import assertion)的伪模块标识__TURBOPACK_PART__来表示。

Part 0——环境模块的副作用导入

import '../../utils/environment';

它对应节点 N0:单独保留"加载../../utils/environment会产生副作用"这件事,供需要副作用的其他 Part 复用。

Part 1 与 Part 2——绑定声明的占位片段

import "__TURBOPACK_PART__" assert { __turbopack_part__: 0 };

Part 1 与 Part 2 内容相同,都是"再导入 Part 0"。结合 Final 图中 N1、N2 都指向 N0 可知:声明DEFAULT_ENVIRONMENTparseEnvironment这两个绑定时,需要先确保承载模块副作用的 Part 0 被执行。

Part 3——模块求值片段(Merged module eval)

import "__TURBOPACK_PART__" assert { __turbopack_part__: 0 }; import './globalThis'; export { };

Part 3 是ModuleEvaluation入口对应的代码:先拉取 Part 0(执行环境模块副作用),再真正import './globalThis',并以export { }保持模块语义。后面的Merged (module eval)区块与它完全一致,表明合并后真正用于模块求值的片段就是 Part 3 本身。

Part 4——导入求值片段

import "__TURBOPACK_PART__" assert { __turbopack_part__: 3 };

Part 4 只是"导入 Part 3",对应节点关系 N4 --> N3(./globalThis绑定需要其模块副作用)。它是一个薄转发层,确保引用方只要依赖 Part 4 就会连带触发 Part 3 的模块求值。

Part 5——getEnv的完整实现

import "__TURBOPACK_PART__" assert { __turbopack_part__: 0 }; import { DEFAULT_ENVIRONMENT } from '../../utils/environment'; import "__TURBOPACK_PART__" assert { __turbopack_part__: 0 }; import { parseEnvironment } from '../../utils/environment'; import "__TURBOPACK_PART__" assert { __turbopack_part__: 3 }; import { _globalThis } from './globalThis'; function getEnv() { var globalEnv = parseEnvironment(_globalThis); return Object.assign({}, DEFAULT_ENVIRONMENT, globalEnv); } export { getEnv }; export { getEnv as a } from "__TURBOPACK_VAR__" assert { __turbopack_var__: true };

对应节点 N5(函数体getEnv+ 其导出项)。可以观察到三个关键点:

  1. 该片段完整保留了对DEFAULT_ENVIRONMENTparseEnvironment_globalThis的真实 ESM 导入,并在每个导入前后插入对应的__TURBOPACK_PART__转发导入,保证"绑定 → 模块副作用"的顺序;
  2. 函数体代码与输入完全一致,未做改写;
  3. 末尾除了export { getEnv },还多出一行export { getEnv as a } from "__TURBOPACK_VAR__"——其中的__turbopack_var__: true与别名a用于把导出注册到内部的"导出变量"机制上,使下游可以按名字引用该导出(这是一种内部簿记性质的导出,而非给用户使用的名字)。

Part 6——getEnvWithoutDefaults的完整实现

import "__TURBOPACK_PART__" assert { __turbopack_part__: 3 }; import { _globalThis } from './globalThis'; import "__TURBOPACK_PART__" assert { __turbopack_part__: 0 }; import { parseEnvironment } from '../../utils/environment'; function getEnvWithoutDefaults() { return parseEnvironment(_globalThis); } export { getEnvWithoutDefaults }; export { getEnvWithoutDefaults as b } from "__TURBOPACK_VAR__" assert { __turbopack_var__: true };

对应节点 N6。注意它只导入了parseEnvironment_globalThis,没有任何DEFAULT_ENVIRONMENT相关代码——与分析阶段 Read 信息、Final 节点依赖完全对应,默认环境对象不会被打进只使用getEnvWithoutDefaults的产物里。

Part 7——导出聚合器

export { getEnv } from "__TURBOPACK_PART__" assert { __turbopack_part__: "export getEnv" }; export { getEnvWithoutDefaults } from "__TURBOPACK_PART__" assert { __turbopack_part__: "export getEnvWithoutDefaults" };

Part 7 对应 Entrypoints 中的Exports: 7。它不包含任何实现,只把两个具名导出从对应的 Part(按"export getEnv""export getEnvWithoutDefaults"字符串寻址)重新导出,作为该模块的统一对外出口。

Modules(prod):开发与生产的分析结果在这里收敛

# Modules (prod)在快照中紧接着出现,其 Part 0–Part 7 以及Merged (module eval)与 dev 版本逐字完全相同。也就是说:对于 otel-core 这个输入,无论 dev 还是 prod,分析器得出的模块划分、依赖顺序与代码片段都没有差别。出现这种情况是合理的——这份快照反映的是"代码切分"层面的分析结果,真正的差异(如变量名压缩、死代码删除的强度、热更新相关包装)要等后续的代码生成/优化阶段才会体现。快照同时保留 dev/prod 两份输出,正是为了在分析层单独校验两种模式在此阶段的一致性。

在仓库中继续深挖的线索

如果你希望从源码层面验证上面这些机制,可以顺着以下路径继续阅读:

  • 用例输入与基线输出:otel-core/input.js、otel-core/output.md,以及同目录的几十个兄弟用例(如dcenanoidsimplenode-fetch)用于观察不同语法形态下的分析差异;
  • 依赖图构建与"延迟读取/副作用"分析核心位于 turbopack-ecmascript/src/analyzer(如 graph/mod.rs、imports.rs、side_effects.rs)——它们负责把 import 拆分、收集绑定并把副作用归类;
  • ESM 引用与模块条目(module item)处理可参考 references/esm/module_item.rs;
  • 最终把 Items 划分为 Part、输出__TURBOPACK_PART__形式片段的"模块碎片"机制位于 module_fragments 目录(graph.rs 中即可找到对ImportOfModule等 ItemId 的引用),可结合 graph.rs 与 merge.rs 理解 dev/prod 两条划分路径的合并与比较逻辑。

小结:如何快速读懂一张 tree-shaker 快照

把 otel-core 这个用例读完整后,可以总结出阅读同类output.md的四步法:

  1. 看 Items:数出Count,识别每条 import 被拆出的ImportOfModule(副作用载体)与逐个ImportBinding,以及Normal语句携带的Declares / Reads (eventual) / Write属性——它们预告了后续所有依赖边的来源;
  2. 看 Phase:逐张对比 mermaid 图的边,直到某两个相邻 Phase 完全一致(不动点),即依赖收集结束;再看 Final 图把 Item 归并成哪些节点、导出项与函数体是否同节点;
  3. 看 Entrypoints:把ModuleEvaluation / Export(...) / Exports的编号与 Final/Part 编号对上,理解"求值入口、单导出入口、聚合入口"各在哪个片段;
  4. 看 Modules(dev / prod):核对每个 Part 保留了哪些真实 import、通过__TURBOPACK_PART__转发了哪些 Part、末尾用__TURBOPACK_VAR__注册了哪些别名导出,再对比 dev 与 prod 是否一致。

掌握这四步,tests/tree-shaker/analyzer/下任何一张.md快照都能反过来告诉你:对于给定的真实模块,Turbopack 会怎样裁剪它的依赖、把哪些代码捆在一起、又把哪些代码彻底留在产物之外。

【免费下载链接】next.jsThe React Framework项目地址: https://gitcode.com/GitHub_Trending/next/next.js

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询