AST是什么
AST(Abstract Syntax Tree),抽象语法树, 是程序的抽象意义表示。意味着跟语言的具体文法无关,利用抽象语法树可以进行程序的处理和转换。例如更改函数名称,语法转换甚至不同语言间的转换。
AST的一个示例:
AST的各种应用场景
- 编译器(compilor)
- DOM
- JS引擎
- 构建工具
- webpack
- 依赖查询
- treeshaking
- babel
- webpack
- JS框架(Angular,React)
- js选择器引擎——Sizle(语法简单,更着重词法分析)
那么如何获取一个程序的AST呢?
以下面的一个mini编译器为例,我们说明一下基本流程。
一个mini编译器
将lisp语言的写法转成C语言类似的风格。
'(add 2 (substract 4 3))' ==> 'add(2, substract(4,3))'
题外话: lisp语言诞生历史很久了,现在的高级语言基本都在向lisp语言的风格靠近
词法解析
词法解析是将输入的程序解析出一个个的词法最小单元(token)。例如在下面这行代码中:
var num = 1 + 2;
‘var’, ‘num’, ‘=’, ‘1’, ‘+’, ‘2’, ‘;’ 就是这段程序的tokens。因为语句中的’var’, 'num’等是不可再进一步拆分的最小语义单元。如果把’var’再继续拆下去,就会失去本身表达的语义(声明一个变量)。
而词法解析的过程就是解析输入的程序,输出一个token的数组。
如何做词法解析?
以程序 (add 2 (substract 4 3))
为例,它的tokens按照我们的理解因该是:
["(", "add", "2", "(", "substract", "4", "3", ")", ")"]
我们只以如何解析 'add’这类名称为示例:
// input为输入的程序字符串,
// char为在遍历input过程中,当前的单个字符
// current 为遍历的下标
if (/[a-z]/i.test(char)) {
let val = '';
while (/[a-z]/.test(char)) {
val += char;
char = input[current++];
}
return val;
}
那么词法解析的意义是什么呢?
词法解析的目的是解析出程序中的语义单元,为下一步的语义分析做准备。这就好比我们平时与人沟通,对方说出一句话后,之所以我们能够理解是因为我们清楚这句话中每个字的意思以及这些字连在一起后表示的最终意思。词法解析的目标就是将程序这段话拆成一个个的字。作为程序解析的下一步————语义分析的 输入。
语义分析
语义分析的产出结果就是我们讲到的AST。语义分析的基础是在前一步词法解析的基础上进行的。一门程序语言是有语法存在的,举个例子,如果程序中出现一个单引号"’", 那么我们如何知道它是一个字符串的开始还是结束呢?这就需要判断它在程序中出现的位置。只要跟位置关联上,我们需要判断程序的上下文了。所以说,语义分析的过程本质上是跟程序语言本身的语法关联上的。语义分析也就是根据语法解析程序的过程。
那么在本例中,经过语义分析后,上一步输出的array就会被转成object。
这个主要以代码为主:
function parser(tokens) {
let current = 0;
function walk() {
let token = tokens[current];
if(token.type === 'Number'){
current++;
return {
type: 'NumberLiteral',
value: token.value
}
}
if(token.type === 'String'){
current++;
return {
type: 'StringLiteral',
value: token.value
}
}
if(token.type === 'parent' && token.value ==='('){
token = tokens[++current];
let node = {
type: 'CallExpression',
name: token.value,
params: []
};
token = tokens[++current];
while(
(token.type !== 'parent') ||
(token.type === 'parent' && token.value !== ')')
){
node.params.push(walk());
token = tokens[current];
}
current++;
return node;
}
throw new TypeError(token.type);
}
let ast = {
type: 'Program',
body: []
}
while(current < tokens.length){
ast.body.push(walk());
}
return ast;
}
遍历语法树
我们得到了程序的语法树,语法树等同于程序。如果我们需要修改程序,那么修改AST就可以了。修改AST首先需要我们对它进行遍历traverse。
- 在遍历过程中,当我们遍历到某个节点Node时,我们将在该节点停留的过程分为三个阶段:进入当前节点,处于当前节点,离开当前节点。
- 语法树是嵌套结构,可以使用递归的思路。对于下一级是array结构的,我们使用
traverserArray
方法。
function traverser(ast, visitor){
function traverserArray(array, parent){
array.forEach(child=>{
traverserNode(child, parent);
})
}
function traverserNode(node, parent){
let methods = visitor[node.type];
if(methods && methods.enter){
methods.enter(node, parent);
}
switch(node.type){
case 'Program':
traverserArray(node.body, node);
break;
case 'CallExpression':
traverserArray(node.params, node);
break;
case 'NumberLiteral':
case 'StringLiteral':
break;
default:
throw new TypeError(node.type);
}
if(methods && methods.exit){
methods.exit(node, parent);
}
}
traverserNode(ast, null);
};
语法树转换(transform)
function transform(ast){
let newAst = {
type: 'Program',
body: []
};
ast._context = newAst.body;
traverser(ast, {
NumberLiteral: {
enter(node, parent){
parent._context.push({
type: 'NumberLiteral',
value: node.value
});
}
},
StringLiteral: {
enter(node, parent){
parent._context.push({
type: 'StringLiteral',
value: node.value
})
}
},
CallExpression: {
enter(node, parent){
let expression = {
type: 'CallExpression',
callee: {
type: 'Identifier',
name: node.name,
},
arguments: []
};
node._context = expression.arguments;
if(parent.type !== 'CallExpression'){
expression = {
type: 'ExpressionStatement',
expression: expression
};
}
parent._context.push(expression);
}
}
});
return newAst;
}
代码生成(generator)
抽象语法树本身不具有在实际环境中直接运行的能力,我们还需要将它转换成程序代码,这样才在实际环境具有价值。
function generator(node){
switch(node.type){
case 'Program':
return node.body.map(generator).join('\n');
case 'ExpressionStatement':
return (generator(node.expression) + ';');
case 'CallExpression':
return (
generator(node.callee) + '(' + node.arguments.map(generator).join(',') + ')'
);
case 'Identifier':
return node.name;
case 'NumberLiteral':
return node.value;
case 'StringLiteral':
return '"'+node.value + '"';
default:
throw new TypeError(node.type);
}
}
AST的一些应用实例
代码压缩
压缩代码时,如何正确更改变量名称?
语法检查
在IDE中,实时检查语法错误
代码转换
ES6+代码转成ES5代码
虚拟DOM和模板
Vue.js, React, 和Angular都涉及到了模板/虚拟DOM。在解析模板/虚拟DOM时,都需要先转成AST。
依赖分析
webpack进行依赖分析时,如何精确获取依赖程序中的模块ID
treeshaking(摇树优化)
剔除代码中未被引用的函数、变量或者模块
Sizzle(jQuery的选择器引擎)
在根据selector查找DOM时,需要解析selector为一个个最小的token。例如$(.cls span)
会被解析成 ['.cls', 'span']
。
高级程序编译
Java、C等高级语言转成二进制语言的过程,更是避免不了转成AST的步骤。
参考文章:
the-super-tiny-compiler
he SpiderMonkey parser API
关于ES语言抽象语法树规范
Babel是如何读懂JS代码的
AST in Modern JavaScript