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 / buildEnd
  • resolveId
  • load
  • transform
  • renderStart / 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、插件系统这些环节都有自己的创新思考,值得前端工程师花时间了解。

评论区

0 条评论

登录后可评论。

查看完整榜单
查看完整榜单
查看完整榜单