JSVM2:为什么小程序需要一台 JS 虚拟机

📅
2 分钟阅读
·

2020 年我写过一篇《使用TS 开发 JS 虚拟机》,介绍用 TypeScript 实现基于 AST 的 JS 解释器。项目在 2021 年 7 月正式立项重写,并于 2022 年 2 月发布 1.0.0。本系列整理其实现过程和已知边界;性能与执行模型问题在 JSVM3 系列继续讨论。本文说明自研虚拟机的原因及整体结构。

业务背景:代码必须能动态下发

小程序和低代码平台需要从远端拉取 JS bundle,并在端上的沙箱中执行。小程序发版需要平台审核,周期以天计;运营活动、页面模板和搭建物料可能按小时更新。低代码平台的页面逻辑由搭建结果生成,需要在运行时确定。动态下发代码是这类业务的前提条件。

同时这段代码不能直接在宿主环境里跑。它来自远程,必须和宿主隔离:能访问什么全局对象、能调什么宿主 API,要由我们控制,而不是由代码自己决定。所以需要的不是一个 eval 的替代品,而是一个带沙箱边界的执行环境。

这个背景不是纸面推演。仓库的 dev.ts 里留着一个当时的真实调试样本,是从我们动态化平台拉下来的 loader bundle,编译后的样子大致如下:

function getTplFromTT(_x, _x2) {
  return _getTplFromTT.apply(this, arguments);
}

function _getTplFromTT() {
  _getTplFromTT = _asyncToGenerator(regeneratorRuntime.mark(function _callee(key, version) {
    var res, bundle, _yield$get, data;
    return regeneratorRuntime.wrap(function _callee$(_context) {
      while (1) {
        switch (_context.prev = _context.next) {
          case 0:
            _context.next = 2;
            return post({
              url: ".../config/theseus/checkList?bundleNames=".concat(key),
              data: { appVersionName: version, platform: 'Android', bundles: [] }
            });
          case 2:
            res = _context.sent;
            bundle = res.data.bundles.find(function (item) {
              return item.bundleName === key;
            });
            // ...拿到 bundle.url 再下载,返回的 data 里带 ast 字段

这段代码有三个地方值得注意。第一,它是 babel 的编译产物,_asyncToGeneratorregeneratorRuntime 这些 helper 说明源码里的 async/await 在构建期就被降级成了 ES5 状态机,解释器实际要处理的是这种工具链生成、风格高度统一的代码,而不是手写玩具。第二,它干的就是动态化的标准流程:拿 key 和版本号去配置服务查 bundle 地址,下载,然后执行。第三,返回数据里直接带 ast 字段,也就是说下发的可以是解析好的语法树而不一定是源码文本,这一点后面讲架构时会用到。

选型:三条路都走不通

动态执行 JS,常规选项有三个。

一是用宿主能力,evalnew Function。微信小程序环境明确禁止这两个 API,这是平台限制,没有绕的空间,直接排除。

二是使用现成的 JS 解释器库。当时以 kangax 的 es5-testsuite 和 Test262 的相关章节作为验收条件。2020 年的文章已记录结论:已评估的 VM 都不能完整通过 ES5 或 ES2015 的测试用例,不能用于生产。变量提升、闭包、this 绑定、原型链、控制流与中止完成之间的任一语义偏差,都可能影响业务代码;下发代码也无法逐行人工审查。

排除前两种方案后,只能自研,需确认工作量是否可控。依据有两点:执行代码是编译产物,构建工具链可以预先限制语法形态,解释器无需覆盖完整的语言语法;解释器的核心机制较少,主要工作在语义覆盖和测试,每项测试都有明确验收条件。项目后续也符合这一判断:核心机制在数周内完成,之后一年多主要处理语义和测试。

Babel 生成 AST,visitor 前序遍历求值

jsvm2 使用 @babel/parser 将源码解析为 AST,再以前序遍历按节点类型分发到对应的求值函数。

这里有两个决策值得展开。一是不自己写 parser。JS 解析已经是被充分解决的问题,@babel/parser 覆盖全部语法且持续跟随标准,自研 parser 只会把人力耗在一个不产生差异化的环节上。真正的难点在语义求值,把解析外包出去,精力才能集中在该集中的地方。二是输入形态可以收窄。因为执行的是编译产物,解释器实际面对的语法分布远比语言全集窄:前面那段 bundle 里,async/await 已经被 regenerator 降级成 function、switch 和 while 组成的状态机,AST 里没有 generator 相关的节点。构建工具链每多承担一步降级,解释器就少实现一类语义,这笔账非常划算。

全景架构按数据流向是这样几环:

  1. 源码在端外由 @babel/parser 解析成 AST。引擎本身不吃源码,只吃 AST,所以 parse 这一步完全可以在构建期完成,下发的直接是语法树,前面 bundle 里的 data.ast 就是这个用意,端上连解析成本都省了。
  2. AST 节点被包装成 Path。Path 除了节点本身,还携带父节点引用、所在作用域、上下文和调用栈,求值过程中需要的环境信息都从 Path 上取。
  3. visitor 按 node.type 查表,把 Path 分发到对应节点类型的求值函数。
  4. 变量的声明和读写沿着 Scope 链进行,Scope 对应 ECMA-262 里的环境记录。
  5. 最外层是 Context,即沙箱的全局对象。宿主想暴露什么能力给沙箱,就注入到 Context 里;没注入的,沙箱代码就摸不到。

该结构不包含自研解析器、字节码或 JIT。可在编译期完成的工作交给 Babel,解释器只处理经工具链限制后的输入形态。Object、Array、Promise 等内建对象直接复用宿主实现,因此原型链和类型转换沿用宿主语义。后续文章会继续说明这两项选择的影响。

入口只有十几行

src/vm.ts:15-27 包含 runInContext 的完整实现:

export function runInContext(ast: any, context = createContext()) {
  const s = Scope.createRoot(context);
  // define module
  const $exports = {};
  const $module = { exports: $exports };
  s.declareConst(MODULE, $module);
  s.declareVar(EXPORTS, $exports);
  const path = new Path(ast, null, s, {}, new Stack());
  const res = visitor(path);
  // exports
  const moduleVar = s.hasOwnBinding(MODULE);
  return moduleVar ? moduleVar.value.exports : res;
}

这十几行把架构图的每一环都落实了:Scope.createRoot(context) 建根作用域并把 Context 挂上去;new Path(ast, null, s, {}, new Stack()) 把 AST 包装成根 Path,带上一个空的调用栈;visitor(path) 开始遍历求值。执行结束后从根作用域里把 module 取出来,返回它的 exports

module/exports 不实现完整模块系统,只在根作用域预声明两个变量:module 指向 { exports: {} }exports 指向同一对象。沙箱代码执行 module.exports = xxx 时使用普通成员赋值;执行结束后,引擎返回 module.exports。前述 loader bundle 末尾的 exports.checkList = checkList 因此可以导出。该 CommonJS 外壳仅依赖两个变量。同一文件的 run 函数(vm.ts:29-34)去除 module 外壳并直接返回求值结果,适用于不需要模块语义的场景。

Context:沙箱的边界在哪

沙箱全局对象的实现在 src/context.ts,思路同样简单。引擎先准备一份内置全局清单 CTX:Infinity、NaN、parseInt、Object、Function、Error 家族、Number、Math、Date、String、RegExp、Array、JSON、定时器、console,再按宿主环境探测补上 Symbol、各类 TypedArray、Map、Set、Promise、Reflect、Proxy 等。注意这些值全是宿主环境里的真身,沙箱里的 Object 就是宿主的 Object,所以原型链、instanceof、各种内建方法的行为不用解释器操心。

Context 的构造器只有一件事:把 CTX 和调用方传入的外部对象合并,逐个 key 挂到自己身上。业务侧想给沙箱开放能力,比如小程序里的 wx.request,就 createContext({ wx }) 注进去;没注的 API,沙箱代码访问时就是未声明变量。动态下发的代码能摸到多大的世界,完全由这一次注入决定。

这里有一个已知限制。context.ts:55-58 有一段探测逻辑:如果宿主环境存在 eval,就把它放进 CTX。本意是兼容浏览器场景,副作用是在浏览器里沙箱代码能拿到宿主 eval,沙箱边界因此多出一个缺口。小程序环境里 eval 本身是 undefined,这个分支自然不会触发,所以线上业务没受影响,但它说明内置清单这种「先全给再控制」的思路是有代价的,更稳妥的方向是默认最小集合、按需开放。

证据链:怎么知道它能用

选型阶段最大的质疑一定是:自研引擎的语义覆盖度凭什么够?当时的回答是一组可以核查的事实,而不是一句「我们测过了」。

语法覆盖上,readme 里的 ES5 清单除 WithStatement 外全部打钩。with 不实现不是偷懒,是实现不了:@babel/parser 在严格模式下直接禁用 with 语句,而引擎整体表现为严格模式,AST 里根本不会出现这个节点,没有可实现的输入。ES2015 是部分支持,let/const、箭头函数、解构、展开运算符这些编译产物里高频出现的特性做了;class、模板字符串、import/export 这些能由 babel 在构建期降级掉的就没做。这张清单是诚实的,打钩和没打钩都写在 readme 里。第 6 篇会拿实测结果逐项核对它,其中有打钩打早了的地方,到时如实说。

测试上,单测覆盖率 91%。用例来源有四类:kangax 的 es5-testsuite、Test262 的精选章节、《JavaScript 高级程序设计》里的经典 case、JS 面试陷阱题。es5-testsuite 和 Test262 是社区维护的规范符合性测试,跑它们是把「我以为实现了」换成「规范认为实现了」;高程 case 和面试题的价值在于它们专门瞄准提升、闭包、this、原型链这些实现者最容易想当然的地方,很多用例人看着都会答错,拿来压解释器正好。测试怎么搭、为什么这样分层、跑通过程中修了什么,是第 7 篇的全部内容。

生态验证上做了两件更硬的事。一是把 lodash 的全量 API 放进 VM 里跑,用 lodash 自己的官方 spec 回归,覆盖真实库里各种边角用法。二是把 React 服务端渲染完整跑了起来,这个在 2021 年 11 月达成,一次跑通的过程中顺手修掉了作用域实现、this 绑定等一批只有真实框架才压得出来的 bug。选 SSR 而不是浏览器渲染,是因为 SSR 输出是字符串,断言稳定,且不依赖 DOM。这两件事的分量和单测不同:单测是解释器作者自己选的考题,真实库和框架不会配合你,它们用到什么语义你就必须答对什么语义,答错就是直接抛错或结果不对。一个能把 React 渲染到字符串的解释器,语义覆盖度基本不需要再口头辩护。

时间线如下:2021 年 7 月立项开发,11 月跑通 React SSR,12 月接入 lodash 回归,2022 年 2 月发布 1.0.0。JSVM2 的 AST 直驱方案完成了生产验证,但执行开销、暂停恢复和端侧分发仍受 AST 结构限制,后续因此重写 JSVM3。本系列记录设计决策、已知问题和未覆盖的语义。

系列目录

接下来六篇按实现层次展开,每天一篇:

日期标题
02-20JSVM2:为什么小程序需要一台 JS 虚拟机(本篇)
02-23JSVM2:解释器的骨架,visitor、Path 与作用域链
02-26JSVM2:变量提升与闭包,两阶段扫描与一道经典面试题
03-01JSVM2:return/break/continue 怎么传,Signal 信号与 finally 的坑
03-04JSVM2:函数、this 与 new,能白嫖的绝不自己写
03-07JSVM2:表达式求值的现实主义,左值、运算符与 ES2015 裁剪
03-10JSVM2:怎么证明一个 JS 引擎是对的

下一篇说明 visitor 分发器、Path 五元组和 Scope 环境记录,以及解释器核心代码的组织方式。


569 字 · 39 段落
ximing

Follow onGitHub

相关文章