前两天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