惰性解析:V8 的两层策略
前面所有引擎都假设「解析就是把整份输入变成完整的树」。启动时间敏感的场合会质疑这个假设:一个几兆字节的 JavaScript 包,首屏真正执行的代码可能只占百分之几。为那百分之九十几建 AST 是纯粹的浪费。
V8 的对策是两条解析路径。立即执行的代码走急切解析:建完整 AST、建作用域、查语法错误。暂不执行的函数体走预解析:扫到结尾、查语法错误,但不建 AST、不编译;等它真被调用,才回头完整解析一遍。
1 · 套到 JSON 上
本仓库的 lazy-parse 模块把这套策略原样搬到 JSON,载体复用 json 模块的 lexer 与 AST。对应关系是:
| V8 | JSON 复刻 |
|---|---|
| 语句(立即执行) | 标量值,恒急切 |
| 函数体(暂不执行) | 容器值(object / array),默认惰性 |
| 被调用时完整解析 | 被 get / value 访问时 force |
预解析对一个容器体做两件事:校验它合法,记下它的 token 区间。产物是一个占位节点,没有子树。
@vega/parsing/lazy-parse 的真实模块。点击任一占位即 force 一层(孙级容器重新成占位);可调急切深度,或选取「语法错误藏在惰性体里」的示例观察错误仍在预解析阶段被报出。2 · Builder 与 PreParser
模块内部有两条分立的代码路径,对应 V8 的 Parser 与 PreParser:
Builder 逐节点构造骨架。PreParser 走同一份文法但不分配任何节点,只推进游标。两者共用文法逻辑而分离构造行为,这个结构是关键,它保证了「预解析扫过的 token 数等于全量 lex 的 token 数」。
这条等式的后果是:语法错误即便落在永不访问的惰性体里,也在预解析阶段就被报出。图 1-1 里那个把 [1,,2] 藏在惰性体里的示例可以验证。V8 的行为与此一致:写错的函数体即便从未被调用,脚本加载时也会抛 SyntaxError。
注 · 这个设计选择并非必然。理论上可以「连语法都不查,等真访问再说」,那样能更省。V8 没这么做,因为 JavaScript 规范要求脚本在加载时就报出全部语法错误——早期错误(early error)是语言语义的一部分。JSON 没有这个要求,但本模块照做了,为的是保持与 V8 的对照关系完整。
3 · 可观测的三项计数
省下的 AST 分配:急切节点数远小于全量解析的节点数。存在嵌套容器时严格小于,这是惰性的全部收益。
语法早查:预解析扫过的 token 数等于全量 lex 的 token 数,坏文档于是在这一阶段即失败。
double-parse 成本:force 时要重扫该体的 token。这是惰性的代价,也是它可能倒亏的原因。
定理 3.1 设文档共 个 token。若最终访问了全部惰性体,则总扫描量约为 $2n(一次预解析加一次完整解析);若只访问了比例 $p 的体,则约为 。
推论直接给出适用条件:
小则净赚,
接近 1 则倒亏,且倒亏的幅度约是一倍。V8 的工程经验正是如此:无脑惰性化反而更慢。它的启发式(函数是否被立即调用、是否在 IIFE 里、代码位置)就是在猜
,而 preParse 的显式括号 (function(){})() 之所以曾被推荐,就是为了给这个猜测一个明确信号。
4 · force 也是惰性的
实体化一个占位只展开一层:孙级容器重新变成占位。图 1-1 里点开一个 object 后,它成员里的容器仍是 ⏳。
这镜像 V8 的逐调用级联编译:外层函数被调用时编译它自己,它内部的嵌套函数仍留待各自被调用。若 force 一次就深度展开到底,惰性策略在深嵌套结构上的收益会被一次访问全部抹掉。
急切深度参数控制的是初始那一层的边界:深度 1 表示只有顶层容器急切构造,深度 2 表示顶层与它的直接容器成员都急切。这个参数的最优值取决于访问模式,刚好覆盖「几乎肯定会访问」的那几层最好。
5 · 同一想法的其他落点
「先只知道边界、需要时再解析」这个模式在很多地方出现:
-
simd-json的 tape:容器节点互记 matching 下标,由此可以 跳过整个数组或对象而不解析其内容。这是同一想法的另一种实现:不延迟构造,而是让跳过变得免费。 - 数据库的列式存储:只读取查询涉及的列,其余列的字节连解压都不做。
- 模块系统的动态
import():延迟到调用点才加载与解析那一段代码,把决策权从引擎的启发式交回给作者。 - 图片格式的渐进式解码:先解出低分辨率的骨架,细节按需补齐。
共同的判据是:产物的哪一部分会被真正用到,事先不确定。若确定全部会用到,任何形式的惰性都只是纯开销。
6 · 参考文献
- Zaks, T. (2019). Blazingly fast parsing, part 1: Optimizing the scanner & part 2: Lazy parsing. V8 blog.
- Langdale, G., & Lemire, D. (2019). Parsing gigabytes of JSON per second. The VLDB Journal, 28(6), 941–960.
- ECMA International. (2024). ECMAScript Language Specification (ECMA-262), §5.2.4 Early Error Rules.