Rust 重写前端工具链深度工程:从 Oxc 的 Arena AST、Tree Shaking 可达性到 esbuild 并行流水线的全链路解析

执行摘要:过去两年,前端基础设施发生了一场静默的替换:Vite 的构建核心换成 Rolldown,Rust 版的 Oxlint 跑进 CI,Biome 抢食 Prettier,Turbopack 与 Rspack 把 webpack 塞进 Rust 外壳。看点不是"Rust 更快"这种偷懒结论,而是三条被重新推导的工程原理:把 AST 从"每节点一次分配"改成 bump arena 加借用源串;把构建从"模块级事件回调"改成阶段内并行、阶段间收窄的流水线;承认跨 JS/Rust 边界的序列化成本,反过来要求插件钩子变粗。本文从内存布局、并行模型、图算法三层拆开这三件事,并给出可对照的代码。

一、先算一笔账:构建时间到底烧在哪里

一个中型前端项目的冷启动构建,时间大致这样分布:

  • 解析与词法分析:30%~45%。纯 CPU,与文件数量线性相关,天然可并行。
  • 语义绑定与模块解析:10%~20%。涉及文件系统 stat、路径回退算法、package.json 的 exports 条件解析,混有阻塞式系统调用。
  • 变换与降级(TS 到 JS、JSX、装饰器、语法降级):15%~30%。AST 遍历密集,内存带宽瓶颈。
  • 代码生成与压缩:15%~25%。字符串拼接、作用域重命名、Source Map 编码,同样是 CPU 密集。

关键的观察是:前、中、后三部分里,除模块解析外都是"每文件局部"的,只有跨文件绑定需要全局状态。传统 webpack 架构把四件事揉在同一个事件流里,导致一个模块的解析要去唤醒几十个插件钩子,而每个钩子都可能做一次字符串往返。所谓 Rust 重写,本质是把中可以并行化的部分压成阶段内的批任务,把必须串行的那一小块收敛成一个精心优化的小窗口——这正是 esbuild 最先证明、后被 Rolldown 与 Rspack 继承的模型。

一句话:它不是语言层面的重写,是拓扑层面的重写。把 JavaScript 换成 Rust,只是为了让这个拓扑能安全地并行。


二、第一层:Arena AST 与借用切片的内存布局

传统 JS 工具链的 AST 是由对象链表构成的图。每个节点一个对象,每个对象一次堆分配,每个字符串一次拷贝。一个 5000 行的 TSX 文件可以轻易产出 20 万个 AST 节点、8 万次分配。分代回收要在这些短命对象之间反复复制,回收时间经常占到解析总耗时的 20%~30%。Oxc、swc、Biome 给出的答案是同一套组合拳。

2.1 Bump Arena:把百万次分配变成几次

use oxc_allocator::{Allocator, Box, Vec};
use oxc_span::{Atom, Span};

// Oxc 风格:AST 节点全部带生命周期参数 'a,
// 意味着它的数据只能来自某个 arena,不能来自别的堆。
pub enum Expression<'a> {
    StringLiteral(Box<'a, StringLiteral<'a>>),
    CallExpression(Box<'a, CallExpression<'a>>),
    Identifier(Box<'a, IdentifierReference<'a>>),
}

pub struct StringLiteral<'a> {
    pub span: Span,        // 两个 u32 字节偏移,不是行列号
    pub value: Atom<'a>,   // 关键:借用源码切片,零拷贝
}

fn parse(src: &str) {
    let allocator = Allocator::default();       // 内部包装 bumpalo
    let ret = Parser::new(&allocator, src, SourceType::tsx()).parse();
    let program: Program<'_> = ret.program;
    // 整棵树的释放 = drop allocator,一次调用,
    // 而不是 20 万次逐个释放。
}

这里的 Box 不是标准库的 Box,它是 arena 内部的偏移指针。释放整棵树的代价从 O(节点数) 降到 O(1)。更重要的是局部性:同一子树在深度优先遍历顺序下被连续分配,遍历时几乎是完美的顺序内存访问,缓存命中率与链表式对象图不在一个量级。

2.2 Span 与 Atom:字符串零拷贝

注意上面的 Span 是字节偏移而非行列。这个决策有两个收益:

  • 解析期不需要维护行列计数器,省掉每个 token 的分支判断。
  • 报错时才把偏移换算成行列,那时通常只涉及报错的这一个位置,代价可忽略。

Atom 更进一步:标识符与字符串字面量的值是源文本的一段借用切片,解析本身不产生任何字符串堆分配。只有需要改写的内容(转义处理、Unicode 解码)才分配新串。配合字符串驻留,符号表让"两个标识符是否相等"从 O(len) 的字符比较变成 O(1) 的指针或哈希比较——这在作用域解析里被调用数百万次。

2.3 代价:Send 约束与并行解析的边界

带生命周期的 AST 有个副作用:跨线程移动 arena 中的数据需要类型满足 Send,且 arena 不能被多线程可变共享。实践中采用的解法不是共享 arena,而是每个文件一个 arena,解析完整体搬出:

struct OwnedParse {
    allocator: Allocator,     // 必须和 AST 同生共死
    program: Program<'static>,
}

// 关键点:源码必须被固定在稳定地址上,才能被 AST 借用。
// 生产做法是 Box::leak 或 allocator 自带的 alloc_str,
// 把 String 的所有权转移到 arena 生命周期。
let results: Vec<OwnedParse> = paths
    .par_iter()                       // rayon 并行
    .map(|p| {
        let text = std::fs::read_to_string(p).unwrap();
        let src: &'static str = Box::leak(text.into_boxed_str());
        let allocator = Allocator::default();
        let ret = Parser::new(&allocator, src, SourceType::tsx()).parse();
        OwnedParse { allocator, program: unsafe { std::mem::transmute(ret.program) } }
    })
    .collect();

这段伪代码里最值得注意的其实是那句注释:每个线程独立持有自己的 arena,是让借用检查器与并行度握手的关键妥协。你不去争执这块内存能不能共享,而是把系统设计成它根本不需要共享。


三、第二层:并行流水线的边界

3.1 esbuild 的三阶段模型

esbuild 之所以能在普通笔记本上把巨型项目压到秒级,核心不是 Go 有多快,而是它把整个流程砍成了三段:

  1. Scan 阶段(全并行):从入口出发遍历 import 图,对每个文件做词法语法分析,产出每文件的导出与导入符号表。此阶段没有跨文件的写共享。
  2. Link 阶段(串行,但极窄):把 import { a } from './x' 解析成指向另一个文件符号的直接链接,合并同名导出,处理 CommonJS 互操作。此步的数据量是符号表而非文件内容,因此即使串行也很快。
  3. Print 阶段(按 chunk 并行):每个输出 chunk 一个协程做代码生成、作用域重命名、压缩与 Source Map 编码。

Go 侧实现骨架大致如下,注意这里刻意不能用简单的关闭管道模式:

// esbuild 式 scan phase:worker 在图遍历里发现新节点时自己 Add(1),
// 因此必须靠 WaitGroup 计数器收敛,而不是靠关闭 channel。
var mu sync.Mutex
files := make(map[string]*JSFile)
seen  := make(map[string]bool)
sem   := make(chan struct{}, runtime.NumCPU())

var visit func(path string)
visit = func(path string) {
    defer wg.Done()
    mu.Lock()
    if seen[path] { mu.Unlock(); return }
    seen[path] = true          // 先占位,避免公共依赖被重复解析
    mu.Unlock()

    sem <- struct{}{}
    ast := parseFile(path)     // 纯 CPU,无共享写
    <-sem

    mu.Lock()
    files[path] = ast
    for _, imp := range ast.Imports {
        if !seen[imp] {
            wg.Add(1)
            go visit(imp)      // 动态追加任务
        }
    }
    mu.Unlock()
}
for _, e := range entryPoints { wg.Add(1); go visit(e) }
wg.Wait()

工程要点:sem 把并行 parse 的并发度钉在 CPU 核数上。少了 CPU 打不满,多了会因为大文件集中加载引发页缓存抖动,反而更慢。这是一个必须实测标定的参数,而不是写死的默认值。

3.2 Tree Shaking:从语法树到可达性图

摇树本质是一个有向图上的可达性问题,不是语法优化。它的深度体现在三处:

fn include_from(module: ModuleId, ctx: &mut LinkCtx) {
    if !ctx.visited.insert(module) { return }   // 防止循环 import 无限递归
    for stmt in ctx.graph[module].top_level_stmts() {
        if stmt.has_side_effect(ctx) {          // 一是副作用判定
            ctx.mark_included(stmt);
            for sym in stmt.referenced_symbols() {
                if let Some(target) = ctx.resolve(sym, module) {
                    include_from(target.owner, ctx);   // 二是沿符号边递归
                }
            }
        }
    }
}
  • 一是副作用判定,这是决定性的。纯声明不影响外部状态(常量、函数声明、类声明),可被摇掉;顶层函数调用、IIFE、getter 求值、import './global.css' 一律保留。package.json 里的 sideEffects: false 字段,作用正是把"整个模块无副作用"这一信息从运行时提升到构建期,写错了就是生产事故。
  • 二是粒度,是语句级而非模块级。lodash-es 能被摇掉九成以上,正是因为 ES Module 的导出是静态绑定,符号边可以在构建期确定;而 CommonJS 的导出是运行时对象赋值,只能整体保留。
  • 三是环的处理。visited 集合能防死循环,但如果只做朴素深度优先,遇到循环引用会产出错误的语句顺序。正确做法是先跑 Tarjan 强连通分量,把环缩成一个超级节点,再在缩点后的有向无环图上做拓扑排序输出。循环依赖之所以能被"不崩",靠的就是这一步。

四、第三层:Rolldown 与统一 IR 的真正含义

Rolldown 与 Oxlint 共享同一个 Oxc 前端。这句话的工程含义容易被低估,它实际带来三件事:

  1. 源码再也不会被重复解析。旧链路里检查工具用自己的 parser 解析一次、TypeScript 编译一次、Babel 再解析一次、打包器的 loader 又解析一次——同一个源文件在一台 CI 机器上被完整解析四遍。统一 IR 之后变成一遍,后续所有消费者读同一份 AST。
  2. 跨语言边界的成本被显式化。这一点很反直觉:把 webpack 换成 Rust 之后,瓶颈往往会从 AST 处理转移到 JS 插件的边界序列化上。每个模块的每次钩子都要在 Rust 侧发起跨语言调用,把字符串与对象搬一趟。于是正确的工程做法是把钩子变粗:
// 反模式:每模块一次跨边界回调。10 万模块的巨型项目,
// 会产生 10 万次 Rust 到 JS 的切换,序列化开销吃掉所有收益。
export default defineConfig({
  plugins: [{ transform(code, id) { /* 对每个模块都调用 */ } }],
})

// 正确姿势:一次性初始化 + 谓词下推过滤
export default defineConfig({
  plugins: [{
    buildStart() { this.bootstrap() },                 // 整个构建只跑一次
    transform: {
      filter: { id: /\.vue$/ },                        // 命中率极低,筛选在 Rust 侧完成
      handler(code, id) { return transformVue(code, id) },
    },
  }],
})

关键在 filter:它让"这个插件对该模块是否感兴趣"这个判断发生在 Rust 侧,只有真正命中的模块才付一次跨界成本。生态里 plugin filter 的提案就是为此而生的。

  1. 增量必须是函数级的。Turbopack 的函数记忆化与 Parcel 的请求级缓存是两种典型路线:前者把每个纯函数调用与结果记录在依赖图里,改一个文件只让下游失效节点重算;后者以构建请求为单位缓存到磁盘。前者内存占用高但增量极快,后者冷启可复用。Rust 系普遍选择前者,因为 arena AST 的内存账是可以精确控制的——前提是你在变换完成后立刻释放掉不需要的中间表示,只保留符号表与 chunk 计划。这一点做不好,构建峰值内存会是旧链路的两倍。

五、最后一块拼图:Source Map 的 VLQ 编码

压缩后的产物必须能被映射回源码,否则 Rust 重写省下的那几秒,会在一次线上排障里赔回去。Source Map 的 mappings 字段是 Base64 VLQ 编码:

const B64: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

fn encode_vlq(value: i32, out: &mut String) {
    // 符号信息塞进最低位
    let mut v = if value < 0 { ((-value) << 1) | 1 } else { value << 1 };
    loop {
        let mut digit = (v & 31) as u8;   // 每个字符携带 5 bit
        v >>= 5;
        if v != 0 { digit |= 32; }        // 32 是续行位
        out.push(B64[digit as usize] as char);
        if v == 0 { break }
    }
}

每个片段是 4 到 5 个 VLQ 值(生成列、源文件索引、源码行、源码列、可选符号名索引),用逗号分隔,行用分号分隔。工程量在于:这个编码必须在 print 阶段的同一遍扫描里流式完成,否则你要为 Source Map 再做一次完整的字符串拼接,把它变成新的热点。


六、生产落地必须躲的七个坑

  1. 只看冷启不看增量。多数项目的痛点确实是冷启,但某些 monorepo 的痛点是 watch 模式下的重编译延迟。先测量再迁移,否则你可能为一个不存在的瓶颈付出迁移成本。
  2. 插件生态无法平移。webpack loader 与 Babel plugin 是 JavaScript 写的,涉及具体业务 DSL 的部分大概率要重写。双链路并存跑一段时间是常态,不要指望一次性切换。
  3. CJS 与 ESM 的互操作语义。CommonJS 的动态导出必须在产物里保留一个运行时包装层,否则默认导出的分支会静默失效。Node 内置模块的 polyfill 策略也要显式配置。
  4. sideEffects: false 误判。一个包声明自己无副作用、但其入口顺手执行了全局 polyfill 或样式导入,摇树构建后这部分会在生产环境消失,而开发态往往不显形。
  5. 内存峰值翻倍。并行解析时如果每个文件的 arena 都活到 link 阶段结束,常驻内存会暴涨。策略必须是解析完立即做语义提取,然后尽快释放完整 AST,只留符号表。
  6. Source Map 失真。多层变换串联时,每一层的映射链必须正确合并,否则断点会跳到错误位置,比没有 Source Map 更糟。
  7. 哈希不稳定导致缓存击穿。如果 chunk 哈希的输入混入了绝对路径、时间戳或非确定性并行的顺序,内容哈希每次都变,CDN 长效缓存与本地持久缓存会全面失效。

七、结论:这是一次系统重构,不是一次换语言

把构建时间从 90 秒压到 8 秒,功劳要算在三条账头上:arena 内存布局消除了海量分配与回收抖动,阶段化并行把天然的并行度从回调地狱里释放出来,粗粒度钩子加统一 IR把跨语言开销压到最低。Rust 只是让这一切能写成安全代码的工具。

判断标准很朴素:如果迁移之后瓶颈从 AST 处理转移到了插件边界或 IO,这次迁移就是成功的——最难优化的 CPU 密集负载已被压到极限,剩下的都可以用更大的并行度与批处理解决。反之,只是换个语言跑同样的回调链,收益上限一开始就被那个拓扑锁死了。

一句话总结:先重构拓扑,再考虑语言。顺序反了,换什么语言都救不了。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部