系列目录
「JSVM3 原理与思考」系列,基于 jsvm3 源码整理:
- 从 AST 直驱到字节码:JSVM3 的编译与执行分层
- JSVM3 如何执行字节码:指令、栈帧与求值栈
- JSVM3 如何实现函数调用、作用域与声明提升
- JSVM3 如何编译循环、跳转与 try/catch/finally
- 用测试定义解释器的语义边界
- JSVM3 的预编译、序列化与运行时优化(本篇)
前五篇的重点是把源码编成 Script,再由 Fiber/Frame 执行。这个结构还留下两个工程问题:Script 如何离开编译进程,以稳定的数据形态传给另一个运行环境;运行时和传输产物怎样同时控制体积、执行开销与可排查性。本文只依据仓库现有实现和 benchmark/readme.md 的记录讨论这两件事。尤其是性能部分,结论只限于这份记录给出的各轮分数,不把某次代码改动直接写成通用收益。
字节码之后为什么仍需要序列化协议
字节码把 AST 的树形结构变成了 Script.instructions 指令数组,但内存中的 Script 仍不能直接作为网络协议使用。它包含 Instruction 对象;对象上除了参数,还有 run 函数、操作码 id、调试名及栈深度计算方法。函数不能通过 JSON 传输,子函数 children、异常表 guards 和 RegExp 也各有需要明确的恢复方式。
JSVM3 的边界是明确的:编译期负责生成可传输的 Script 表示;端侧只拿 JSON 和运行时恢复并执行,不必重新解析源码,也不必加载编译器。这个分离只在几个条件成立时才可用:编译端与端侧运行时对操作码编号、参数位置和 Script 数组字段顺序达成一致;端侧提供 InsMap 中对应的指令工厂;宿主对象、全局对象和语言支持范围也仍需满足目标代码的需要。服务端预编译、端侧执行只是现有序列化接口支持的部署方式,仓库没有提供服务端分发模块。序列化协议并不能扩大第 5 篇讨论过的语义边界。
src/utils/convert.ts 的 scriptToJson() 使用数组而非带字段名的对象,当前构造的数组包含 10 个元素,索引为 0~9:
[
fName 或 0, // 0
name 或 0, // 1
[[opcodeId, ...args]], // 2:instructions
[childScriptJson], // 3:children
localNames, // 4
[[start, handler, finalizer, end]], // 5:guards
stackSize, // 6
strings, // 7
["source/flags"], // 8:regexps
globalNames // 9
]这里的“字节码”不是裸指令编号数组。局部变量的名字表、全局名字表和字符串表仍被保留,分别供 GETL/SETL、GETG/SETG、STRING_LITERAL 的参数索引使用;子函数是递归的 Script JSON;Guard 的四个地址与第 4 篇的异常控制流一致;正则表达式由 regexpToString() 编成 source + '/' + g/i/m,恢复时按最后一个 / 分隔并调用 new RegExp(source, flags)。
每条指令的首项来自 Instruction.id。例如 src/opcodes/opIdx.ts 将 GETL、SETL、ADD、JMP、FUNCTION 等操作码定义为数字常量;src/opcodes/utils.ts 的 createOP() 在模块加载时把每个 id 对应的工厂注册到 InsMap。instructionsFromJson() 读取 [id, ...args] 后以 InsMap.get(id) 取得工厂,重新创建含有 run 函数的运行时 Instruction。因此 JSON 里不需要传输实现函数,协议的兼容性则直接依赖 id 到指令实现的映射不变。
仓库还提供了 scriptToJsonObject()。它输出 fName、instructions、children 等具名字段,Script.toJSON() 在 process.env.JSVM_DEBUG 为真时选择这一形态,便于观察。此分支下 instructionsToJson() 还会把每条指令写为 [name, ...args];fromJson() 则读取紧凑数组,并以数字 id 查询 InsMap。因此对象形态既不是数组协议,也不能直接作为当前反序列化器的输入;可读调试表示与可分发表示是两条不同的路径。
JSON 往返测试验证了什么
__tests__/fromJson.test.ts 的 runViaJson() 把验证过程写得很直接:先用 transform(code, 'test.js', { hoisting: true, convertES5: false }) 生成 Script,接着执行 JSON.parse(JSON.stringify(scriptToJson(script))),再调用 fromJson(),最后交给新的 JSVM 执行并读取 module.exports。
测试只有两个行为断言:
module.exports = 1 + 2经往返后得到3;- 定义递归
fibonacci,并导出fibonacci(10),经往返后得到55。
第二个用例比算术用例多覆盖了子 Script 的递归恢复和函数调用路径。测试没有对原 Script 与恢复后对象做逐字段深比较,也没有枚举 Guard、正则、所有操作码和异常语义。因此它能证明两段指定程序在 JSON 往返后仍可执行并得到预期结果,不能证明协议的每个字段、所有输入和跨版本兼容性都已覆盖。对于下发协议,这种区别需要保留:一条新字段或新操作码进入协议后,应补与它对应的往返行为用例。
还有一个实现细节值得如实记录。fromJson() 会读取 json[10] 作为 source,而当前 scriptToJson() 只构造索引 0 到 9,并把写入 source 的两行注释掉。所以紧凑 JSON 往返得到的 source 为 null;源码文本没有进入当前实际输出的紧凑协议。文章后面的反汇编调试也不依赖它,而是读取恢复后 Script 的指令、索引表与名称。
构建阶段压缩了什么
传输 Script 只是分离编译器和运行时的一半;端侧还要携带 VM 本身。rollup.config.js 的配置可以确认几类实际动作。
首先,配置在构建开始时读取 src/opcodes/opIdx.ts,得到 OPCodeIdx 对象,构造 minifyObj,再交给 Babel 的 transform-define 插件。也就是说,源码中的 OPCodeIdx.ADD、OPCodeIdx.GETL 等访问会被替换为具体数字,而不是让运行时保留整张操作码常量表的属性查找。src/opcodes/ins.ts 中的指令定义仍以常量名字书写,构建产物使用数值,这是可读源码和紧凑产物之间的分工。
其次,rollup-plugin-preprocess 对 TypeScript 文件传入 VM: true、CURRENT: 'all'。源码中可见 // @ifdef COMPILER、// @if CURRENT != 'exp' 一类条件段,例如 Script.toJSON()、操作码的编译期元数据和不同 Realm 内建内容。这里能确认条件预处理是构建链的一部分,且其上下文如上;不能只凭这份默认配置推断所有目标构建一定去除了哪些代码,因为具体保留范围还取决于条件表达式和其他构建入口。
最后,Rollup 将产物输出为 CJS 和 ES Module,均生成 sourcemap;Babel 目标为 Safari 10 与 Android 53;随后 Terser 开启模块级 mangle,保留 exec 属性名。配置中还自定义了一个 Babel visitor:只把 evalStack.push、pop、top 三个调用属性分别改成 p、u、t。这是一项针对热点内部接口的静态改名,不是对所有方法的全局重命名。
这些步骤说明构建期确实做了常量内联、条件分支处理、内部接口短名化和 Terser 压缩,但仓库没有提供“构建前后体积减少多少”的记录。因而不能从配置本身推出体积百分比,更不能把构建阶段的静态压缩等同于某一类程序的运行时加速。
作用域索引:把名称解析留在编译期
第 3 篇已经解释了 GETL [scopeDepth, varIndex]。这一篇从优化边界补充两个事实。src/compiler/emitter.ts 的 scope() 在编译期遍历 this.scopes,找到标识符时直接返回跨函数作用域深度和变量槽位;scopeGet()/scopeSet() 随即发射 GETL/SETL。declareVar() 为局部名称分配 varIndex,而运行时 Scope.get()/Scope.set() 只是对 data[i] 做数组读取和写入。
运行时仍会处理闭包层级:GETL 的实现先按第一个参数循环走 scope.parentScope,再读取指定槽位。优化点不是“完全没有父链”,而是父链长度和槽位已由编译期写入指令,运行期不再按变量名搜索作用域或做名字哈希查找。对于当前帧的局部变量,深度为 0,路径就是一次数组索引;对于外层闭包,成本随既定深度增长。
这也解释了 benchmark/readme.md 里一项被撤销的尝试。文档原文将它标为“负向优化,撤销”,描述为“不 while 获取 parent 值,直接暂存在一个数组里面通过索引去获取”。对应分数是 Richards 20.3、Crypto 29.4、RayTrace 52.0、NavierStokes 58.5、DeltaBlue 18.6。它紧随一组 Richards 26.2、Crypto 37.2、RayTrace 68.2、NavierStokes 76.5、DeltaBlue 26.9 的记录之后;记录没有说明测试机、单位、具体代码版本或统计方法,只能确认作者将该轮结果判断为负向并撤销。不能把这组数字解释为跨环境可复现的绝对性能结论。
第 2 篇留下的 evalStack.tail(2) 伏笔也应放在这里看待。它通过一次索引下移和数组切片取出栈顶多个值,替代连续 pop();GET、SET 旁仍保留旧写法的注释。这个实现选择可能减少方法调用,但 benchmark 记录没有逐轮对应代码改动,也没有隔离其他变量,不能把任何一组分数直接归因于它。优化假设仍需由可复现、固定环境和比较口径的基准验证。
“调整参数格式”为什么必须按基准验证
benchmark/readme.md 一共保留了五组 Richards、Crypto、RayTrace、NavierStokes、DeltaBlue 的数值。第一组为 21.5、29.8、54.7、66.2、23.7;第二组是上一节的 26.2、37.2、68.2、76.5、26.9;第三组是被标为负向并撤销的作用域父链缓存尝试;第四组标题为“调整参数格式”,数值为 Richards 28.6、Crypto 39.4、RayTrace 53.4、NavierStokes 61.8、DeltaBlue 31.3;第五组标题为“计算指令函数展开,函数参数调用压缩,数组/对象 声明加速”,数值为 23.6、26.9、80.7、72.0、38.7。
各轮在五项基准上的变化方向并不一致,因此不能只按某一项最高分判断优化效果。以第二组与“调整参数格式”一组逐项比较:Richards、Crypto、DeltaBlue 的数值更高,RayTrace 与 NavierStokes 更低。第五组又呈现另一种组合:RayTrace、NavierStokes、DeltaBlue 高于“调整参数格式”一组,Richards、Crypto 更低。文档没有给出总分、单位和评判规则,无法据此宣布哪一轮整体最快,也无法证明“调整参数格式”是正向或负向优化。
真正被明确写作负向的是第三组作用域父链缓存方案,而不是第四组“调整参数格式”。大纲中若把“调整参数格式”称作负向案例,与文档不一致,需要更正。更可靠的表述是:参数格式调整展示了不同基准会向不同方向变化;父链缓存方案则被记录明确标记为负向并撤销。两者共同支持同一个工程结论:优化假设必须以代表性基准验证,并在改动前定义比较口径。仅凭“少一次循环”“少一次对象访问”或“参数更紧凑”都不足以断言收益。
基准也不能替代语义测试。前者只观察特定负载的性能数值,后者检查解释器在给定输入下的行为。涉及 GETL、指令参数或栈操作的改动,至少要同时运行回归测试和基准;性能变好而闭包、异常或调用语义改变,仍然不是可接受的优化。
预编译后怎样保留排查能力
紧凑数组和数字操作码降低可读性。JSVM3 并未把排查能力全部放在 JSON 源码字段中,而是保留 Script 的元数据与独立反汇编工具。demo/src/disassemble.ts 的 disassembleScript() 递归遍历 Script 及其 children,按 ip 输出每条 Instruction.name 与参数。
它还用现有索引表补回人能阅读的信息:GETG/SETG 由 script.globalNames[args[0]] 注释名字;GETL/SETL 依据 scope depth 和祖先 Script 的 localNames 推导局部或闭包变量;FUNCTION 通过 children 下标显示子脚本名;STRING_LITERAL 从 strings 表取回文本。对跳转指令,工具把首参数格式化为 -> NNNN。这些数据都来自 Script 的可执行结构,而不是重新解析原始 JS。
这条路径有明确限制。紧凑 scriptToJson() 默认保存的是数字操作码 id,反汇编需要已恢复的 Instruction 才能获得 name;JSVM_DEBUG 下的具名对象 JSON 方便人工观察,但不能直接交给当前 fromJson();前面提到的 source 也没有写入紧凑数组。若产品要求端侧直接定位源码行、跨版本回放或离线审计,就需要在协议之外明确增加版本号、源码或映射信息,并为这些字段补测试。当前仓库已经有 LINE、COLUMN 操作码,Frame 也保存行列状态,但这不等于现有紧凑 JSON 带有完整源码映射。
系列小结
六篇文章从同一条实现链的不同环节展开:第 1 篇说明源码如何经 Emitter 变为 Script;第 2 篇分析 Frame、求值栈和临时状态;第 3 篇讨论函数、Scope 与声明提升;第 4 篇解释跳转、Guard 与异常传播;第 5 篇用测试限定可确认的语义范围;本文补上预编译产物的序列化、构建与调试路径。
JSVM3 的结构使“编译一次、传输 Script、端侧恢复并执行”成为可实现的流程,但协议、运行时版本、宿主环境和语义覆盖都仍是前提。性能改动也应沿用同样的审慎标准:先明确行为不变的测试证据,再用能代表目标负载的基准判断是否保留。仓库里被撤销的作用域缓存尝试和各轮分化的 benchmark 结果,正是这个判断过程留下的证据。

