两周用Rust重写TypeScript编译器:从42万美元失败到成功
1. 项目缘起与整体思路拆解1.1 一个烧掉 42 万美元 token 的教训先说这个标题里最扎眼的数字42 万美元的 token 消耗。这不是某个团队一年的云账单而是一次编译器迁移尝试中模型调用费用累积出来的真实开销。我第一反应是这钱花得值不值但仔细拆解之后发现真正值得聊的不是钱而是为什么前一次会失败后一次两周就跑通了。TypeScript 编译器业内通常叫 tsc是一个用 TypeScript 写成的、体量巨大的自举项目。它的源码规模在几十万行量级类型系统、控制流分析、增量编译、声明文件生成这些模块互相咬合牵一发动全身。把它搬进 Rust本质上是做一次跨语言的等价重写而不是简单的语法翻译。前一次尝试之所以烧掉 42 万美元还没成核心问题在于把这件事当成了逐文件翻译任务——让模型一个文件一个文件地读 TypeScript、吐 Rust结果类型系统对不上、生命周期表达不了、递归结构直接卡死越往后改越乱最后变成一堆编译不过的碎片。第二次用两周跑通思路完全变了不再追求翻译而是先重建骨架再逐层填充语义。这个转变是整件事的分水岭也是我想在这篇里重点拆的东西。1.2 为什么选 Rust 而不是别的语言很多人会问为什么非得是 RustGo 不行吗C 不行吗这里有几个硬性约束决定了选型。第一是性能与内存控制。编译器是典型的 CPU 密集 内存敏感型程序tsc 在大项目上慢是出了名的。Rust 没有 GC所有权模型能在编译期就把内存生命周期管清楚这对一个需要频繁创建、销毁 AST 节点的程序来说省掉的不只是 GC 停顿还有大量堆分配。第二是错误处理与类型系统表达力。TypeScript 的类型系统本身就很复杂联合类型、条件类型、映射类型要在一个宿主语言里表达这些概念宿主语言的类型系统得够强。Rust 的 enum trait 泛型组合恰好能比较自然地映射 TypeScript 的代数数据类型风格。第三是生态与工具链成熟度。cargo 的构建、测试、基准测试一体化对一个需要频繁跑回归的编译器项目来说省了大量脚手架工作。对比一下三种候选语言的适配度维度RustGoC内存控制编译期所有权无 GCGC 停顿手动管理易出错类型系统表达力enum/trait 强接口较简单模板复杂难维护构建工具cargo 一体化go build 够用CMake 繁琐并发安全编译期检查goroutine 简单需自行保证与 TS 语义映射较自然一般可行但痛苦选 Rust 不是因为它新潮而是因为编译器这个场景对它的强项刚好全中。1.3 两周跑通的关键分层重建而非逐文件翻译我把第二次成功的策略总结成一句话先让骨架编译通过再让语义逐步对齐。具体分四层第一层数据结构层。先把 AST 节点、符号表、类型表示这些核心数据结构用 Rust 的 enum 和 struct 定义出来不管逻辑只保证能编译。第二层遍历与访问层。实现 visitor 模式让程序能走完整棵树此时所有节点处理函数先返回占位值。第三层语义填充层。逐个模块把类型检查、作用域解析、控制流分析的真实逻辑填进去。第四层回归对齐层。用原 tsc 的测试用例做对照逐条跑通。这个分层的好处是任何一层出问题影响范围都被限制在那一层内不会像逐文件翻译那样一个类型对不上就导致整个项目编译失败然后陷入改一个错冒十个错的泥潭。提示跨语言重写大型项目时能编译永远优先于逻辑正确。先让空壳跑起来再填肉这个顺序反了会非常痛苦。2. 核心细节解析与实操要点2.1 TypeScript 类型系统到 Rust 的映射难点这是整个项目最硬的部分。TypeScript 的类型系统有几个特性直接映射到 Rust 会撞墙。联合类型Union Types。TS 里string | number是家常便饭Rust 里对应的是enum { Str(String), Num(f64) }。听起来简单但 TS 的联合类型可以嵌套、可以跟条件类型组合一旦复杂起来Rust 的 enum 就得层层嵌套代码可读性急剧下降。我的处理方式是为高频联合类型定义具名 enum而不是每次内联展开。条件类型Conditional Types。T extends U ? X : Y这种在编译期做类型计算的机制Rust 没有直接对应物。可行的做法是用 trait 的关联类型 特化来模拟但特化在稳定版 Rust 里支持有限。实际项目中这部分我是降级处理的——把条件类型在解析阶段就展开成具体类型而不是在 Rust 侧保留完整的类型计算能力。结构化类型Structural Typing。TS 是结构化的只要形状对得上就是同一个类型Rust 是名义化的nominal类型名字不同就是不同类型。这个差异导致符号表的设计必须额外维护一层形状哈希用来判断两个类型是否结构等价。// 用形状哈希做结构化类型比较的简化示意 #[derive(Hash, PartialEq, Eq)] struct TypeShape { kind: TypeKind, members: Vec(String, TypeShape), } fn is_structurally_equal(a: TypeShape, b: TypeShape) - bool { a b }这段代码看着简单但实际落地时members的排序、可选成员的处理、递归类型的终止条件每一个都是坑。我踩过最深的一个是递归类型导致的无限展开——两个互相引用的接口做形状比较时如果不加访问标记直接栈溢出。2.2 生命周期与所有权在 AST 上的处理编译器里 AST 节点之间大量互相引用父节点指向子节点子节点可能反向引用父节点符号表里的符号被多处引用。这在 Rust 里是所有权模型最不擅长的场景。我试过三种方案纯引用 生命周期标注。写起来最Rust但一旦出现环状引用就彻底卡死编译器报错能刷满屏幕。Rc RefCell。能解决共享和可变问题但运行时开销大而且 RefCell 的借用检查在运行时才报错调试困难。Arena 分配 索引引用。所有节点存在一个大的 Vec 里节点之间用usize索引互相引用。最终我选了第三种。原因是编译器场景下节点生命周期高度一致——整棵树在编译过程中基本不单独释放用 arena 一次性管理最省心。索引引用还有个额外好处可以序列化、可以做增量缓存。struct AstArena { nodes: VecNode, } impl AstArena { fn alloc(mut self, node: Node) - NodeId { self.nodes.push(node); NodeId(self.nodes.len() - 1) } fn get(self, id: NodeId) - Node { self.nodes[id.0] } }注意arena 方案会让内存一直增长到编译结束才释放。对单次编译没问题但如果要做长期驻留的语言服务得配合分代回收或定期重建 arena。2.3 增量编译的语义对齐tsc 的增量编译是它在大项目上还能用的关键。Rust 侧要复刻这套机制核心是依赖图的精确维护。TS 的增量编译基于文件级别的依赖追踪改了 A 文件找出所有依赖 A 的文件重新检查它们。听起来简单但依赖的粒度很关键——是文件级、符号级还是表达式级粒度越细增量越准但维护成本越高。我的做法是符号级依赖 文件级失效的混合策略记录每个符号被哪些文件引用当符号变化时标记引用它的文件为脏重新检查脏文件。这样既避免了全量重编译又不用维护表达式级的超细粒度依赖图。实测下来在一个约 800 个文件的项目上全量编译约 12 秒增量编译改一个文件约 1.2 秒加速比接近 10 倍。这个数字跟原 tsc 的增量表现基本持平。3. 实操过程与核心环节实现3.1 环境搭建与项目骨架初始化第一步是把 Rust 工具链和项目结构搭好。我用的是 stable 工具链没有引入 nightly 特性原因是编译器项目要长期维护nightly 特性随时可能变稳定性优先。cargo new ts_compiler_rs --lib cd ts_compiler_rs项目结构按模块划分src/ lexer/ 词法分析 parser/ 语法分析 ast/ AST 定义与 arena binder/ 符号绑定 checker/ 类型检查 emitter/ 代码生成 driver/ 编译驱动与增量逻辑这个划分直接对应原 tsc 的模块结构好处是迁移时能一一对照减少遗漏。每个模块先建空文件定义好对外接口保证cargo build能过。3.2 词法与语法分析器的移植词法分析器相对好办TS 的 token 类型是有限的直接照着定义 enum 就行。真正麻烦的是自动分号插入ASI——TS 允许省略分号解析器得根据换行和上下文判断语句边界。这个逻辑在原 tsc 里散落在多个地方我花了整整两天才把边界情况理清楚。语法分析器我用的是递归下降 Pratt 表达式解析的组合。递归下降处理语句Pratt 处理表达式优先级。这个组合在编译器圈子里是标配原因是代码可读性好错误恢复容易做。fn parse_expression(mut self, min_bp: u8) - ResultExpr, ParseError { let mut lhs self.parse_prefix()?; loop { let op match self.peek() { Some(t) if is_binary_op(t) t.clone(), _ break, }; let (l_bp, r_bp) infix_binding_power(op); if l_bp min_bp { break; } self.advance(); let rhs self.parse_expression(r_bp)?; lhs Expr::Binary { op, lhs: Box::new(lhs), rhs: Box::new(rhs) }; } Ok(lhs) }这段 Pratt 解析的核心是binding_power表它决定了运算符优先级和结合性。我踩的坑是三元运算符和箭头函数的优先级冲突——a ? b : c d这种写法得靠上下文才能判断是箭头函数还是别的。原 tsc 用了回溯我在 Rust 里用了一个小的 lookahead 缓冲来模拟。3.3 类型检查器的分层实现类型检查是整个项目最重的部分我把它拆成三个子阶段第一阶段符号解析。遍历 AST把所有声明变量、函数、类、接口、类型别名登记到符号表建立作用域链。这个阶段不检查类型只建表。第二阶段类型推导。对每个表达式推导出它的类型。这里要处理类型推断、泛型实例化、上下文类型contextual typing。上下文类型是 TS 的特色——const f: (x: number) void x {}里x的类型是从左边的注解推出来的而不是从右边推的。第三阶段类型检查。把推导出的类型跟期望类型做兼容性判断。TS 的类型兼容是结构化的所以这里要调用前面说的形状比较逻辑。三个阶段分开的好处是错误定位清晰。符号找不到是第一阶段的问题类型推不出来是第二阶段的问题类型不匹配是第三阶段的问题。混在一起做的话报错信息会非常混乱。3.4 用测试用例驱动对齐两周能跑通很大程度靠的是测试驱动。原 tsc 有大量测试用例我写了个脚本把这些用例转成 Rust 侧的集成测试。#[test] fn test_union_narrowing() { let source r# function f(x: string | number) { if (typeof x string) { return x.length; } return x.toFixed(2); } #; let result compile(source); assert!(result.errors.is_empty()); }每跑通一批用例就说明对应的语义对齐了一块。这种用例点亮的方式比漫无目的地读代码改代码效率高得多。我统计了一下最终跑通了约 3000 个核心用例覆盖了类型系统、控制流、模块解析的主要路径。实操心得跨语言重写时原项目的测试套件是最宝贵的资产。它既是验收标准也是进度条。没有测试套件的重写基本等于盲人摸象。4. 常见问题与排查技巧实录4.1 编译期报错刷屏怎么破Rust 编译器一次报几百个错是常态尤其是刚搭骨架的时候。我的处理顺序是先看第一个错。Rust 的错误经常是连锁的第一个错修了后面几十个可能自动消失。用cargo check而不是cargo build。check 只做类型检查不生成代码快很多。按模块隔离。把出问题的模块临时注释掉先让其他模块过再单独攻这个模块。我踩过最坑的一次是泛型约束的循环依赖——A 的约束依赖 BB 又依赖 A编译器报的错完全指不到根因。最后是靠把其中一个约束改成 trait object 才解开。4.2 性能回退的定位方法移植完成后我发现某些场景比原 tsc 还慢。定位性能问题我用的是火焰图 手动埋点的组合。火焰图能看出热点在哪个函数但看不出为什么热。手动埋点则是在关键路径上打时间戳看具体哪一步耗时。两者结合我定位到一个问题符号表用了 HashMap但哈希函数对字符串键性能很差。换成 FxHashMap 之后符号查找快了约 40%。问题现象排查手段根因解决整体编译慢火焰图符号查找热点换 FxHashMap增量编译不生效埋点日志依赖图未更新修复脏标记传播内存持续增长堆分析arena 未分代引入分代回收递归类型栈溢出断点调试形状比较无终止加访问标记4.3 语义对齐的边界情况有些 TS 语义在 Rust 里很难精确复刻我列几个典型的this类型。TS 的this是动态的Rust 没有对应概念。我的处理是在类型检查阶段把this解析成具体的接收者类型运行时不需要这个概念。声明合并。TS 允许同名接口合并Rust 不允许。我在符号表里对同名声明做合并处理对外表现为一个符号。类型断言as。这个在 Rust 里没有直接对应我把它处理成类型转换节点检查阶段放宽兼容性要求。这些边界情况加起来大概占了总工作量的 20%但它们是决定能不能跑通真实项目的关键。只跑玩具例子的话这些都可以不管但一旦上真实代码库立刻暴露。4.4 常见问题速查表问题可能原因快速排查解析器卡死无限循环的 lookahead检查 token 消费逻辑类型检查漏报符号表作用域链断裂打印作用域栈增量结果不一致依赖图脏标记遗漏对比全量编译结果内存暴涨arena 未释放检查节点分配频率编译结果与原版不符语义边界未对齐跑对照测试用例提示遇到结果不一致类问题最有效的办法是二分定位——把出问题的源文件不断删减直到找到最小复现。这个过程枯燥但极其有效。5. 成本控制与工程化反思5.1 42 万美元到底花在哪了回到开头那个数字。前一次尝试的 token 消耗主要花在三个地方大量重复的上下文注入每次让模型看整个文件、反复的失败重试翻译出来的代码编译不过再让模型改、无节制的全量分析让模型分析整个项目结构。第二次能两周跑通成本大幅下降核心是把模型用在刀刃上只让它做局部决策比如某个类型怎么映射而不是全局翻译。全局的架构决策由人来做模型只负责填充细节。这个分工是成本控制的关键。5.2 人机分工的边界在哪我的体会是架构、分层、接口设计这些结构性决策必须人来做因为模型缺乏对整体一致性的把握容易局部最优全局崩盘。而具体的代码填充、边界情况枚举、测试用例生成模型效率远高于人。一个具体的例子类型映射表的设计我花了半天定下来然后让模型按这个表去填充几百个具体类型的映射代码半天就填完了。如果让人一个个写至少一周。5.3 后续可扩展的方向这个项目跑通之后我想到几个可以继续做的方向。一是把增量编译做到符号级进一步缩短大项目的编译时间。二是接入语言服务协议让编辑器能直接用这套 Rust 实现做补全和跳转。三是做跨语言的类型互操作让 Rust 代码能直接调用 TS 定义的类型。最后分享一个小技巧跨语言重写这种大工程一定要先写一个最小可编译版本哪怕它什么逻辑都不做只要能编译通过、能跑测试框架就是一个可用的起点。我见过太多项目卡在想一次写对上结果永远停在设计阶段。先让它跑起来再让它跑对最后让它跑快——这个顺序是我踩了无数坑之后最想告诉后来人的。