在 JavaScript 生态中,解析器生成器领域长期存在一个核心矛盾:灵活性和性能难以兼得。Ohm 作为基于 PEG(Parsing Expression Grammar)的解析工具包,长期以来采用 AST 解释执行的方式,其性能在处理大规模语法分析时往往成为瓶颈。就在 2026 年 3 月,Ohm 团队发布了 v18 版本,通过将 PEG 语法直接编译为 WebAssembly,实现了超过 50 倍的性能提升,同时将内存占用降低到原来的 10%。这一技术突破背后蕴含的工程思路,对于任何关注编译器优化和 WebAssembly 应用的开发者而言,都具有重要的参考价值。
从解释执行到编译执行的核心转变
理解 Ohm v18 的设计,首先需要回顾其历史版本的工作方式。在 v17 及之前的版本中,Ohm 采用了一种被称为 AST 解释的架构:当开发者定义一个 Ohm 语法时,Ohm 会将语法解析为一棵抽象语法树(AST),树的节点称为解析表达式(PExpr)。每个规则(例如 JSON 语法中的 Value、Object、Member 等)可以类比为编程语言中的函数,而函数体就是解析表达式的组合。Alt、Seq、Opt、Term、Apply 等都是 PExpr 类的子类,它们都实现了 eval 方法来执行实际的匹配逻辑。
这种解释执行的方式实现简单,调试方便,但有一个致命的性能问题:每一次解析都需要递归调用大量的 eval 方法,而这些方法本身又是通过 JavaScript 解释执行的。以 Alt(或运算)为例,其 eval 实现仅仅是遍历所有子表达式,尝试匹配第一个成功的选项。这种运行时 dispatch 的开销,在解析大型文件时会累积成显著的性能损耗。
v18 的突破在于,它不再解释执行这棵 PExpr 树,而是将其编译为 WebAssembly 指令。编译过程分为两个主要部分:其一是运行时支持代码,使用 AssemblyScript 编写;其二是代码生成部分,使用 TypeScript 实现。代码生成从相同的 PExpr 树结构出发,但不再调用解释器,而是将其转换为等价的 WebAssembly 指令序列,然后与运行时支持代码链接,生成最终的 Wasm 模块。
这种设计的关键洞见在于:编译时循环替代运行时循环。以 Alt 表达式为例,在 v17 的解释器中,每次遇到 Alt 都需要在运行时遍历其所有子选项;而在 v18 生成的 Wasm 代码中,这个遍历过程在编译阶段就已经完成。生成的伪代码结构大致如下:首先尝试匹配第一个选项,成功则返回 true;否则尝试第二个选项,以此类推。这种结构消除了运行时的方法调度开销,使得解析速度有了质的飞跃。
线性内存与区域化内存管理
将 JavaScript 编写的解析器移植到 WebAssembly 面临的一个核心挑战是内存管理。v17 中,解析产生的具体语法树(CST)节点是普通的 JavaScript 对象,由垃圾回收器管理。这种方式虽然使用方便,但存在几个显著的性能问题:每个节点相对较小,导致每个节点的内存管理开销较大;一个典型的语法树大约每个输入字符对应一个终结符节点,因此节点数量庞大;节点中包含大量引用,增加了垃圾回收时的扫描开销;而且所有节点的生命周期基本一致 —— 要么整个树都在使用,要么整个树都可以被释放。
这些特征使得 CST 节点非常适合采用基于区域的内存管理(也称为 arena allocation)。v18 利用 WebAssembly 的线性内存(linear memory)实现了一个 bump allocator(也称为 area allocator)。具体做法是使用 AssemblyScript 提供的 stub runtime,每次需要分配节点时,只需将一个指针向前移动即可,无需复杂的内存分配逻辑。每个 CST 节点只占用一个 32 位头部字段,而在 JavaScript 引擎中,一个对象通常需要 3 到 4 个头部字段。这种设计显著降低了内存开销。
对于节点之间的引用,v18 使用 32 位偏移量而不是完整的指针。在 32 位 WebAssembly 中,这种做法是标准做法,既节省空间又提高了缓存局部性。这种技术与 Adrian Sampson 在其博客文章 "Flattening ASTs" 中描述的方法有异曲同工之妙,都是通过紧凑的内存布局来提升编译器数据结构的性能。
终结符节点是优化策略中最激进的部分。由于典型的语法树大约每个输入字符对应一个终结符节点,为每个终结符单独分配一个完整的节点结构是极大的浪费。v18 采用了一种巧妙的方案:使用带标签的 32 位值来表示终结符,编码为 (matchLength << 1) | 1。由于常规的 CST 节点引用总是 4 字节对齐的(低两位为 0),当检测到低两位为 1 时,系统就知道这是一个终结符而非真正的节点引用,其高 31 位存储的就是匹配的字符数。这种编码方案将终结符的内存占用降到了极致。
非终结符、列表和可选节点的布局则稍微复杂一些。它们包含匹配长度、类型和详情字段、子节点数量、失败偏移量,以及子节点引用数组。这种统一的布局使得解析器可以在运行时快速遍历和操作语法树,而无需关心具体节点类型的内部差异。
分块绑定与回溯优化
PEG 解析的一个核心特性是回溯:当某个分支匹配失败时,解析器需要恢复到之前的状态并尝试其他分支。v17 使用动态数组来存储绑定(binding),即解析表达式成功时产生的 CST 节点。这种做法在某些场景下性能不佳,因为每次向数组添加元素时,如果数组容量不足,就需要分配新的底层缓冲区、复制所有元素并释放旧数组,导致较高的性能开销。
v18 采用了一种名为分块绑定(chunked bindings)的优化方案:用未展开的双向链表来管理绑定,每个块固定存储 128 个条目。这种设计的关键在于:push 操作只需要一条存储指令和一次索引递增,只有当索引达到 128 时才会切换到下一个块,而且如果之前有块被回溯丢弃,可以重用那些块。这种方式将最常见操作(push)的复杂度降到了 O (1),且没有动态内存分配的开销。
对于 PEG 解析器来说,回溯的速度至关重要。v18 的回溯只需要恢复两个 i32 值:保存的块指针和块内索引。不需要清除、复制或释放任何元素 —— 后续块中 “废弃” 的槽位会被简单地忽略,在下一次向前匹配时被覆盖。这种设计使得回溯成为一项极轻量的操作,是实现高效 PEG 解析的关键所在。根据 Ohm 团队的基准测试,这一优化单独贡献了 15% 到 16% 的性能提升。
块稀疏记忆化表
Ohm 使用的 packrat 解析技术通过记忆化来避免重复解析:第一次在某个输入位置应用某个规则时,结果会被存储在表中;如果同一规则在同一位置再次被调用,只需查找结果而无需重新计算。理论上,记忆表是一个二维结构,索引为输入位置和规则 ID。
然而,朴素实现的记忆表会浪费大量内存 —— 它有 位置数 × 规则数 个条目,但实际使用时,表格是极度稀疏的,大多数规则在大多数位置上从未被尝试过。
v18 采用块稀疏(block-sparse)表示来解决这个问题:使用一个扁平的位置指针数组来索引块,每个块包含 16 个条目,这些块在首次写入时才被延迟分配。具体而言,索引结构为 index[pos * numBlocks + blockIdx],每个块持有 16 个 i32 记忆条目(64 字节),值为 0 表示该块尚未分配。这种表示方法确保了只分配实际被访问的规则和位置组合,同时保持快速的查询速度。记忆条目的编码同样经过精心设计:成功时存储指向 CST 节点的指针(低两位为 0),失败时编码为 (failureOffset << 1) | 1,空格节点使用 (matchLength << 2) | 2。这种单一位字段就能区分三种状态的编码方式避免了额外数据结构的必要性。
参数化规则与空白跳过优化
Ohm 的参数化规则允许语法定义接受解析表达式作为参数。例如 KeyVal<keyExp, valExp> = keyExp ":" valExp 定义了一个接受两个参数的规则。在 v17 的解释器中,参数化规则通过维护一个规则栈来在运行时处理。在 v18 中,参数化规则通过静态特化(static specialization)来处理:编译器为每种唯一的参数组合生成独立的规则体,本质上相当于宏展开。这意味着参数化规则在运行时不存在任何参数概念 —— 编译器会为 KeyVal<"\"id\"", number> 生成一个专门的规则体 KeyVal$0 = "\""id"\"" ":" number。这种设计简化了运行时语义,并且与记忆化配合良好:不同参数组合的规则应用被当作不同的规则,它们的记忆条目不会相互混淆。
空白跳过是 Ohm 的另一个显著特性:大写字母开头的句法规则会在匹配之间自动跳过空白,语法作者可以自定义空白定义(例如允许注释作为空白)。在 v17 中,隐式空白跳过被当作显式的 spaces 规则应用来处理,需要分配 CST 节点并记忆结果。v18 则采用了一种优化形式:完全避免创建空白节点的 CST 表示,只在解析完成后的遍历阶段按需惰性生成这些节点。对于无人检查空白跳过节点的常见情况,这避免了大量的内存分配。记忆表中的空格节点采用特殊编码 (matchLength << 2) | 2,因为没有 CST 节点可以指向,只需记录 matchLength。
根据 Ohm 团队使用 ES5 语法的测试,在 742KB 源文件上,优化后的空白跳过带来了显著的性能提升,证明了这一优化对于实际语法的重要性。
工程落地的关键参数
对于希望在项目中应用 Ohm v18 的开发者,以下几个工程参数值得特别关注。首先是安装方式,通过 npm install ohm-js@next 安装运行时,通过 npm install --save-dev @ohm-js/compiler@next 安装编译器。编译器应作为开发依赖,而运行时是生产依赖。其次是目标语法选择:v18 目前主要针对纯 PEG 特性进行优化,左递归和参数化规则等功能需要特殊处理。第三是内存预算评估:虽然 v18 的内存占用大幅降低,但由于使用线性内存,需要根据预期输入大小预留足够的 Wasm 内存空间。最后是兼容性考量:生成的 Wasm 模块需要浏览器或运行时支持 WebAssembly 1.0,对于需要极致轻量化的场景可能需要评估目标平台的 Wasm 支持情况。
资料来源:本文核心事实来自 Ohm 官方博客文章《Inside Ohm's PEG-to-Wasm compiler》(2026 年 3 月 12 日),该文详细描述了 v18 的编译架构、内存管理优化和性能基准数据。