go实现一个js解释器

avatar
前端工程师 @字节跳动

豆皮粉儿们,大家好呀。愉快的五一节就这么过去了,假期有没有好好游玩一番呢。今天由清风慕竹给大家带来一篇《如何用go实现一个js解释器》。

作者:清风慕竹

背景

前段时间在开发版本发布系统过程中,为了追求系统的灵活性,我们允许用户通过写js的方式生成json配置,业务上有定制的需求可以通过js代码来实现,这样在不调整底层系统的情况下可以尽可能的支持业务中的个性化需求。由于发布系统是用golang开发的,所以这里需要一个go版本的js解释器(不需要考虑gc、jit、inline-cache等复杂内容,只是一个简单的解释器的实现,可以解析并执行js即可),能够在golang应用中安全的运行js代码。

实现思路

关于js解释器的实现其实已经有很多版本了,比如tinyjs(c++版本的实现)tinyjs.py(py版本的实现)、还有若干用js自举实现的版本,比如eval5 。这些解释器实现思路大致如下:

image-20210125074255009

其中转换步骤是可选的,这一步主要工作是将语法树上的节点转换成目标语言可执行的节点,对于eval5这种js-in-js的实现,这一步就不需要实现了。但是对js-in-x(x可能是go、c++、py)这种情况则需要增加转换的步骤。关于词法分析、语法解析这两块实现资料比较多,这里不再赘述,熟悉js的同学可以参考acornbabel-parserespree等实现,这里重点讲下转换和遍历执行的过程。

go与js数据交换

在转换、执行之前需要先解决go和js数据交换的问题,需要考虑 js< --- >go 双向的场景。

  1. go代码访问js变量 js代码在ast语法树转换的过程中,对应的ast节点转换的过程中被转换成expression节点,基本的值被装箱成Value类型,golang访问js变量实际上访问的是变量对应的ast节点转换后生成的expression节点。 比如在js中定义如下:
var a = 2;
function print(name) {
  console.log('hello ' + name)
}

变量定义转换如下:

image-20210125171519499

函数定义转换如下:

image-20210125170736066

golang在执行前会处理变量定义,处理之后会在对应作用域对象上生成key(变量名or函数名)到expression的binding:

image-20210125175722806

go里面并不会直接访问js变量,而是访问js变量对应的expression。

  1. js代码访问go变量 假定go提前注册了变量x和函数twoPlus:
vm := New()
vm.Set("x", 10)
vm.Set("twoPlus", func(call FunctionCall) Value {
right, _ := call.Argument(0).ToInteger()
result, _ := vm.ToValue(2 + right)
return result
})

js访问golang中变量x、函数twoPlus:

var a = x + 2;
var b = twoPlus(a)
console.log('twoPlus(a): ' + b)

js代码并没有直接执行,真正执行的是js代码对应的ast转换后的结果,golang侧注册变量实际上是把变量注册到了当前作用域的property上了:

image-20210125181404804

执行ast转换后的节点时发现需要获取identifier x对应值的时候,会从property对应的map上拿到x对应的值。函数也是如此。

转换

  • 从ast树的body节点开始遍历,依次执行statement转换的过程

    比如上图中 1+1在ast树上对应的节点是ExpressionStatement,对应的会依次调用parseStatement:

    image-20210125083936749

    parseExpression:

    image-20210125084026214

    ExpressionStatement内部的expression是BinaryExpression,最终会转换成如下结构:

    _nodeBinaryExpression {
      operator:   token.PLUS,
      comparison: false, 
      left: &_nodeLiteral{
        value: Value{
          kind:  valueNumber,
          value: 1, 
        }, 
      },    
      right: &_nodeLiteral{
        value: Value{
          kind:  valueNumber, 
          value: 1,
        },
      },
    }
    
  • 处理变量声明情况

    处理变量声明和js的变量提升相关,在遍历完ast树之后,对于树上的变量、函数的定义,会保存到varList、functionList数组中。 image-20210125084629642

遍历、执行以

1+1 为例:

image-20210125183015895

遍历ast执行对应的节点时需要注意,js存在作用域的区别(全局作用域、函数作用域)。在上面的代码执行的时候,默认将代码放在全局作用域执行:

image-20210125153241238

enterGlobalScope和leaveScope对应实现如下:

image-20210125153636497

进入globalScope的时候会把当前runtime的scope暂存在_scope.outer字段上,defer对应的匿名函数在函数执行完毕之后执行,当函数执行完之后,再把scope.outers上暂存的scope恢复回来。globalScope和functionScope的scope对象是隔离的,在非严格模式下,functionScope会是一个从globalScope深拷贝的对象。

接下来处理变量定义,比如var a = 1变量定义 或者function f(){} 函数定义,变量和函数存放的地方在当前作用域scope对象上的variable.object.property字段上,这个字段其实类型就是一个巨型map,存储的时候key为变量名,值为js对应值的包装类型。完成变量、函数定义后,遍历program下的body节点,并根据节点类型执行对应的操作:

image-20210125160131093

接着执行ExpressionStatement中的expression,对应类型是BinaryExpression:

image-20210125161116991

BinaryExpression需要计算左值和右值,先计算node.left:

image-20210125161156947

再进入cmp_evaluate_nodeExpression时,此时expression类型为nodeLiteral, 直接返回node.value即可。下面根据node.operator执行对应的计算逻辑(参与的时候先对Value类型执行拆箱,获取基本类型的值后转换成float64类型参与计算,计算的结果以Value的包装类型返回):

image-20210125161807232

到这里就完成了1+1的计算。

总结

借助现有的js代码解析库可以相对容易的实现一个js的解释器,实现思路比较明确,但是对于新语法规范支持度还是比较差,后面可以进一步扩充语法。除了本文介绍的js-in-go的实现之外,js-in-js也有一些比较有意思的玩法,比如借助js-in-js的实现,可以在js引擎屏蔽了eval、new Function情况下实现js代码的热更新。

更多精彩内容,定制礼品图书赠送,高薪职位内推,微信搜索关注“豆皮范儿”