一、为什么要用JS写JS的解释器
接触过小程序开发的同学应该知道,小程序运行的环境禁止new Function,eval等方法的使用,导致我们无法直接执行字符串形式的动态代码。此外,许多平台也对这些JS自带的可执行动态代码的方法进行了限制,那么我们是没有任何办法了吗?既然如此,我们便可以用JS写一个解析器,让JS自己去运行自己。
在开始之前,我们先简单回顾一下编译原理的一些概念。
二、什么是编译器
说到编译原理,肯定离不开编译器。简单来说,当一段代码经过编译器的词法分析、语法分析等阶段之后,会生成一个树状结构的“抽象语法树(AST)”,该语法树的每一个节点都对应着代码当中不同含义的片段。
比如有这么一段代码:
const a =1
console.log(a)
经过编译器处理后,它的AST长这样:
{
"type":"Program",
"start":0,
"end":26,
"body":[
{
"type":"VariableDeclaration",
"start":0,
"end":11,
"declarations":[
{
"type":"VariableDeclarator",
"start":6,
"end":11,
"id":{
"type":"Identifier",
"start":6,
"end":7,
"name":"a"
},
"init":{
"type":"Literal",
"start":10,
"end":11,
"value":1,
"raw":"1"
}
}
],
"kind":"const"
},
{
"type":"ExpressionStatement",
"start":12,
"end":26,
"expression":{
"type":"CallExpression",
"start":12,
"end":26,
"callee":{
"type":"MemberExpression",
"start":12,
"end":23,
"object":{
"type":"Identifier",
"start":12,
"end":19,
"name":"console"
},
"property":{
"type":"Identifier",
"start":20,
"end":23,
"name":"log"
},
"computed":false
},
"arguments":[
{
"type":"Identifier",
"start":24,
"end":25,
"name":"a"
}
]
}
}
],
"sourceType":"module"}
常见的JS编译器有babylon,acorn等等,感兴趣的同学可以在AST explorer这个网站自行体验。
可以看到,编译出来的AST详细记录了代码中所有语义代码的类型、起始位置等信息。这段代码除了根节点Program外,主体包含了两个节点VariableDeclaration和ExpressionStatement,而这些节点里面又包含了不同的子节点。
正是由于AST详细记录了代码的语义化信息,所以Babel,Webpack,Sass,Less等工具可以针对代码进行非常智能的处理。
三、什么是解释器
如同翻译人员不仅能看懂一门外语,也能对其艺术加工后把它翻译成母语一样,人们把能够将代码转化成AST的工具叫做“编译器”,而把能够将AST翻译成目标语言并运行的工具叫做“解释器”。
在编译原理的课程中,我们思考过这么一个问题:如何让计算机运行算数表达式1+2+3:
1+2+3
当机器执行的时候,它可能会是这样的机器码:
1 PUSH 12 PUSH 23 ADD
4 PUSH 35 ADD
而运行这段机器码的程序,就是解释器。
在这篇文章中,我们不会搞出机器码这样复杂的东西,仅仅是使用JS在其runtime环境下去解释JS代码的AST。由于解释器使用JS编写,所以我们可以大胆使用JS自身的语言特性,比如this绑定、new关键字等等,完全不需要对它们进行额外处理,也因此让JS解释器的实现变得非常简单。
在回顾了编译原理的基本概念之后,我们就可以着手进行开发了。
四、节点遍历器
通过分析上文的AST,可以看到每一个节点都会有一个类型属性type,不同类型的节点需要不同的处理方式,处理这些节点的程序,就是“节点处理器(nodeHandler)”
定义一个节点处理器:
const nodeHandler ={
Program(){
},
VariableDeclaration(){
},
ExpressionStatement(){
},
MemberExpression(){
},
CallExpression(){
},
Identifier(){
}}
关于节点处理器的具体实现,会在后文进行详细探讨,这里暂时不作展开。
有了节点处理器,我们便需要去遍历AST当中的每一个节点,递归地调用节点处理器,直到完成对整棵语法书的处理。
定义一个节点遍历器(NodeIterator):
classNodeIterator{
constructor (node){
this.node = node
this.nodeHandler = nodeHandler
}
traverse (node){
// 根据节点类型找到节点处理器当中对应的函数
const _eval =this.nodeHandler[node.type]
// 若找不到则报错
if(!_eval){
thrownewError(`canjs: Unknown node type "${
node.type}".`)
}
// 运行处理函数
return _eval(node)
}}
理论上,节点遍历器这样设计就可以了,但仔细推敲,发现漏了一个很重要的东西——作用域处理。
回到节点处理器的VariableDeclaration()方法,它用来处理诸如const a = 1这样的变量声明节点。假设它的代码如下:
VariableDeclaration(node){
for(const declaration of node.declarations){
const{