编写一个javascript元循环求值器

1,210 阅读4分钟

https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2020/4/13/17171f9193cefd72~tplv-t2oaga2asx-image.image

在上一篇文章中,我们通过AST完成了微信小程序组件的多端编译,在这篇文章中,让我们更深入一点,通过AST完成一个javascript元循环求值器

结构

一个元循环求值器,完整的应该包含以下内容:

  • tokenizer:对代码文本进行词法和语法分析,将代码分割成若干个token
  • parser:根据token,生成AST树
  • evaluate:根据AST树节点的type,执行对应的apply方法
  • apply:根据环境,执行实际的求值计算
  • scope:当前代码执行的环境

代码目录

根据结构看,我将代码目录大致拆分为以下几个文件

  • parser
  • eval
  • scope

tokenizer和parser这两个过程不是本文的重点,我统一放在了parser中,交由 @babel/parser 来处理。

evaluate和apply这两个过程我统一放在了eval文件中处理,一会我们重点看下这部分。

scope则放入scope文件。

evaluate-apply

这其实是一个递归计算的过程。 首先,evaluate 接收两个参数,node 当前遍历的AST树节点和 scope 当前环境。然后,evaluate去根据 nodetype 属性,判断该节点是什么类型。判断出类型后,执行 apply 去求值这个节点所代表的表达式。apply 中会再次递归的执行 evaluate 去计算当前节点的子节点。最终,执行完整颗AST树。

evaluate 的具体实现如下

const evaluate = (node: t.Node, scope) => {
  const evalFunc = evaluateMap[node.type];
  if (!evalFunc) {
    throw `${node.loc} ${node.type} 还未实现`;
  }
  return evalFunc(node, scope);
}

其中,evaluateMap 是所有实现的节点类型集合。 根据 @babel/types ,我们可以把节点类型分为以下这么几类:File、Program、Identifier(标识符)、Literal(字面量)、Statement(语句)、Expression(表达式)

File、Program

这两类是遍历的起点,代码如下

File(node: t.File, scope) {
  evaluate(node.program, scope);
},

Program(node: t.Program, scope) {
  for (const n of node.body) {
    evaluate(n, scope);
  }
},

Identifier

标识符。当遍历到这类节点时,需要从环境中取值,例如

var a = 'hello';
console.log(a); // 这个a对应的ast树节点类型就是Identifier类型

具体实现如下:

Identifier(node: t.Identifier, scope) {
  const $var = scope.$find(node.name);
  if (!$var) {
    throw `[Error] ${node.loc}, '${node.name}' 未定义`;
  }
  return $var.$get();
},

Literal

字面量。当遍历到这类节点时,直接返回值即可。代码如下

StringLiteral(node: t.StringLiteral, scope) {
  return node.value;
},

NumericLiteral(node: t.NumericLiteral, scope) {
  return node.value;
},

BooleanLiteral(node: t.BooleanLiteral, scope) {
  return node.value;
},

NullLiteral(node: t.NullLiteral, scope) {
  return null;
},

Declaration

声明。当遍历到这类节点时,需要在scope中完成声明。声明分为两类,函数声明和变量声明。代码如下

FunctionDeclaration(node: t.FunctionDeclaration, scope) {
  const func = evaluateMap.FunctionExpression(node, scope);
  scope.$var(node.id.name, func);
},

VariableDeclaration(node: t.VariableDeclaration, scope) {
  const { kind, declarations } = node;
  for (const decl of declarations) {
    const varName =decl.id.name;
    const value = decl.init ? evaluate(decl.init, scope) : void 0;
    if (!scope.$define(kind, varName, value)) {
      throw `[Error] ${name} 重复定义`
    }
  }
},

Statement

语句。当遍历到这类节点时,就要根据环境去执行语句了。这边会递归调用evaluate来计算语句中的每一部分。以If语句为例

IfStatement(node: t.IfStatement, scope) {
  if (evaluate(node.test, scope)) {
    return evaluate(node.consequent, scope);
  }

  if (node.alternate) {
    const ifScope = new Scope('block', scope, true);
    return evaluate(node.alternate, ifScope)
  }
},

Expression

表达式。当遍历到这类节点时,跟Statement类似,需要去递归计算每一部分,然后执行对应的表达式。以逻辑运算表达式为例

LogicalExpression(node: t.LogicalExpression, scope) {
  const { left, right, operator } = node;
  const expressionMap = {
    '&&': () => evaluate(left, scope) && evaluate(right, scope),
    '||': () => evaluate(left, scope) || evaluate(right, scope),
  }
  return expressionMap[operator]();
},

以上就是 evaluate-apply 的大致过程。 完整代码可以在仓库中查看。Nvwa.js

scope

我们再来看下 scope 该如何实现。

class Scope implements IScope {
  public readonly variables: EmptyObj = Object.create(null);

  constructor(
    private readonly scopeType: ScopeType,
    private parent: Scope = null,
    public readonly shared = false,
  ) { }
}

我们构造一个类来模拟 scope。可以看到,Scope 类包含了以下4个属性:

  • variables:当前环境下存在的变量
  • scopeType:当前环境的type
  • parent:当前环境的父环境
  • shared:有些时候不需要重复构造子环境,故用此标识

接下来我们看下该如何在环境中声明变量

首先构造一个类来模拟变量

class Variable implements IVariable {
  constructor(
    private kind: Kind,
    private value: any
  ){ }

  $get() {
    return this.value
  }

  $set(value: any) {
    if (this.kind === 'const') {
      return false
    }
    this.value = value;
    return true;
  }
}

这个类中有两个属性和两个方法

  • kind 用于标识该变量是通过 varlet 还是 const 声明
  • value 表示该变量的值
  • $get$set 分别用于获取和设置该变量的值

有了 Variable 类之后,我们就可以编写 Scope 类中的声明变量的方法了。

letconst 的声明方式基本一样

$const(varName: string, value: any) {
  const variable = this.variables[varName];
  if (!variable) {
    this.variables[varName] = new Variable('const', value);
    return true;
  }
  return false;
}

$let(varName: string, value: any) {
  const variable = this.variables[varName];
  if (!variable) {
    this.variables[varName] = new Variable('let', value);
    return true;
  }
  return false;
}

var 的声明方式稍微有一点差异,因为js中,除了在 function 中,用var 声明的变量是会被声明到父级作用域的(js的历史遗留坑)。我们看下代码

$var(varName: string, value: any) {
  let scope: Scope = this;
  while (!!scope.parent && scope.scopeType !== 'function') {
    scope = scope.parent;
  }
  const variable = scope.variables[varName];
  if (!variable) {
    scope.variables[varName] = new Variable('var', value);
  } else {
    scope.variables[varName] = variable.$set(value);
  }
  return true
}

除了声明,我们还需要一个寻找变量的方法,该方法会从当前环境开始,一直沿着作用域链,找到最外层的环境为止。因此,代码实现如下

$find(varName: string): null | IVariable {
  if (Reflect.has(this.variables, varName)) {
    return Reflect.get(this.variables, varName);
  }
  if (this.parent) {
    return this.parent.$find(varName);
  }
  return null;
}

以上,一个基本的javascript元循环求值器就完成了

最后

大家可以在 codesandbox 在线体验一下。

完整的项目地址是:nvwajs,欢迎鞭策,欢迎star。

参考