原文链接:www.atriiy.dev/blog/rolldo...
作者: Atriiy
引言
Rolldown 是一款基于 Rust 开发的极速 JavaScript 打包工具,专为无缝兼容 Rollup API 设计。其核心目标是在不久的将来成为 Vite 的统一打包器,为 Vite 提供底层支撑。目前 Vite 在本地开发阶段依赖 esbuild 实现极致的构建速度,而生产环境构建则基于 Rollup;切换为 Rolldown 这类单一打包器后,有望简化整个构建流程,让开发者更有信心 ------ 开发环境所见的效果,与生产环境最终上线的结果完全一致。此外,Rolldown 的打包速度预计比 Rollup 快 10~30 倍。想了解更多细节?可查阅 Rolldown 官方文档。
本文将先从 Rolldown 的整体架构入手,帮你建立对其工作原理的全局认知,避免过早陷入细节而迷失方向。在此基础上,我们会深入本文的核心主题:模块加载器 ------ 这是 Rolldown 扫描阶段的核心组件,我们将剖析其关键功能,以及支撑它运行的重要数据结构。
接下来,我们还会探讨依赖图,以及 Rolldown 采用的部分性能优化策略。尽管其中部分内容你可能此前有所接触,但结合上下文重新梳理仍有价值 ------ 这些内容是理解 Rolldown 如何实现极致速度与效率的关键。
好了,让我们正式走进 Rolldown 的世界吧。😉
Rolldown 整体架构概览
Rolldown 的核心流程分为四个主要步骤:依赖图构建 → 优化 → 代码生成 / 打包 → 输出 。最终生成的打包产物会根据场景(本地开发 / 生产构建)写入内存或文件系统。你可在 crates/rolldown/src/bundler.ts 路径下找到入口模块的实现。以下是该流程的示意图:
模块加载器是构建模块依赖图阶段 的核心组件,它由 Bundler 结构体中的 scan 函数触发调用。为实现更清晰的职责分离,整个扫描流程已被封装到专用的 ScanStage 结构体中。
但真正的核心工作都发生在 ModuleLoader(模块加载器)内部:它负责处理构建依赖图、解析单个模块等关键任务,也是 Rolldown 中大量核心计算逻辑的落地之处 ------ 这正是本文要重点探讨的内容。
模块加载器(Module Loader)
简而言之,模块加载器的核心职责是定位、获取并解析单个模块(包括源码文件、CSS 文件等各类资源),并将这些模块转换为打包器能够识别和处理的内部数据结构。这一步骤是构建精准且高效的模块依赖图的关键。
以下示意图展示了 Rolldown 在打包流程中如何使用模块加载器:
上述所有步骤均发生在 ScanStage 结构体的 scan 函数内部。你可以将 scan 函数理解为一个编排器(builder) ------ 它统筹并封装了运行模块加载器所需的全部逻辑。
拉取模块(Fetch modules)
fetch_modules 是整个流程的 "魔法起点"。它扮演着调度器(scheduler) 的角色,启动一系列异步任务来解析所有相关模块。该函数负责处理用户定义的入口点 ------ 这也是模块扫描算法的起始位置。
在进入 fetch_modules 之前,scan 函数会先解析这些入口点,并将其转换为 Rolldown 内部的 ResolvedId 结构体。这一预处理步骤由 resolve_user_defined_entries 函数完成。
以下示意图展示了 fetch_modules 函数的核心工作流程:
看起来有点复杂,对吧?这是因为该阶段集成了大量优化策略和功能特性。不过别担心 ------ 我们可以暂时跳过细枝末节,先聚焦整体流程。
如前文所述,fetch_modules 函数以解析后的用户定义入口点为输入,开始执行处理逻辑。对于每个入口点,它会调用 try_spawn_new_task 函数:该函数先判定模块属于内部模块还是外部模块,再执行对应的处理逻辑,最终返回一个类型安全的 ModuleIdx(模块索引) 。这个索引后续会作为整个系统中引用对应模块的唯一标识。
当所有入口点的初始任务都已启动后,fetch_modules 会进入循环,监听一个基于 tokio::sync::mpsc 实现的消息通道。每个模块处理任务都持有该通道的发送端句柄(sender handle),并向主进程上报事件。fetch_modules 内部的消息监听器会响应这些消息,具体包括以下类型:
- 普通 / 运行时模块处理完成:存储处理结果,并调度该模块的所有依赖模块;
- 拉取模块:响应插件的按需加载特定模块请求;
- 添加入口模块:在扫描过程中新增入口点(通常由插件触发);
- 构建错误:捕获加载或转换过程中出现的所有错误。
当所有模块处理完毕且无新消息传入时,消息通道会被关闭,循环随之退出。随后 fetch_modules 执行收尾清理工作:存储已处理的入口点、更新依赖图,并将聚合后的结果返回给调用方(即 scan 函数)。该结果包含模块、抽象语法树(AST)、符号、入口点、警告信息等核心数据 ------ 这些都会被用于后续的优化和代码生成阶段。
启动新任务(Spawn new task)
try_spawn_new_task 函数首先尝试从模块加载器的缓存中获取 ModuleIdx(模块索引)。由于扫描阶段本质上是对依赖图的遍历过程,该缓存通过哈希映射表跟踪每个模块的访问状态 ------ 其中键为模块 ID,值用于标识该模块是否已处理完成。
接下来,函数会根据模块类型,将其转换为普通模块或外部模块结构,以便进行后续处理。理解外部模块的处理逻辑尤为重要:这类模块不会被 Rolldown 打包 ------ 它们预期由运行时环境提供(例如 node_modules 中的第三方库)。尽管不会被纳入最终打包产物,但 Rolldown 仍会记录其元数据,实际上是将其视为占位符(placeholder) 。打包产物会假定这些模块在运行时可用,并在需要时直接引用它们。
而普通模块(通常由用户编写)的处理方式则不同:try_spawn_new_task 会为每个普通模块创建一个专属的模块任务,并以异步方式执行。这些任务由 Rust 异步运行时 Tokio 管理。如前文所述,每个任务都持有消息通道的发送端,可在运行过程中上报错误、新发现的导入项,或动态添加的入口点。
数据结构(Data structures)
为提升性能和代码复用性,Rolldown 大量使用专用数据结构。理解模块加载器中几个核心数据结构的设计,能让你更清晰地认知扫描流程的底层实现逻辑。
ModuleIdx & HybridIndexVec
ModuleIdx 是一种自定义数值索引,会在模块处理过程中动态分配。这种索引设计兼顾类型安全与性能:Rolldown 不会传递或克隆完整的模块结构体,而是使用这种轻量级标识符(类似其他编程语言中的指针),在整个系统中引用模块。
Rust
pub struct ModuleIdx = u32;
HybridIndexVec 是 Rolldown 用于存储模块数据的智能自适应容器 。由于 Rolldown 核心操作的对象是 ModuleIdx(模块索引)而非实际的模块数据,实现高效的 "基于 ID 查找" 就至关重要 ------ 而这正是 HybridIndexVec 的设计初衷:它会针对不同的打包场景做针对性优化。
Rust
pub enum HybridIndexVec<I: Idx, T> {
IndexVec(IndexVec<I, T>),
Map(FxHashMap<I, T>),
}
打包工具通常运行在两种模式下:
- 全量打包(Full bundling) (生产环境构建的主流模式):所有模块仅扫描一次,并以连续存储的方式保存。针对这种场景,Rolldown 采用名为
IndexVec的紧凑高性能结构 ------ 它的行为类似向量(vector),但强制要求类型安全的索引访问。 - 增量打包(Partial bundling) (常用于开发环境):模块依赖图可能频繁变化(例如开发者编辑文件时)。这种场景下,稀疏结构(sparse structure)更适用,Rolldown 会使用基于
FxHash算法的哈希映射表,以实现高效的键值对访问。
FxHash 算法比 Rust 默认哈希算法更快,尽管其哈希冲突的概率略高。由于键和值均由 Rolldown 内部管理,且 "可预测的性能" 比安全性更重要,因此这种取舍对于 Rolldown 的使用场景而言是可接受的。
模块(Module)
普通模块由用户定义 ------ 通常是需要解析、转换或分析的源码文件。Rolldown 会加载这些文件,并根据文件扩展名进行处理。例如,.ts(TypeScript)文件会通过高性能的 JavaScript/TypeScript 解析器 Oxc 完成解析。
Rust
pub enum Module {
Normal(Box<NormalModule>),
External(Box<ExternalModule>),
}
内部的 NormalModule 结构体存储着每个模块的详细信息:既包含 idx(索引)、module_type(模块类型)等基础元数据,也涵盖模块内容的富表示形式(richer representations) 。根据文件类型的不同,这些内容具体包括:
ecma_view:用于 JavaScript/TypeScript 模块css_view:用于样式表文件asset_view:用于静态资源文件
这种结构化设计,能让打包流程后续阶段(如优化、代码生成)高效处理已解析的模块内容。
ScanStageCache(扫描阶段缓存)
这是一个在模块加载过程中存储所有缓存数据的结构体。以下是该数据结构的定义:
Rust
pub struct ScanStageCache {
snapshot: Option<NormalizedScanStageOutput>,
pub module_id_to_idx: FxHashMap<ArcStr, VisitState>,
pub importers: IndexVec<ModuleIdx, Vec<ImporterRecord>>,
}
snapshot(快照)存储着上一次扫描阶段的执行结果,用于支持增量构建。Rolldown 无需从头重新扫描所有模块,而是复用上次扫描的部分结果 ------ 当仅有少量文件变更时,这一机制能大幅缩短构建耗时。
module_id_to_idx 是一个哈希映射表,存储模块 ID 与其访问状态的映射关系。程序可通过它快速判断某个模块是否已处理完成。
该映射表的键类型为 ArcStr------ 这是一种内存高效、支持引用计数的字符串类型,专为跨线程共享场景优化。更重要的是,这个字符串是模块的全局唯一且稳定的标识符,在多次构建过程中保持一致,这对缓存的可靠性至关重要。
importers 是模块依赖图的反向邻接表 :针对每个模块,它会跟踪 "哪些其他模块导入了该模块"。这在增量构建中尤为实用:当某个模块内容变更时,importers 能帮助 Rolldown 快速确定受影响模块的范围 ------ 本质上就是识别出需要重新处理的模块。
需注意,importers 还会有一个临时版本存储在 IntermediateNormalModules(中间普通模块)中。你可以将其理解为 "草稿状态",会在当前构建过程中动态生成。
依赖图(Dependency graph)
依赖图描述了模块间的相互依赖关系,也是扫描阶段最重要的输出之一。Rolldown 会在后续阶段(如摇树优化、代码分块、代码生成)利用这份关系映射表完成各类核心任务。
在深入讲解具体实现前,我们先介绍邻接表的概念 ------ 它是依赖图的表示与遍历的核心载体。
图与邻接表(Graph and Adjacency table)
众所周知,图是用于表示 "事物间关联关系" 的数据结构,由两部分组成:
- 节点(Nodes):被关联的项或实体(对应 Rolldown 中的模块)
- 边(Edges):节点之间的关联或依赖关系(对应模块间的导入导出关系)
图有两种常见的表示方式:邻接矩阵 和邻接表。
邻接矩阵是一个二维网格(矩阵),每行和每列对应一个节点。矩阵中某个单元格的值表示两个节点之间是否存在边:例如,值为 1 表示存在关联,值为 0 则表示无关联。
css
| A | B | C
A | 0 | 1 | 0
B | 1 | 0 | 1
C | 0 | 1 | 0
这种方式(邻接矩阵)简单直观,在稠密图场景下表现优异 ------ 即大多数节点之间都存在关联的图。但对于稀疏图而言,它的内存利用率极低,而 Rolldown 这类打包工具中的模块依赖图恰好属于稀疏图。(相信没人会在项目里把所有模块都导入到每一个文件中吧。😉)
邻接表则是另一种存储方式:每个节点都维护一个 "邻居节点列表"。它不会使用固定大小的矩阵,而是只存储实际存在的关联关系,因此在稀疏图场景下效率更高。
举个例子:若节点 A 关联到节点 B,节点 B 关联到节点 A 和 C,最终节点 C 仅关联到节点 B。
css
A → [B]
B → [A, C]
C → [B]
这种结构(邻接表)内存利用率高,且能轻松适配大型稀疏图场景 ------ 比如 Rolldown 这类打包工具所处理的模块依赖图。同时,它还能让程序仅遍历相关的关联关系,这一点在扫描或优化阶段尤为实用。
正向与反向依赖图(Forward & reverse dependency graph)
在扫描阶段,Rolldown 会构建两种类型的依赖图:正向依赖图和反向依赖图。其中,正向依赖图存储在每个模块的 ecma_view 中,记录当前模块所导入的其他模块。
Rust
pub struct ecma_view {
pub import_records: IndexVec<ImportRecordIdx, ResolvedImportRecord>,
// ...
}
正向依赖图对打包至关重要。模块加载器从用户定义的入口点出发,构建这张图来确定最终打包产物需要包含哪些模块。它在确定执行顺序 和管理变量作用域方面也扮演着关键角色。
此外,模块加载器还会创建一张反向依赖图 ,方便追踪哪些模块导入了指定模块。这对摇树优化(Tree Shaking)、副作用分析、增量构建、代码分块和代码分割等功能至关重要。
这些功能涉及大量上下文,这里就不展开细讲。你可以简单这样理解:如果我(某个模块)发生了变化,谁会受到影响? 答案是:所有依赖这个变更模块的模块都需要重新处理。这就是实现增量构建 或热模块替换(HMR) 的核心思想。
性能优化
Rolldown 底层包含大量性能优化手段。得益于 Rust 的零成本抽象 和所有权模型,再搭配 Tokio 强大的异步运行时,开发者拥有了将性能推向新高度的工具。模块加载器本身也运用了多种提速技术,这里我们简要介绍一下,大部分内容前面已经提到过。
异步并发处理
并发是模块加载器的核心。如前所述,它的主要职责是遍历所有模块并构建依赖图。在实际项目中,导入关系会迅速变得复杂且嵌套很深,这使得异步并发至关重要。
在 Rust 中,async 和 await 是异步函数的基础构建块。异步函数会返回一个 Future,它不会立即执行,只有在显式 await 时才会运行。Rolldown 基于 Rust 最主流的异步运行时 Tokio,高效并发地执行这些模块处理任务。
缓存
由于 Rolldown 会执行大量异步操作,并且在本地开发环境中会频繁重复运行,缓存就成了避免重复工作的关键。
模块加载器的缓存存放在 ModuleLoader 结构体内部,包含 snapshot、module_id_to_idx、importers 等数据,大部分我们在前面章节已经介绍过。这些缓存能帮助 Rolldown 避免重复处理相同模块,让增量构建速度大幅提升。
未来展望
Rolldown 仍在积极开发中。未来,它有望成为 Vite 的底层引擎,提供一致的构建结果 和极致的性能 。你可以在这里查看路线图。
我写这篇文章是为了记录我研究 Rolldown 的过程,也希望能为你揭开它那些出色底层实现的神秘面纱。如果你发现错误或觉得有遗漏,欢迎在下方留言 ------ 我非常期待你的反馈!😊
感谢阅读,我们下篇文章见!