【翻译】Rolldown 工作原理解析:符号关联、CJS/ESM 模块解析与导出分析

原文链接:www.atriiy.dev/blog/rolldo...

作者:Atriiy

引言

Rolldown 是一款基于 Rust 开发的高性能 JavaScript 打包工具。它在完全兼容 Rollup API 的前提下,实现了 10 至 30 倍的打包速度提升。出于对开发与生产环境统一引擎的需求,Vite 团队正将 Rolldown 打造为当前基于 esbuild + Rollup 的打包架构的继任者。

在现代前端项目中,成百上千的模块构成了复杂的依赖图谱。打包工具的理解不能仅停留在 "文件级导入" 层面:它必须深入分析,判断 featureA.js 中导入的 useState 是否与 featureB.js 中的 useState 为同一个实体。这一关键的解析过程被称为链接(linking)

链接阶段(link stage)正是为解决这一问题而生:它处理那些会在模块图谱中传播的宏观属性(例如顶层 await 的 "传染性");搭建不同模块系统(CJS/ESM)之间的通信桥梁;最终将每一个导入的符号追溯到其唯一的原始定义。

为揭开这一过程的神秘面纱,我们将通过三级心智模型拆解链接阶段的内部机制,从宏观到微观逐步剖析其工作原理。

三级心智模型

扫描阶段输出的是一份基础的模块依赖图谱,但仅停留在文件级别。而链接阶段会通过一系列数据结构和算法细化这份图谱,最终生成精准的「符号级依赖映射」。

  • 基础与固有属性 。扫描阶段生成的初始图谱存储在 ModuleTable 中,记录了所有模块的依赖关系。链接阶段会对该图谱执行深度优先遍历,计算并传播诸如顶层 await(TLA)这类具有 "传染性" 的属性。这些属性可能通过间接依赖影响整个模块链,因此这一分析是代码生成阶段的关键前提。

  • 标准化与模块通信协议。JavaScript 对多模块系统(主要是 CommonJS 即 CJS、ES 模块即 ESM)的支持带来了复杂度。在核心链接逻辑执行前,必须先规范化这些不同的模块格式,处理命名空间对象、垫片化导出(shimmed exports)等细节。这种标准化构建了统一的处理环境,让符号链接算法能专注于核心的解析逻辑,而非大量边缘情况。

  • 万物互联:符号谱系 。在最细粒度层面,该阶段会将符号与其对应的导入、导出语句建立关联。它借助并查集(Disjoint Set Union,DSU) 数据结构高效建立跨模块的等价关系,确保每个符号都能解析到唯一、无歧义的原始定义。

示例项目

为了梳理链接阶段复杂的数据结构与算法逻辑,我们将以一个具体的示例项目展开讲解。这种方式能让底层逻辑变得更具象、更易理解。该项目是特意设计的,旨在展现链接阶段必须处理的多个关键场景:

  • CJS 与 ESM 混合使用
  • 顶层 await(TLA)
  • 具名导出与星号重导出
  • 潜在歧义符号
  • 外部依赖
  • 副作用

完整源码可在这份 GitHub Gist 中查看,项目的文件结构如下:

ini 复制代码
📁 .
├── api.js                # (1) Fetches data, uses Top-Level Await (ESM)
├── helpers.js            # (2) Re-exports modules, creating linking complexity (ESM)
├── legacy-formatter.cjs  # (3) An old formatting utility (CJS)
├── main.js               # (4) The application entry point (ESM)
└── polyfill.js           # (5) A simulated polyfill to demonstrate side effects (ESM)

若你想自行运行这个示例,需将这些文件放置到 Rolldown 代码仓库的 crates/rolldown/examples/basic 目录下。随后,修改 basic.rs 文件,把 main.js 配置为入口点:

Rust 复制代码
// ...
input: Some(vec![
  "./main.js".to_string().into(),
]),

完成调试环境配置后,建议你结合断点运行这款打包工具。通过单步执行代码、实时查看数据结构的方式,能让你更深入地理解整个处理流程。

基础与固有属性

扫描阶段会生成一份基础的模块依赖图谱。在这张有向图 中,节点代表单个模块,边表示模块间的导入关系;为了实现高效遍历,该结构通常基于邻接表实现。链接阶段基础环节的核心任务,是遍历这张图谱并计算那些会沿导入链传播的宏观属性 ------ 例如顶层 await(TLA)的 "传染性"。想要理解这些算法的工作原理,扎实掌握核心模块的数据结构是必不可少的前提。

图谱设计

图谱设计的核心是 ModuleIdx(模块索引)------ 一种用于指向特定模块的类型化索引 。模块分为两类:NormalModuleExternalModule,不过我们的分析将主要聚焦前者。每个NormalModule都会封装其 ECMAScript 解析结果,其中最关键的是 import_records 字段(该字段会列出模块中所有的导入语句)。以下类图展示了这一数据结构的设计思路。

classDiagram note for ModuleIdx "Typed Index of Module" class ModuleIdx { TypeAlias for u32 } note for ModuleTable "Global Module Table" class ModuleTable { +modules: IndexVec< ModuleIdx, Module > } ModuleIdx -- ModuleTable : used_as_index_for class Module { <> +Normal: Box~NormalModule~ +External: Box~ExternalModule~ +idx() : ModuleIdx +import_records() : IndexVec< ImportRecordIdx, ResolvedImportRecord > } ModuleTable "1" o-- "0..*" Module : contains class ExternalModule { +idx: ModuleIdx +import_records: IndexVec<...> } Module -- ExternalModule : can_be_a class NormalModule { +idx: ModuleIdx +ecma_view: EcmaView } Module -- NormalModule : can_be_a class ImportRecordIdx { TypeAlias for u32 } class EcmaView { import_records: IndexVec< ImportRecordIdx, ResolvedImportRecord > } NormalModule "1" *-- "1" EcmaView : own ImportRecordIdx -- EcmaView : used_as_index_for class ImportRecord~State~ { +kind: ImportKind } EcmaView "1" o-- "0..*" ImportRecord~State~ : contains class ImportRecordStateResolved { +resolved_module: ModuleIdx } ImportRecord~State~ *-- ImportRecordStateResolved : holds

遍历依赖图时,需要逐一迭代处理每个模块的依赖项。这些依赖项可通过 import_records 字段访问。为了简化这一高频操作,Module 枚举类型专门实现了一个便捷的 import_records() 访问器方法。这一设计选择简化了依赖图的遍历流程,Rolldown 源码中下述常见代码模式即可印证这一点:

Rust 复制代码
module_table.modules[module_idx]
	.import_records()
	.iter()
	.map(|rec| {
	  // do something...
	})

核心数据结构:LinkingMetadata

链接阶段(link stage)的最终输出封装在 LinkStageOutput 结构体中。其定义如下:

Rust 复制代码
pub struct LinkStageOutput {
  pub module_table: ModuleTable,
  pub metas: LinkingMetadataVec,
  pub symbol_db: SymbolRefDb,
  // ...
}

总结这些字段的作用:ModuleTable 是扫描阶段(scan stage)的主要输入,而 LinkingMetadataVecSymbolRefDb 是核心输出 ------ 前者存储在模块层面新计算出的信息,后者则存储符号层面的相关信息。这三个结构体共同从宏观到微观的维度,完整、多层级地描述了模块间的依赖关系。

ModuleTable 类似,LinkingMetadataVec 是一个带索引的向量(indexed vector),可通过 ModuleIdx 访问每个具体模块的元数据。而绝大多数这类新增的模块层面信息,均记录在 LinkingMetadata 结构体内部。

classDiagram note for ModuleIdx "Typed Index of Module" class ModuleIdx { TypeAlias for u32 } class LinkStage { +metas: IndexVec< ModuleIdx, LinkingMetadata > } ModuleIdx -- LinkStage : used_as_index_for class LinkingMetadata { +wrap_kind: WrapKind +resolved_exports: FxHashMap +has_dynamic_exports: bool +is_tla_or_contains_tla_dependency: bool } LinkStage "1" o-- "0..*" LinkingMetadata : contains

顶层 await(TLA)的处理逻辑

顶层 await(Top-level await,简称 TLA),顾名思义,允许开发者在 ES 模块的顶层作用域中使用 await 关键字,无需将异步代码包裹在 async 函数中。该特性极大简化了异步初始化任务的编写,相比传统的立即调用函数表达式(Immediately Invoked Function Expression,IIFE)模式,代码逻辑更清晰。我们示例项目中的 api.js 文件就体现了这一用法:

Rust 复制代码
console.log('API module evaluation starts.')
// Use top-level await to make the entire dependency chain async
const response = await fetch('https://api.example.com/items/1')
const item = await response.json()

// Export a processed data
export const fetchedItem = { id: item.id, value: item.value * 100 }

// Export a normal variable, we will use it to create confusion
export const source = 'API'

console.log('API module evaluation finished.')

TLA 的一个核心特性是其 "传染性":若某个模块使用了 TLA,所有直接或间接导入该模块的其他模块都会受到影响,且必须被当作异步模块处理。每个模块的 LinkingMetadata 中都包含一个 is_tla_or_contains_tla_dependency 标记,用于追踪这一状态。在我们的示例中,main.js 依赖 helpers.js,而 helpers.js 又依赖使用了 TLA 的 api.js;因此 Rolldown 会对依赖图执行深度优先遍历,并将这三个模块的该标记均设为 true

该标记的计算逻辑完全贴合 TLA 的行为特征:Rolldown 采用递归的深度优先搜索(DFS)算法,并通过哈希表做记忆化处理(memoization),避免对同一模块重复计算。算法核心通过检查两个条件,判断模块是否受 TLA 影响:

  • 模块自身是否包含顶层 await?(这一信息来自扫描阶段计算得到的 ast_usage 字段)
  • 模块是否导入了任何受 TLA 影响的其他模块?

这种递归检查确保了 TLA 的 "传染性" 能从源头沿整个依赖链向上正确传播。

副作用的判定逻辑

简单来说,若一个模块在被导入时,除了导出变量、函数或类之外还执行了其他操作,则称该模块具有 "副作用"。具体而言,它会执行影响全局环境或修改自身作用域之外对象的代码。Polyfill(兼容性补丁)是典型应用场景 ------ 这类代码通常会扩展全局对象,以使老旧浏览器能够支持新的 API。

我们示例项目中的 polyfill.js 就是绝佳例证:尽管 main.js 仅通过 import './polyfill.js' 导入该模块,并未引用任何符号,但由于它修改了全局的 globalThis 对象,该模块仍被判定为具有副作用。因此 Rolldown 必须确保该模块的代码被包含在最终的打包产物中。

由于打包工具无法通过编程方式判断副作用是否 "有益",只能保守地保留所有被标记为含副作用的模块。识别这类模块的流程与计算 TLA 类似:该属性同样具有传染性,若一个模块存在副作用,所有直接 / 间接导入它的模块都会被认定为受影响。其底层算法也基本一致:通过递归的深度优先搜索(DFS)检查每个 Module 上的 side_effects() 信息,并借助记忆化处理避免重复检查。

至此,我们的模块图已标注了顶层 await、副作用等宏观属性,但这仍不够。在精准链接符号之前,我们必须先解决一个问题:模块可能使用不同的模块系统(如 CJS 和 ESM),它们需要一套统一的交互协议 ------ 这正是我们下一步要实现的目标。

标准化处理与模块交互协议

尽管扫描阶段已梳理出模块依赖图,但该阶段仅捕获了 "文件级" 的依赖关系。这份原始依赖图并未考虑一个关键问题:模块可能采用不同的模块系统(如 CommonJS(CJS)和 ES 模块(ESM)),而这些系统本身并不具备互操作性。为解决这一问题,在执行更深层的链接逻辑前,必须先完成标准化处理流程。

该标准化处理在链接阶段执行:Rolldown 遍历模块依赖图,为每个模块计算所需的标准化信息,并将其记录到对应的 LinkingMetadata 结构体中。正如我们此前所述,该结构体存储在 LinkStageOutputmetas 字段内。

模块系统与包装器(Wrapper)

现代 JavaScript 生态中主流的模块系统有两种:CommonJS(CJS)和 ES 模块(ESM)。

  • CommonJS(CJS) :主要应用于 Node.js 生态,是一种同步模块系统。依赖通过阻塞式的 require() 调用加载,模块则通过向 module.exportsexports 对象赋值的方式暴露自身 API。
  • ES 模块(ESM) :ECMAScript 推出的官方标准,同时适配浏览器和 Node.js 环境。其静态结构(使用 importexport 语句)专为编译期分析设计,而浏览器端的加载机制本身是异步、非阻塞的。

这两种系统常出现在同一代码库中 ------ 尤其是现代基于 ESM 的项目依赖仅提供 CJS 分发包的老旧第三方库时。为处理这种混合使用场景,Rolldown 会判定模块是否需要 "包装器(Wrapper)"。尽管具体算法将在后续详述,但其核心思路十分简单:包装器是一个函数闭包,能够模拟特定的模块运行环境,从而让不兼容的模块系统实现交互。

以下简化示例阐释了这一核心思想:

Rust 复制代码
// Code storage
const __modules = {
  './utils.js': exports => {
    exports.add = (a, b) => a + b
  },
  './data.js': (exports, module) => {
    module.exports = { value: 42 }
  },
}

// Runtime
function __require(moduleId) {
  const module = { exports: {} }
  __modules[moduleId](module.exports, module)

  return module.exports
}

尽管实际生成的代码更为复杂,但其核心原理始终不变:将模块代码包裹在函数中,以在运行时为其提供所需的执行环境。我们示例项目中的 legacy-formatter.cjs 正是如此 ------Rolldown 检测到这个 CJS 文件被一个 ESM 模块(helpers.js)导入,因此会对其进行对应的包装处理(使用 WrapKind::Cjs 类型的包装器)。该包装器模拟了 module.exports 执行环境,确保不同模块系统间实现无缝互操作。你可以查看 basic/dist 目录下的打包产物,直观看到这一处理逻辑的实际效果。

模块类型判定与包装器选择

为确保 CJS 与 ESM 之间的无缝互操作,Rolldown 必须为每个模块选择合适的包装器。这一决策不仅取决于模块自身的格式,还与其他模块导入该模块的方式相关。

首先,在扫描阶段,Rolldown 会根据模块的语法特征识别出每个模块的 ExportsKind(导出类型),并将其存储在该模块的 EcmaView 中:

Rust 复制代码
pub struct EcmaView {
  pub exports_kind: ExportsKind,
  // ...
}

接下来,Rolldown 会考量导入方模块所使用的 ImportKind(导入类型)。该枚举类型涵盖了 JavaScript 中引用其他文件的所有方式:

Rust 复制代码
pub enum ImportKind {
  /// import foo from 'foo'
  Import,
  /// `import('foo')`
  DynamicImport,
  /// `require('foo')`
  Require,
  // ... (other kinds like AtImport, UrlImport, etc.)
}

pub enum ExportsKind {
  Esm,
  CommonJs,
  None,
}

核心逻辑在于导入方的 ImportKind(导入类型)与被导入方的 ExportsKind(导出类型)的组合判定 。二者的匹配关系决定了被导入方所需的 WrapKind(包装器类型)。例如,当一个模块通过 require() 方式加载(对应 ImportKind::Require 类型)时,其 WrapKind 由自身的 ExportsKind 决定。这一逻辑确保了被导入模块在运行时能获得适配的执行环境。

Rust 复制代码
// ...
ImportKind::Require => match importee.exports_kind {
  ExportsKind::Esm => {
    self.metas[importee.idx].wrap_kind = WrapKind::Esm;
  }
  ExportsKind::CommonJs => {
    self.metas[importee.idx].wrap_kind = WrapKind::Cjs;
  }
}
// ...

递归应用包装器

在确定了不同交互场景下所需的 WrapKind(包装器类型)后,wrap_modules 函数会遍历模块依赖图,应用这些包装器并处理相关的复杂逻辑。

其中一个核心难点是 CommonJS 模块中的 "星号导出"(export * from './dep')。由于 CJS 模块的完整导出列表无法在编译期确定,这类导出会被视为动态导出,需要特殊处理。

此外,包装过程本身是递归的:当一个模块需要包装器时(例如被 ESM 模块导入的 CJS 模块),仅包装该单个模块是不够的 ------ 包装器可能引入新的异步行为。因此 Rolldown 必须递归向上遍历整个导入链,确保所有依赖这个新包装模块的模块都被正确处理。这种递归传播机制能保证所有依赖在运行时就绪,并维持正确的执行顺序。

整合所有环节:符号溯源

在完成依赖图基础属性的标注、模块格式的标准化后,我们终于来到链接阶段的核心任务:处理导入的符号。最终目标是将每个符号追溯到其唯一、明确的原始定义。

以示例项目中的场景为例:main.jshelpers.js 导入名为 source 的符号,而 helpers.js 又会将 api.js 中的所有内容重新导出。打包工具如何确认 main.js 中使用的 source,就是 api.js 中定义的那个完全相同的变量?

这本质上是一个 "等价性判定" 问题。为高效解决该问题,Rolldown 采用了并查集(Disjoint Set Union,DSU) 数据结构 ------ 这是一种专为这类等价性问题设计的算法。在该模型中,每个符号引用都被视为一个元素,核心目标是将所有指向同一原始定义的引用归并到同一个集合中。

并查集(DSU)

并查集(Disjoint Set Union,DSU)也被称为 "联合 - 查找(union-find)" 数据结构,是一种高效的数据结构:每个集合以树的形式表示,树的根节点作为该集合的 "标准代表元"。并查集主要支持两种操作:

  • 查找(Find) :确定某个元素所属集合的标准代表元;
  • 合并(Union) :将两个不相交的集合合并为一个集合。

经典的并查集实现会使用一个简单的数组(我们称之为 parent),其中 parent[i] 存储元素 i 的父节点。若 parent[i] == i,则 i 是其所在树的根节点。以下伪代码展示了一个未做优化的基础实现:

python 复制代码
parent = []

def find(x):
	return x if parent[x] == x else find(parent[x])

def union(x, y):
	root_x = find(x)
	root_y = find(y)
	if root_x != root_y:
		# Link the root of x's tree to the root of y's tree
		parent[root_x] = root_y

Rolldown 沿用了这一核心思路,但实现方式更健壮且具备类型安全性。它并未使用原生数组,而是采用 IndexVec------ 一种类向量结构,通过 SymbolId 这类带类型的 ID 进行索引。父指针的作用则由 SymbolRefDataClassic 结构体中的 link 字段来实现,如下图所示。

classDiagram note for ModuleIdx "Typed Index of Module" class ModuleIdx { TypeAlias for u32 } class SymbolRefDb { +inner: IndexVec< ModuleIdx, Option~SymbolRefDbForModule~> link(&mut self, base: SymbolRef, target: SymbolRef) find_mut(&mut self, target: SymbolRef) : SymbolRef } ModuleIdx -- SymbolRefDb : used_as_index_for class SymbolRefDbForModule { owner_idx: ModuleIdx root_scope_id: ScopeId +ast_scopes: AstScopes +flags: FxHashMap< SymbolId, SymbolRefFlags> +classic_data: IndexVec< SymbolId, SymbolRefDataClassic> create_facade_root_symbol_ref(&mut self, name: &str) : SymbolRef get_classic_data(&self, symbol_id: SymbolId) : &SymbolRefDataClassic } SymbolRefDb "1" o-- "0..*" SymbolRefDbForModule : contains class SymbolRefDataClassic { +namespace_alias: Option~NamespaceAlias~ +link: Option~SymbolRef~ +chunk_id: Option~ChunkIdx~ } SymbolRefDbForModule "1" o-- "0..*" SymbolRefDataClassic : contains

如图所示,每个符号的等价性信息都存储在 SymbolRefDataClassic 结构体中。可选的 link 字段指向其父符号 ------ 这一点与经典并查集实现中的 parent 数组完全对应。

Rolldown 将并查集的两个核心操作实现为 find_mutlink 方法。

find_mut 方法(带路径压缩的查找操作)

Rolldown 的 find_mut 方法不仅能找到根节点,还会执行一项关键优化:路径压缩(path compression)

Rust 复制代码
pub fn find_mut(&mut self, target: SymbolRef) -> SymbolRef {
  let mut canonical = target;
  while let Some(parent) = self.get_mut(canonical).link {
    // Path compression: Point the current node to its grandparent
    self.get_mut(canonical).link = self.get_mut(parent).link;
    canonical = parent;
  }
  canonical
}

当 while 循环沿着树结构向上遍历至根节点(即 link 字段为 None 的元素)时,会将遍历过程中访问到的每个节点重新关联 ,使其直接指向自身的祖父节点(self.get_mut(parent).link)。这一操作能高效地 "扁平化" 树结构,大幅提升后续对该路径上任意节点执行查找(find)操作的速度。最终返回的 "标准符号" 即为该集合的根节点代表元。

link 方法实现了并查集的合并(union)操作。

Rust 复制代码
/// Make `base` point to `target`
pub fn link(&mut self, base: SymbolRef, target: SymbolRef) {
  let base_root = self.find_mut(base);
  let target_root = self.find_mut(target);
  if base_root == target_root {
    // Already linked
    return;
  }
  self.get_mut(base_root).link = Some(target_root);
}

该方法首先找到基准符号(base)目标符号(target) 各自的根节点代表元。若二者根节点相同,说明这些符号已属于同一集合,无需执行任何操作;反之,则通过将基准符号根节点的 link 字段指向目标符号的根节点,完成两个集合的合并。

绑定导入与导出(Bind imports and exports)

符号解析流程始于 bind_imports_and_exports 函数。初始步骤是遍历所有模块,提取其中的显式命名导出 ;这些导出信息会被存储在一个哈希映射(hash map)中 ------ 键为导出的字符串名称,值则是 ResolvedExport 结构体实例。

Rust 复制代码
pub struct ResolvedExport {
  pub symbol_ref: SymbolRef,
  pub potentially_ambiguous_symbol_refs: Option<Vec<SymbolRef>>,
}

但这一流程会因 ES 模块的星号导出(export * from './dep' 变得复杂 ------ 该语法会将另一个模块的所有命名导出重新导出。我们示例中的 helpers.js 就使用了这一语法:export * from './api.js'

星号导出可能引入歧义,必须在最终链接前解决。因此,对于任何包含星号导出的模块,Rolldown 都会调用一个专用函数 add_exports_for_export_star。该函数通过递归深度优先搜索(DFS) 遍历星号导出的依赖图;为检测循环依赖并管理导出优先级,它采用经典的回溯模式维护一个 module_stack(模块栈):递归调用前将模块 ID 压入栈中,递归返回后再将其弹出。

这一递归遍历主要承担两项核心职责:

  • 遮蔽(Shadowing) :模块内的显式命名导出始终拥有最高优先级,会 "遮蔽" 所有通过星号导出从深层依赖导入的同名导出。module_stack 可根据导入链中的 "就近原则" 判定这种优先级关系。
  • 歧义检测 :当一个模块试图从多个 "优先级相同" 的不同来源导出同名符号时(例如通过两个不同的星号导出:export * from 'a'export * from 'b'),就会产生歧义。若一个新引入的星号导出符号与已存在的符号同名、且未被遮蔽,则会被记录到 potentially_ambiguous_symbol_refs 字段中,留待后续解析。

在整个过程中,该函数会操作一个由调用方传入的、可变的 resolve_exports 哈希表(FxHashMap 类型),逐步构建出该模块完整的已解析导出集合。

匹配导入与导出(Match imports with exports)

完成所有模块导出的解析后,下一步是将每个导入项匹配到对应的导出项。这一完整流程由封装在 BindImportsAndExportsContext 中的数据和结构体统一管理。

Rust 复制代码
struct BindImportsAndExportsContext<'a> {
  pub index_modules: &'a IndexModules,
  pub metas: &'a mut LinkingMetadataVec,
  pub symbol_db: &'a mut SymbolRefDb,
  pub external_import_binding_merger:
    FxHashMap<ModuleIdx, FxHashMap<CompactStr, IndexSet<SymbolRef>>>,
  // ... fields omitted for brevity
}

这一环节的最终目标是填充 symbol_db(符号数据库)------ 借助并查集(DSU)逻辑,将每个导入符号关联到其真正的定义源头。具体流程为:遍历所有 NormalModule(普通模块),并对模块中的每一个命名导入项(每个导入项由 NamedImport 结构体表示,例如 import { foo } from 'foo' 这类语法)执行匹配函数。

但在关联内部符号之前,外部导入项会先经过一套特殊的预处理流程。当某个导入项来自外部模块(例如 import react from 'react' 中的 react)时,并不会立即解析该导入,而是将其收集起来,并归类到 external_import_binding_merger(外部导入绑定合并器)中。

该数据结构是一个嵌套哈希映射,其设计目的是聚合所有 "引用同一外部模块中同名导出" 的导入项。

classDiagram class ExternalImportBindingMerger { +FxHashMapᐸModuleIdx, ModuleExportsᐳ } class ModuleExports { +FxHashMapᐸCompactStr, SymbolSetᐳ } ExternalImportBindingMerger o-- ModuleExports : uses as value class ModuleIdx ExternalImportBindingMerger o-- ModuleIdx : uses as key class SymbolSet { +IndexSetᐸSymbolRefᐳ } ModuleExports o-- SymbolSet : uses as value class CompactStr ModuleExports o-- CompactStr : uses as key class SymbolRef SymbolSet "1" o-- "0..*" SymbolRef : contains

我们以示例项目中的 main.js 文件为例来具体说明:

JavaScript 复制代码
// ...
// (2) Import from external dependencies, this will be handled by external_import_binding_merger
import { useState } from 'react'

// ...

由于 react 是外部模块,Rolldown 会更新 external_import_binding_merger(外部导入绑定合并器)。假设 react 对应的模块索引(ModuleIdx)为 react_module_idx,最终生成的数据结构如下所示:

graph TD param1["external_import_binding_merger\n (FxHashMap)"] param2["FxHashMapᐸCompactStr,\n IndexSetᐸSymbolRefᐳᐳ"] param3["IndexSetᐸSymbolRefᐳ:\n {sym_useState_main}"] param1 -->|key: react_module_idx| param2 param2 -->|"key: 'useState' (CompactStr)"| param3

若另有一个文件(例如 featureB.js)也从 react 导入 useState,则其对应的 SymbolRef(符号引用)会被添加到同一个 IndexSet 集合 中。这也是该结构被恰如其分地命名为 "合并器(merger)" 的原因:它将指向同一个外部符号(react.useState)的所有本地引用汇总到一处。这种聚合方式支持后续的统一处理,确保所有对 useState 的引用最终都指向唯一、统一的外部符号。

遍历完所有模块及其导入项后,Rolldown 会迭代这个已完全填充的合并器映射表(merger map),完成所有外部符号的绑定操作。

追溯导入项的定义源头

符号解析的核心执行函数是递归函数 match_import_with_export。该函数的使命是:根据 ImportTracker(导入追踪器)描述的单个导入项,一路追溯到其原始定义。

Rust 复制代码
struct ImportTracker {
  pub importer: ModuleIdx,      // The module performing the import.
  pub importee: ModuleIdx,      // The module being imported from.
  pub imported: Specifier,      // The name of the imported symbol (e.g., "useState").
  pub imported_as: SymbolRef,   // The local SymbolRef for the import in the importer module.
}

该函数的返回值 MatchImportKind(导入匹配类型)会封装本次追溯的结果。整个解析流程可拆解为三个阶段:

阶段 1:循环检测与初始状态判定

该函数采用带循环检测的递归深度优先搜索(DFS) 实现。MatchingContext(匹配上下文)会维护一个 "追踪器栈(tracker stack)",用于检测同一导入方模块是否试图解析 "正在处理中的、同名的 imported_as 符号引用"。若检测到这种情况,则无需继续执行,直接返回 MatchImportKind::Cycle(循环)即可。

接下来,一个辅助函数 advance_import_tracker 会对直接被导入方(direct importee) 执行快速的非递归分析,检查简单场景并返回初始状态:

  • 若被导入方是外部模块,返回 ImportStatus::External(外部模块);
  • 若被导入方是 CommonJS 模块,返回 ImportStatus::CommonJS(CJS 模块);
  • 若该导入是星号导入(import * as ns),判定为 ImportStatus::Found(已找到);
  • 对于 ES 模块的命名导入,会检查直接被导入方的 "已解析导出集合":若找到匹配的导出项,返回 ImportStatus::Found;否则返回 ImportStatus::NoMatch(无匹配)或 ImportStatus::DynamicFallback(动态降级)。

阶段 2:重新导出链遍历

真正的复杂度在于重新导出链(re-export chain) 的遍历。当返回 ImportStatus::Found 时,函数会进一步检查:找到的这个符号本身是否是从另一个模块导入的:

Rust 复制代码
let owner = &index_modules[symbol.owner];
if let Some(another_named_import) = owner.as_normal().unwrap().named_imports.get(&symbol) {
  // This symbol is re-exported from another module
  // Update tracker and continue the loop to follow the chain
  tracker.importee = importee.idx;
  tracker.importer = owner.idx();
  tracker.imported = another_named_import.imported.clone();
  tracker.imported_as = another_named_import.imported_as;
  reexports.push(another_named_import.imported_as);
  continue;
}

这一过程会以迭代方式持续进行,同时构建用于副作用依赖追踪的重新导出链(reexports chain),直至追溯到符号的原始定义为止。

阶段 3:歧义消解与后置处理

在阶段 2 中,若某个导出项包含 potentially_ambiguous_export_star_refs(由 export * 语句导致的潜在歧义星号导出引用),函数会递归解析每一条歧义路径。收集到所有 ambiguous_results(歧义结果)后,函数会将其与主结果对比:若存在任何不一致,便返回 MatchImportKind::Ambiguous(存在歧义)。

而针对 NoMatch(无匹配)的结果,函数会检查垫片(shimming)功能是否启用 (对应配置项 options.shim_missing_exports 或空模块场景)。垫片可为遗留代码提供兼容性降级方案:

Rust 复制代码
let shimmed_symbol_ref = self.metas[tracker.importee]
  .shimmed_missing_exports
  .entry(imported.clone())
  .or_insert_with(|| {
    self.symbol_db.create_facade_root_symbol_ref(tracker.importee, imported.as_str())
  });

完成绑定操作(Finalizing bindings)

在针对所有内部导入项的核心匹配逻辑执行完毕后,Rolldown 会执行两项最终的批量处理步骤。

1. 合并外部导入项(Merging external imports)

如前文所述,所有来自外部模块的导入项会先被收集到 external_import_binding_merger(外部导入绑定合并器)中。现在,Rolldown 会处理这个映射表:对于每个外部模块及其命名导出(例如 react 中的 useState),Rolldown 会创建一个单一的门面符号(facade symbol) ;随后遍历所有导入了 useState 的本地符号集合(来自 featureA.jsfeatureB.js 等文件),并通过并查集(DSU)的 link 操作将这些本地符号全部合并,使其均指向这个唯一的门面符号。这一操作确保了对同一外部实体的所有导入项都被视为一个整体。

2. 处理歧义导出(Addressing ambiguous exports)

星号导出可能导致真正的歧义。请看以下场景:

JavaScript 复制代码
// moduleA.js
export const foo = 1;

// moduleB.js
export const foo = 2;

// main.js
export * from './moduleA'; // Exports a `foo`
export * from './moduleB'; // Also exports a `foo`

Rolldown 采取保守策略:若某个导出名称对应多个不同的原始定义,该名称会被直接忽略,且不会被纳入模块的公共 API 中。这一设计能避免运行时出现不稳定或不可预测的行为。

但并非所有潜在冲突都会导致真正的歧义。在我们的示例项目中,main.jshelpers.js 导入 source 符号时,虽会沿重新导出链(export * from './api.js')追溯,但由于 source 仅有唯一的原始定义,match_import_with_export 函数能无冲突地完成解析。

链接阶段的输出结果

链接阶段会将扫描阶段生成的 "基础文件级依赖图",转化为一个信息丰富、可深度解析的结构化数据。最终输出结果被封装在 LinkStageOutput 结构体中:

Rust 复制代码
pub struct LinkStageOutput {
  pub module_table: ModuleTable,
  pub metas: LinkingMetadataVec,
  pub symbol_db: SymbolRefDb,
  // ... fields omitted for clarity
}

该结构体既包含原始的 ModuleTable(模块表),更重要的是,还包含链接阶段生成的全新产物。其中两个核心产物如下:

  1. LinkingMetadataVec :一个按 ModuleIdx(模块索引)索引的向量,存储每个模块对应的 LinkingMetadata(链接元数据)。它包含已解析的模块级信息 ------ 例如最终的导出映射表(resolved_exports)、以及图遍历结果(如 is_tla_or_contains_tla_dependency 标记,即 "是否含顶层 await 或依赖含顶层 await 的模块")。该向量为后续阶段提供了对每个模块属性和关联关系的语义级理解
  2. SymbolRefDb :符号关联关系数据库。它基于并查集(DSU)结构维护所有内部符号的等价类 ,借助这个数据库,可通过 find_mut 方法将任意导入符号追溯到其唯一的原始定义。

本质上,链接阶段是对模块依赖图的一次高效优化与解析过程。阶段结束时,所有模块和符号均已完全解析,且所有歧义都已消除。这为后续的代码生成、摇树优化(Tree Shaking)和代码分割阶段奠定了稳定、可预测的基础 ------ 而这正是这些阶段能够正确且高效执行的关键。

总结

链接阶段是一个复杂的处理流程,它将扫描阶段生成的基础依赖图转化为一份完全解析、无歧义的符号映射表。我们详细梳理了其核心逻辑:如何系统性地遍历依赖图,传播 "顶层 await(TLA)""副作用" 等属性;如何标准化不同的模块格式以确保互操作性。该阶段的核心支撑是一系列高效的数据结构(如 IndexVecFxHashMap)和强大的算法(深度优先搜索、并查集)。正是这些精心选择的数据结构与算法的组合,构成了 Rolldown 卓越性能的底层基石。

希望本次深度解析能帮助你扎实理解链接阶段的原理,并建立起对其内部工作机制的清晰认知。若你发现任何错误或有改进建议,欢迎在下方留言 ------ 你的反馈至关重要!

在下一篇文章中,我们将探索打包流程的最后一个阶段:代码生成。敬请期待!

相关推荐
想睡好1 小时前
标签的ref属性
前端·javascript·html
扶苏10021 小时前
Vue 3 新增内置组件详解与实战
前端·javascript·vue.js
henry1010102 小时前
HTML5小游戏 - 数字消除 · 合并2048
前端·游戏·html·html5
扶苏10023 小时前
详解Vue2和Vue3的变化
前端·javascript·vue.js
Hello eveybody3 小时前
如何将十进制转为二进制、八进制、十六进制?
前端·javascript·数据库
We་ct3 小时前
LeetCode 25. K个一组翻转链表:两种解法详解+避坑指南
前端·算法·leetcode·链表·typescript
悦悦子a啊3 小时前
CSS 知识点
开发语言·前端·css
ssshooter3 小时前
Transform 提高了渲染性能,但是代价是什么?
前端
光影少年3 小时前
前端工程化
前端·webpack·taro