使用TS 开发 JS 虚拟机

📅
1 分钟阅读
·

背景

小程序和低代码平台需要执行动态下发的代码。当时调研到的部分 VM 未完整通过 ES5 或 ES2015 测试用例,不能满足生产环境的兼容性要求,因此实现了自己的 JSVM 引擎。

目前已放到 github 上 :jsvm 2 传送门。 目前除了 WithStatement 语句外,其他 es5 特性均支持,单测覆盖 91%

原理

基于语法树的 JSVM 需要遍历 AST,并为 Babel 的 ProgramVariableDeclaration 等节点实现对应的执行逻辑。下面是一个简化的 VM:

const babelParser = require("@babel/parser");
const babelTraverse = require("@babel/traverse").default;

// 执行引擎
class ExecutionEngine {
  constructor() {
    this.globalScope = {}; // 全局作用域
  }

  // 执行语法树
  execute(ast) {
    babelTraverse(ast, {
      enter: (path) => {
        const node = path.node;
        switch (node.type) {
          case 'Program':
            this.executeProgram(node);
            break;
          case 'VariableDeclaration':
            this.executeVariableDeclaration(node);
            break;
          case 'Identifier':
            return this.executeIdentifier(node);
          case 'NumericLiteral':
            return this.executeNumericLiteral(node);
          case 'BinaryExpression':
            return this.executeBinaryExpression(node);
          // 处理其他类型的语法树节点
          // ...
        }
      }
    });
  }

  // 执行 Program 节点
  executeProgram(node) {
    for (const statement of node.body) {
      this.execute(statement);
    }
  }

  // 执行 VariableDeclaration 节点
  executeVariableDeclaration(node) {
    const variableName = node.declarations[0].id.name;
    const variableValue = this.execute(node.declarations[0].init);
    this.globalScope[variableName] = variableValue;
  }

  // 执行 Identifier 节点
  executeIdentifier(node) {
    const variableName = node.name;
    return this.globalScope[variableName];
  }

  // 执行 NumericLiteral 节点
  executeNumericLiteral(node) {
    return node.value;
  }

  // 执行 BinaryExpression 节点
  executeBinaryExpression(node) {
    const leftValue = this.execute(node.left);
    const operator = node.operator;
    const rightValue = this.execute(node.right);

    // 执行二元运算
    switch (operator) {
      case '+':
        return leftValue + rightValue;
      case '-':
        return leftValue - rightValue;
      case '*':
        return leftValue * rightValue;
      case '/':
        return leftValue / rightValue;
      // 处理其他运算符
      // ...
    }
  }
}

// 输入 JavaScript 代码
const code = `
  let x = 5;
  x + 10;
`;

// 使用 Babel 解析代码为 AST
const ast = babelParser.parse(code, {
  sourceType: "module",
});

// 创建执行引擎并执行语法树
const engine = new ExecutionEngine();
engine.execute(ast);
console.log(engine.globalScope['x']); // 输出:5

完整实现步骤

  1. 词法分析(Lexical Analysis)和语法分析(Syntax Analysis):将输入的 JavaScript 代码转换为抽象语法树(Abstract Syntax Tree,AST)。这里直接使用 Babel 来完成
  2. 作用域分析(Scope Analysis):在语法树中进行作用域分析,确定变量的定义和引用所在的作用域。这可以通过构建作用域链(scope chain)来实现,每个作用域都有一个指向父级作用域的引用。参考 scope.ts
  3. 变量和函数声明(Variable and Function Declaration):在作用域分析的基础上,将变量和函数声明添加到适当的作用域中。这可以在作用域中创建变量和函数的绑定,并为后续的执行做准备。参考 declare
  4. 执行引擎(Execution Engine):遍历语法树,并执行相应的操作。执行引擎根据语法树节点的类型执行不同的操作,例如变量赋值、函数调用、条件判断等。引擎使用作用域链来解析变量和函数的引用,并根据运行时上下文执行相应的操作。参考 visitor 以及 standard
  5. 作用域和上下文管理:在执行过程中,需要管理作用域和运行时上下文。每次进入一个函数时,会创建一个新的函数执行上下文,包括函数的作用域、参数和局部变量。在函数执行完成后,上下文会被销毁。参考 Function
  6. 值的计算和存储:执行引擎根据需要计算表达式的值,并将结果存储在适当的位置,例如变量、对象属性或函数返回值。参考 statement. tsobject
  7. 控制流管理:执行引擎处理控制流语句,例如条件语句、循环语句和异常处理。根据条件的结果,执行引擎决定执行的路径,并更新程序计数器以指向下一条要执行的语句。参考 conditional
  8. 内建函数和对象:实现一些内建的 JavaScript 函数和对象,例如 console、Array、Object 等。这些函数和对象可以通过在执行引擎中添加相应的逻辑来实现。参考 context

效果

react-case 展示了 JSVM 2 执行 React 的示例。该引擎也已在实际业务中持续运行;本文不据此推导其在所有场景下的可靠性。

性能

主要开销来自反复创建执行上下文。在包含大量 for 循环的场景中,性能比未开启 JIT 的 V8 低 2 个数量级,因此不适合大量数据计算。移动端低端机的分类页录屏测试覆盖了复杂交互场景,录屏中未观察到明显差异;这一结果只适用于该测试条件。

压缩&混淆

Babel 生成的 AST 体积较大,普通的 console.log 语句也会产生较多节点。上线前对 AST 做了等价压缩;压缩结果仍大于原始代码,但可用于当前包体约束。为避免端上解压缩的开销,生产环境的 JSVM 改为直接执行压缩后的 AST 协议。这部分实现未开源,执行原理未变。 image.png

线上问题排查

动态下发代码报错后难以定位源码位置。为此,基于压缩后的 AST 定义了一套 sourcemap 协议,并提供后台调试工具:输入报错 ID 后可反解析到对应的行列位置。 image.png


193 字 · 22 段落
ximing

Follow onGitHub

相关文章