# 抛弃 WASM:用 TypeScript 重写 Rust 解析器,速度提升 3 倍
从 Rust/WASM 切换到 TypeScript 来解析浏览器中的自定义 DSL,消除了运行时边界开销,并实现了增量算法,将总流处理时间缩短 2.6–3.3 倍。
openui-lang 解析器架构
解析器将语言模型(LLM)生成的自定义 DSL 转换为 React 组件树。处理流程分为六个连续阶段,每个阶段负责特定任务:
- 自动闭合器 — 通过添加最少的闭合括号和引号,确保部分文本的语法正确性。
- 词法分析器 — 单次扫描输入字符串,生成类型化令牌。
- 分割器 — 将令牌流拆分为独立的
id = expression指令。 - 语法分析器 — 使用递归下降方法,从表达式构建抽象语法树(AST)。
- 解析器 — 解析变量引用,支持声明提升,并检测循环依赖。
- 映射器 — 将内部 AST 转换为供 React 渲染的公共
OutputNode格式。
每个阶段对整体性能都至关重要,因为解析器会在 LLM 输出流的每个数据块上运行。
问题所在:WASM–JS 边界开销主导性能
原始 Rust 实现编译为 WebAssembly 时,解析本身并不慢——真正拖后腿的是跨运行时的数据传输成本。每次调用都涉及:
- 将字符串从 JS 堆复制到 WASM 线性内存。
- 在 Rust 中将解析结果序列化为 JSON。
- 将 JSON 字符串复制回 JS 堆。
- 通过
JSON.parse()将字符串反序列化为 JS 对象。
尝试使用 serde-wasm-bindgen 实现直接对象传输(无需 JSON 序列化),性能反而下降 9–29%。这是因为它递归地将 Rust 数据结构转换为原生 JS 对象,引发大量细粒度运行时跨越。
解决方案:完整 TypeScript 移植 + 增量算法
彻底抛弃 WASM,转用 TypeScript 消除了数据传输开销。但真正改变游戏规则的是流式优化的增量方法。
朴素版本在每个新数据块到来时,从头重新解析整个累积字符串,导致相对于数据块数的二次方 O(N²) 复杂度。
我们引入了指令级缓存的增量策略:
- 以零嵌套级别以换行符结尾的指令视为不可变。
- 首次解析后缓存其 AST。
- 新数据块到来时,仅重新解析尾部(未完成)指令。
- 最终结果合并缓存的 AST 与新鲜尾部解析。
这将复杂度降至线性 O(L),其中 L 是文档总长度。
基准测试与性能结果
我们在三个真实的 LLM 生成测试集上进行了测试。
单次解析调用(中位时间,微秒):
| 测试集 | TypeScript | WASM (via JSON) | 加速比 |
|--------------|------------|-----------------|---------|
| simple-table | 9.3 | 20.5 | 2.2x |
| contact-form | 13.4 | 61.4 | 4.6x |
| dashboard | 19.4 | 57.9 | 3.0x |
完整流处理成本(中位时间,微秒):
| 测试集 | 朴素 TS (O(N²)) | 增量 TS (O(N)) | 加速比 |
|--------------|-------------------|-----------------|---------|
| simple-table | 69 | 77 | — |
| contact-form | 316 | 122 | 2.6x |
| dashboard | 840 | 255 | 3.3x |
simple-table 无加速是因为它只有一个指令,无可缓存内容。收益随指令数量线性增长。
关键经验
- 在频繁调用的 Rust/WASM 场景中,真正杀手不是计算——而是跨边界的数据复制和序列化。
- 通过
serde-wasm-bindgen的直接 WASM-to-JS 对象传递,可能比优化后的 JSON 还慢,因为内部转换无休止。 - 算法优化(O(N²) 到 O(N))往往胜过语言或运行时微优化。
- WASM 适合低交互任务(如图像处理),但在频繁解析为复杂 JS 对象的场景中表现糟糕。
何时使用 WASM(何时不用)
✅ WASM 胜出:
- 计算密集型任务,批量输入 + 标量/原地输出(媒体处理、加密、物理模拟)。
- 将现有 C/C++ 库移植到浏览器,无需完整重写。
❌ WASM 失败:
- 频繁解析结构化文本,需要返回复杂 JS 对象。
- 输入微小、热点路径,边界开销无法摊销。
— Editorial Team
暂无评论。