前端 · 2026年3月21日

Rust WASM编译到浏览器反而更慢?OpenUI用TypeScript重写parser快了3倍

前两天OpenUI团队发了篇技术博客,标题直接就是”We rewrote our Rust WASM Parser in TypeScript – and it got 3x Faster”。

这个结论反直觉到让人想骂街。Rust编译成WASM,不是应该吊打JavaScript吗?性能不是WASM最大的卖点吗?

但数据摆在那里,他们不是在吹牛。

背景:为什么要用WASM

OpenUI的parser做的事情是把LLM输出的自定义DSL转成React组件树。这个parser在流式场景下跑——每收到一个chunk就要parse一次,延迟非常敏感。

整个pipeline有6个阶段:

autocloser → lexer → splitter → parser → resolver → mapper → ParseResult

用Rust写parser,编译成WASM放浏览器里跑,听起来很合理对吧?Rust快,WASM接近原生性能,parser又是CPU密集型任务。

问题出在他们没算的那笔账上。

WASM的隐藏成本:边界开销

每次调用WASM parser,真正的执行流程是这样的:

JS世界                          WASM世界
──────────────────────────────────────────
wasmParse(input)
  │
  ├─ 拷贝字符串: JS堆 → WASM线性内存 (分配 + memcpy)
  │
  │                   Rust解析 ✓ 很快
  │                   serde_json::to_string() ← 序列化结果
  │
  ├─ 拷贝JSON字符串: WASM → JS堆 (分配 + memcpy)
  │
  JSON.parse(jsonString) ← 反序列化结果
  │
  return ParseResult

Rust解析本身从来不是瓶颈。开销全在边界上:字符串拷贝进去,结果序列化成JSON,JSON拷贝出来,V8再反序列化回JS对象。

一进一出,两次内存分配,两次memcpy,一次JSON序列化,一次JSON反序列化。parser本身跑得再快也被这些overhead吃掉了。

试过绕开JSON序列化,更慢了

他们想到了一个方案:用serde-wasm-bindgen直接返回JsValue,跳过JSON序列化这一步。

结果?慢了30%

原因也很反直觉。JS没法直接读WASM线性内存里的Rust struct字节——两个runtime的内存布局完全不同。serde-wasm-bindgen要做的事情是递归地把Rust数据”物化”成JS的array和object,这意味着每次parse()调用都要做大量细粒度的跨runtime边界转换。

而JSON方案虽然多了一步序列化,但serde_json::to_string()是纯Rust执行,零边界穿越,产出一个字符串,一次memcpy拷贝到JS堆,然后V8用原生C++实现的JSON.parse()一次性处理。少量大操作打败大量小操作。

实测数据:

测试用例 JSON往返(µs) serde-wasm-bindgen(µs) 变化
simple-table 20.5 22.5 慢9%
contact-form 61.4 79.4 慢29%
dashboard 57.9 74.0 慢28%

这条路堵死了。

直接用TypeScript重写:快了2-4.6倍

既然WASM的边界开销是根本问题,那就别跨边界了。他们把整个parser pipeline用TypeScript重写——同样的6阶段架构,同样的ParseResult输出格式,完全跑在V8堆里。

单次parse的对比数据(1000次迭代取中位数):

测试用例 TypeScript(µs) WASM(µs) 加速比
simple-table (~180字符) 9.3 20.5 2.2x
contact-form (~400字符) 13.4 61.4 4.6x
dashboard (~950字符) 19.4 57.9 3.0x

没有拷贝开销,没有序列化/反序列化,V8的JIT编译器对这种重复调用的热路径优化得非常好。

流式场景下的进一步优化:增量缓存

单次parse快了还不够。流式场景下parser会被反复调用,朴素的做法是每收到一个chunk就把累积的字符串从头parse一遍:

Chunk 1: parse("root = Root([t")           → 14字符
Chunk 2: parse("root = Root([tbl])\ntbl = T") → 27字符
Chunk 3: parse(完整累积字符串)                → ...

1000字符的输出如果每20字符一个chunk,就是50次parse调用,累计处理约25000字符。O(N²)复杂度。

OpenUI的解决方案是语句级增量缓存。核心思路:已经被换行符终结的完整语句不会再变,缓存它们的AST,每次只重新parse最后那个未完成的语句。

interface StreamState {
  buf: string;
  completedEnd: number;
  completedSyms: Map<string, ASTNode>;
  firstId: string | null;
}

function push(state: StreamState, chunk: string): ParseResult {
  state.buf += chunk;
  
  // 1. 从completedEnd开始扫描,找深度为0的换行符
  // 2. 每找到一个完整语句:parse + 缓存AST → 推进completedEnd
  // 3. 最后那个未完成的语句:autoclose + 重新parse
  // 4. 合并缓存 + pending → resolve + map → 返回结果
}

完整流式处理的总耗时对比:

测试用例 朴素TS重新parse(µs) 增量缓存TS(µs) 加速比
simple-table 69 77 无提升(单语句)
contact-form 316 122 2.6x
dashboard 840 255 3.3x

simple-table只有一个语句所以没法缓存,但多语句场景下提升很明显。

什么时候该用WASM,什么时候不该

OpenUI这个案例给了一个很清晰的判断框架:

WASM适合的场景:

  • 单次调用处理大量数据(图像处理、视频编解码、加密计算)
  • 计算密集到边界开销可以忽略不计
  • 返回值简单(一个数字、一小段buffer)

WASM不适合的场景:

  • 高频调用,每次处理少量数据(比如流式parser每个chunk几十个字符)
  • 返回复杂的嵌套JS对象(AST、组件树这种)
  • 需要和JS对象频繁交互

关键不是”Rust比JavaScript快”这个常识,而是总成本 = 计算成本 + 边界成本。当单次计算成本很小的时候,边界成本就成了主导因素。

V8没你想的那么慢

这个案例还暴露了一个认知偏差:很多人低估了现代V8引擎的能力。

V8跑热代码路径的时候,JIT编译器会生成高度优化的机器码。对于parser这种模式固定、调用频繁的场景,V8的Hidden Class和Inline Cache机制非常有效。加上不用跨边界,不用序列化,所有对象都直接在JS堆上分配——省掉的不只是复制时间,还有GC压力。

当然这不是说WASM没用。搞音视频处理、跑物理模拟、做密码学运算,WASM的优势是实打实的。但对于”把结构化文本变成JS对象”这类任务,你可能高估了WASM的收益。

对我们自己项目的启示

我在看这篇文章的时候一直在想,多少前端项目在用WASM的地方其实用纯TS更快?

特别是这几种情况需要警惕:

1. WASM调用频率很高但单次数据量很小——边界开销占比会很大
2. WASM返回复杂对象需要JSON序列化——双向序列化的成本可能比你想的高得多
3. “Rust更快所以WASM一定更快”的直觉——忽略了跨runtime的固定开销

做技术选型的时候,benchmark说了算,直觉不算。OpenUI团队做对的一件事是:发现慢了之后没有在WASM的框架里死磕优化(比如换更快的序列化库、用SharedArrayBuffer之类的),而是退一步看清瓶颈的本质——跨runtime边界本身就是问题——然后直接砍掉这个边界。

有时候最好的优化不是把现有方案做到极致,而是换一个完全不同的方向。

参考:OpenUI Blog – Rewriting our Rust WASM Parser in TypeScript