Rolldown 底层到底怎么设计的?拆开看看它比 Rollup 快 30 倍的秘密
Rolldown 底层到底怎么设计的?拆开看看它比 Rollup 快 30 倍的秘密
去年尤雨溪把 Rolldown 开源的时候,很多人只是听说"又一个 Rust 打包工具"。但 Rolldown 跟其他 Rust 工具不一样——它是要取代 Vite 里面 esbuild + Rollup 两套工具的。
那问题来了:Rolldown 底层到底怎么设计的?凭什么敢说比 Rollup 快 10-30 倍?
我今天把它的源码架构翻了一遍,写写我的理解。
为什么 Vite 非换掉 esbuild + Rollup 不可?
先说背景。Vite 现在的架构长这样:
开发模式:esbuild 预构建依赖 → 浏览器原生 ESM
生产构建:Rollup 打包 + 压缩
看着还行对吧?但实际维护过 Vite 插件的同学都知道,这个双引擎方案有很多蛋疼的地方:
- 环境不一致。 esbuild 预构建完的产物,跟 Rollup 最终打包的产物,处理方式不一样。同样的
import语句,两个工具的表现不完全一致,容易出 bug。 - 插件得写两套逻辑。 esbuild 的插件 API 跟 Rollup 的插件 API 完全不兼容,Vite 在中间做了一层转化适配,这层适配本身就是复杂度。
- 序列化/反序列化开销。 代码先在 esbuild 里过一遍转成 JS,再交给 Rollup 重新解析一次 AST。同样的事情干了两次。
Rolldown 要解决的就是这个问题——一个工具搞定所有事情,而且是用 Rust 写的,天然比 JS 写的 Rollup 快得多。
Rolldown 整体架构
Rolldown 的项目结构分这几个核心 crate:
rolldown/ # 主打包引擎
rolldown_binding/ # Node.js 绑定层(NAPI-RS)
rolldown_common/ # 共享类型和数据结构
rolldown_plugin/ # 插件系统
rolldown_plugin_*/ # 各种内置插件
string_wizard/ # Rust 版的 MagicString
每个 crate 职责单一,这是 Rust 项目的常规风格,但 Rolldown 的设计亮点不在这,在于它内部的处理流程。
用一个图来表示数据流:
源码 → 模块加载 → AST 扫描 → 链接优化 → 代码生成 → 输出
大部分打包工具都这个流程,但 Rolldown 在每个环节都做了自己的优化。
模块加载:流水线并行处理
模块加载器在 crates/rolldown/src/module_loader/ 下。
传统 JS 打包工具(比如 Rollup)加载模块是串行的——加载完 A,发现它 import 了 B,再去加载 B,等 B 加载完再往下走。
Rolldown 不一样,它用的是流水线并行。每个模块解析完后立刻通知依赖管理器,管理器会同时发起所有可解析的模块加载请求。
举个例子:
// main.js
import { a } from './a.js'
import { b } from './b.js'
Rollup 的做法:加载 main.js → 解析 → 发现依赖 a.js 和 b.js → 先加载 a.js → 解析 → 再加载 b.js → 解析。
Rolldown 的做法:加载 main.js → 解析 → 发现依赖 a.js 和 b.js → 同时加载 a.js 和 b.js。
这看着差别不大,但项目依赖多了以后差距就出来了。比如一个入口文件 import 了 30 个模块,Rolldown 可以并行拉起这些加载,Rollup 只能逐个来。
代码上看,每个模块加载任务是一个 ModuleTask 结构体:
// 伪代码,简化了实际实现
struct ModuleTask {
id: ModuleId,
state: TaskState, // Pending | Resolving | Loading | Parsing | Done
dependencies: Vec<ModuleId>,
}
struct ModuleLoader {
pending: Vec<ModuleTask>,
in_progress: Vec<ModuleTask>,
completed: Vec<ModuleTask>,
}
ModuleLoader 会维护一个待处理队列和一个进行中队列。每当有模块解析出新依赖,就把新依赖丢进待处理队列,然后从待处理队列批量拉起任务。
AST 扫描与依赖分析
AST 扫描器在 crates/rolldown/src/ast_scanner/,它底层用的是字节跳动 Oxc 项目的解析器。
Oxc 的解析器用 Rust 写的,解析速度大概是 swc 的 2-3 倍,是 Babel 的几十倍。Rolldown 直接用它来解析每个模块的 AST,然后扫描出所有 import/export 语句。
扫描器的核心工作:
// AST 扫描器做的事情
struct AstScanner {
// 扫描导入语句,收集依赖
imports: Vec<ImportRecord>,
// 扫描导出语句,收集导出
exports: Vec<ExportRecord>,
// 检测副作用,为 Tree Shaking 服务
side_effects: SideEffectInfo,
// 常量折叠,在编译时就计算
constant_expressions: Vec<ConstantExpr>,
}
这里有个有意思的点:Rolldown 的 AST 扫描会把副作用检测做得很细。它不只是检查有没有 sideEffects: false 的 package.json 字段,还会深入到函数调用级别去看。
比如下面这个代码:
import { Button } from 'antd'
console.log('hello')
Rolldown 的 AST 扫描器会判断出 console.log('hello') 有副作用(因为 console.log 会输出到控制台),但不会错误地认为它依赖了 Button 组件。而 Rollup 很多时候会保守地把 import 全部保留。
链接阶段的增量优化
链接阶段是最核心的优化环节,位于 crates/rolldown/src/stages/link_stage/。
这里主要做三件事:
1) Tree Shaking
Rolldown 的 Tree Shaking 跟 Rollup 走的是同一条路径——基于 ESM 静态分析。但因为 Rust 实现,速度是天壤之别。
更重要的是,Rolldown 在 HMR(热更新)场景下做了增量 Tree Shaking。开发模式下改了一个文件,不会重新对整个依赖图做摇树,只重新处理受影响的部分。
// 增量构建的核心逻辑
fn process_changed_modules(&self, changed: &[ModuleId]) {
// 只重新分析变更的模块及其直接依赖
let affected = self.find_affected(changed);
for module in affected {
self.tree_shake_module(module);
}
// 其他模块的 Tree Shaking 结果直接复用缓存
}
2) 符号绑定
这个阶段会把所有模块的 import/export 串联起来。Rust 的实现方式是用一个全局的符号表,每个符号是一个 ID,查找是 O(1)。而 Rollup 用的是 JS 对象,虽然也是哈希表,但 Rust 的内存布局和数据局部性要好得多。
3) 跨模块公共代码提取
Rolldown 在链接阶段会识别出跨模块共享的代码,自动提取成公共 chunk。这跟 Rollup 的 manualChunks 配置效果类似,但 Rolldown 很多场景可以自动完成。
代码生成:string_wizard 的魔法
代码生成阶段在 crates/rolldown/src/stages/generate_stage/ 和 crates/rolldown/src/ecmascript/。
这里不得不提 Rolldown 的 string_wizard crate——它是 MagicString 的 Rust 实现。
MagicString 是 Svelte 团队写的一个 JS 库,用于高效地操作字符串(替换、删除、插入),同时能生成准确的 source map。Rolldown 用 Rust 重新实现了一遍,性能大幅提升。
// MagicString 在 JS 端的使用方式
const s = new MagicString('hello world')
s.overwrite(0, 5, '你好') // 替换
s.remove(6, 11) // 删除
s.prepend('const x = ')
// → 'const x = 你好'
Rolldown 的 string_wizard 用 Rust 的 Vec<u8> 做底层存储,避免了 JS 字符串在 V8 堆上的 GC 压力和编码转换开销。
插件系统:兼容 Rollup + 钩子过滤器
Rolldown 的插件系统在 crates/rolldown_plugin/,API 基本跟 Rollup 对齐:
buildStart/buildEndresolveIdloadtransformrenderStart/renderChunk/generateBundle
也就是大部分 Rollup 插件可以直接在 Rolldown 上用。
但 Rolldown 加了一个 Rollup 没有的东西——钩子过滤器(Hook Filter)。
impl Plugin for CssPlugin {
fn transform(&self, args: HookTransformArgs) -> Result<HookTransformOutput> {
// 只处理 .css 文件
}
fn get_transform_filter(&self) -> Option<HookFilter> {
Some(HookFilter::Extension("css"))
}
}
这个过滤器有什么用?举个例子:如果你有一个 CSS 处理插件,不配过滤器的话,Rolldown 每个模块都要调用一次 transform,即使这个模块是 .js 文件。配上过滤器后,只有 .css 文件才会触发这个钩子。
这对性能的影响非常大——大项目里成百上千个模块,不必要的钩子调用累积起来是很大的开销。
跟 esbuild 和 Rollup 的性能对比
根据官方公布的 benchmark 数据(打包 Three.js 及其依赖):
| 工具 | 冷构建 | HMR 更新 |
|——|——–|———-|
| Rollup | ~3200ms | N/A |
| esbuild | ~280ms | ~50ms |
| Rolldown | ~350ms | ~30ms |
WASM 版本下,Rolldown 甚至比 esbuild 还快,因为 Go 编译到 WASM 的效率不如 Rust 高。
当然 Rolldown 目前还是 beta,有些边界情况还没处理好,但大方向是对的——用 Rust 统一 Vite 的构建管线,消除双引擎的不一致性。
什么时候能用到生产环境?
Rolldown 现在已经是 beta 阶段了,Vite 6 已经集成了 Rolldown 作为可选的打包后端。社区里已经有人在试用了。
我的建议是:
- 小项目可以先玩玩。 配置基本兼容 Rollup,迁移成本很低。
- 生产环境再等一等。 等 Rolldown 正式发版,Vite 默认切换过去再用。
- 写插件的话现在就可以看 API 了。 插件接口基本冻结了,不会有大变动。
总体来看,Rolldown 的架构设计很扎实,不是那种"Rust 重写一遍就完事"的工具。它在模块加载、Tree Shaking、插件系统这些环节都有自己的创新思考,值得前端工程师花时间了解。
评论区
登录后可评论。












