【翻译】Rolldown工作原理:模块加载、依赖图与优化机制全揭秘

原文链接: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 路径下找到入口模块的实现。以下是该流程的示意图:

graph TD start["Start: Read Config & Entry Points"] parse["Parse Entry Module"] build{"Build Module Graph"} load["Load & Parse Dependency Modules"] optimaze["Code optimization"] generate["Code Generation: Generate\n Chunks"] return["Return Output Assets In\n Memory"] write["Write Output Files to Disk"] start --> parse parse --> build build -->|Scan Dependencies|load load -->|Repeat until all\n dependencies are processed|build build --> optimaze optimaze --> generate generate -->|Generate Mode: rolldown.generate| return generate -->|Write Mode: rolldown.write| write

模块加载器是构建模块依赖图阶段 的核心组件,它由 Bundler 结构体中的 scan 函数触发调用。为实现更清晰的职责分离,整个扫描流程已被封装到专用的 ScanStage 结构体中。

但真正的核心工作都发生在 ModuleLoader(模块加载器)内部:它负责处理构建依赖图、解析单个模块等关键任务,也是 Rolldown 中大量核心计算逻辑的落地之处 ------ 这正是本文要重点探讨的内容。

模块加载器(Module Loader)

简而言之,模块加载器的核心职责是定位、获取并解析单个模块(包括源码文件、CSS 文件等各类资源),并将这些模块转换为打包器能够识别和处理的内部数据结构。这一步骤是构建精准且高效的模块依赖图的关键。

以下示意图展示了 Rolldown 在打包流程中如何使用模块加载器:

graph TD prepare["Bundler: Prepare needs to\n Build Module Graph"] create["Create Module Loader\n Instance"] calls["Bundler: Calls Module\n Loader's fetch_modules"] load[["Module Loader Operation"]] return["Module Loader: Returns\n Aggregated Results to\n Bundler"] result["Bundler: Uses Results for\n Next Steps - e.g., Linking,\n Optimization, Code Gen"] prepare --> create create --> calls calls --> load load --> return return --> result

上述所有步骤均发生在 ScanStage 结构体的 scan 函数内部。你可以将 scan 函数理解为一个编排器(builder) ------ 它统筹并封装了运行模块加载器所需的全部逻辑。

拉取模块(Fetch modules)

fetch_modules 是整个流程的 "魔法起点"。它扮演着调度器(scheduler) 的角色,启动一系列异步任务来解析所有相关模块。该函数负责处理用户定义的入口点 ------ 这也是模块扫描算法的起始位置。

在进入 fetch_modules 之前,scan 函数会先解析这些入口点,并将其转换为 Rolldown 内部的 ResolvedId 结构体。这一预处理步骤由 resolve_user_defined_entries 函数完成。

以下示意图展示了 fetch_modules 函数的核心工作流程:

graph TD start["Start: Receive Resolved\n User Defined Entries"] init["Initialize: Task Counter,\n Module Cache, Result\n Collectors"] launch["Launch Async Tasks for Each\n Entry"] loop{"Message Loop: Listen while\n Task Counter > 0"} store["Store Results;\nProcess Dependencies"] request["Launch Task for Requested\n Module; Increment Counter"] resolve["Resolve & Launch Task for\n New Entry; Increment\n Counter"] record["Record Error; Decrement\n Counter"] depence{"New Dependencies?"} tasks["Launch Tasks for New\n Dependencies; Increment\n Counter"] dec["Decrement Task Counter"] zero{"Task Counter == 0?"} final["Finalize: Update\n Dependency Graph,\n Organize Results"] return["End: Return Output to\n Caller"] start --> init init --> launch launch --> loop loop -->|Module Done| store loop -->|Plugin Fetch Module| request request --> loop loop -->|Plugin Add Entry| resolve resolve --> loop loop -->|Build Error| record record --> loop store --> depence depence -->|Yes| tasks tasks --> loop depence -->|No| dec dec --> zero zero -->|No| loop zero -->|Yes| final final --> return

看起来有点复杂,对吧?这是因为该阶段集成了大量优化策略和功能特性。不过别担心 ------ 我们可以暂时跳过细枝末节,先聚焦整体流程。

如前文所述,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 中,asyncawait 是异步函数的基础构建块。异步函数会返回一个 Future,它不会立即执行,只有在显式 await 时才会运行。Rolldown 基于 Rust 最主流的异步运行时 Tokio,高效并发地执行这些模块处理任务。

缓存

由于 Rolldown 会执行大量异步操作,并且在本地开发环境中会频繁重复运行,缓存就成了避免重复工作的关键。

模块加载器的缓存存放在 ModuleLoader 结构体内部,包含 snapshotmodule_id_to_idximporters 等数据,大部分我们在前面章节已经介绍过。这些缓存能帮助 Rolldown 避免重复处理相同模块,让增量构建速度大幅提升。

未来展望

Rolldown 仍在积极开发中。未来,它有望成为 Vite 的底层引擎,提供一致的构建结果极致的性能 。你可以在这里查看路线图。

我写这篇文章是为了记录我研究 Rolldown 的过程,也希望能为你揭开它那些出色底层实现的神秘面纱。如果你发现错误或觉得有遗漏,欢迎在下方留言 ------ 我非常期待你的反馈!😊

感谢阅读,我们下篇文章见!

相关推荐
SuperEugene1 小时前
《对象与解构赋值:接口数据解包的 10 个常见写法》
前端·javascript
Mr_Xuhhh2 小时前
博客文章:HTML核心概念与常见标签速览
前端
打瞌睡的朱尤2 小时前
Vue day12 Vue3认识,写法区分
前端·javascript·vue.js
阿珊和她的猫2 小时前
Vue Router 的使用指南
前端·javascript·vue.js
打瞌睡的朱尤2 小时前
day8 Vue-x
前端·javascript·vue.js
Web打印2 小时前
Phpask(php集成环境)之04配置网站
开发语言·前端·php
Zhencode2 小时前
vue3运行时核心模块之runtime-dom
前端·javascript·vue.js
夏幻灵2 小时前
CSS 布局深究:行框模型、幽灵节点与绝对居中的数学原理
前端·css
打瞌睡的朱尤2 小时前
Vue day13~16Vue特性,Pinia,大事件项目
前端·javascript·vue.js