Nieyt's Blog

AST入门:原理浅析及实践

字数统计: 1.8k阅读时长: 7 min
2021/06/30 Share

什么是AST?

在计算机科学中,抽象语法树(Abstract Syntax Tree,AST),或简称语法树(Syntax tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。之所以说语法是“抽象”的,是因为这里的语法并不会表示出真实语法中出现的每个细节。

AST的用途

在js运行时,js引擎就是将js代码解析成AST,再进行接下来的其他分析操作,最后输出可执行的字节码。 此外,在我们日常的开发中,经常用到的一些功能:webpack对代码的转换、babel对js(jsx->js、ts->js、es6->es5等)的转换、postCss对css(px2rem等)的转换、eslint对代码风格的检测、UglifyJs对代码的压缩、IDE内代码的高亮显示/报错、vue中template模板的编译、taro跨端的实现等等,都来自于对AST的应用。

AST是如何生成的

我们先来看看AST长啥样,举个例子:
将下行代码

1
let v = 'hello world!';

转换为AST:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
{
"type": "Program",
"body": [
{
"type": "VariableDeclaration",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "v",
"range": [
4,
5
]
},
"init": {
"type": "Literal",
"value": "hello world!",
"raw": "'hello world!'",
"range": [
8,
21
]
},
"range": [
4,
21
]
}
],
"kind": "let",
"range": [
0,
21
]
}
],
"sourceType": "module",
"range": [
0,
21
]
}

生成javascript的抽象语法树,通常依靠的是Javascript Parser(js解析器),js Parser有很多,不同的parser生成的AST也不相同,但原理是类似的,只是定义的标准不同。常见的parser有Acorn(应用于webpack)、Babylon(应用于bable)、Espree(应用于eslint)、Esprima(文档最全、示例最丰富,初学者建议选它)等。例子中用的parser就是Esprima
在这个网站AST explorer ,我们可以将不同的语言在线生成AST,指定parser解析器和转换的transform,还有1个很人性化的功能,当鼠标指到对应的代码段会在语法树中高亮显示。
我们可以看到,AST的JSON对象类型,充分的表达了代码的结构:代码位置、声明类型、变量、赋值等信息。

AST的编译过程是怎样的?

构建AST的过程经过了词法分析、语法分析等阶段。
在词法分析阶段,通过scanner对代码进行扫描,将字符流(char stream)转换为记号流(token stream),由字符串组成的字符分解成有意义的代码块,这些代码块称为词法单元。例如:上段 JS 代码 let v = 'hello world!' 会被分解为词法单元:let、v、=、hello world!。最小词法单元主要有空格、注释、字符串、数字、标志符、运算符、括号等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[
{
"type": "Keyworld",
"value": "let"
},
{
"type": "Identifier",
"value": "v"
},
{
"type": "Punctuator",
"value": "="
},
{
"type": "String",
"value": "'hello world!'"
}
]

在词法分析阶段,将词法单元流转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树,这个树即为“抽象语法树” AST(Abstract Syntax Tree)。
词法分析和语法分析不是完全独立的,而是交错进行的,也就是说,词法分析器不会在读取所有的词法记号后再使用语法分析器来处理。在通常情况下,每取得一个词法记号,就将其送入语法分析器进行分析。

语法分析的过程就是把词法分析所产生的记号生成语法树,通俗地说,就是把从程序中收集的信息存储到数据结构中。在编译中用到的数据结构有两种:符号表和语法树。
符号表:就是在程序中用来存储所有符号的一个表,包括所有的字符串变量、直接量字符串,以及函数和类。
语法树:就是程序结构的一个树形表示,用来生成中间代码。
如果 JavaScript 解释器在构造语法树的时候发现无法构造,就会报语法错误,并结束整个代码块的解析。

AST三板斧

通常来说,对AST的应用,都包含3个阶段:

  1. 解析:将代码字符串解析成抽象语法树。
  2. 转换:对抽象语法树进行转换操作(遍历、增删改查)。
  3. 生成:根据变换后的抽象语法树再生成代码字符串。

如图:

AST的运用实践

下面我们来实际操作1下,实现3个小练习,体验AST的能力和生态。

1. 实现一个代码转换工具, 将let/const转换成var

首先,我们需要下载1个工具库

1
sudo npm install -g jscodeshift

jscodeshift 也是基于 esprima的,其通过 path 可以很容易地实现在 AST 上遍历 node。

实现1个transform.js(根据jscodeshift官方示例的模板改写)

1
2
3
4
5
6
7
8
9
export default function transformer(file, api) {
const j = api.jscodeshift;
return j(file.source)
.find(j.VariableDeclaration) // 找到AST中的变量声明
.forEach(path => {
path.node.kind = 'var' // 不管是let、const还是var,全都重置为var就可
})
.toSource(); // 根据转换后的AST重新生成代码
}

源代码文件为index.js

1
2
let a = 'hello world';
const b = 'hello food';

运行

1
jscodeshift -t transform.js ./src/index.js

可以看见代码已被转换为

1
2
var a = 'hello world';
var b = 'hello food';

2. 编写一个Babel插件,去除console的输出,可配置忽略项

我们可以先了解1下Babel插件的开发:Babel插件开发手册
安装babel-ali

1
2
npm init -f
npm install --save-dev babel-cli

在Babel中,解析和最后的生成(三板斧中第1步和第3步),已经都给我们处理好了,我们只需要关心第2步,AST的转换。

创建1个名为console-remove.js的文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
module.exports = function ({ types: t }) {
return {
visitor: {
// 遍历AST时每遇到1个ExpressionStatement时执行该函数
ExpressionStatement(path, { opts }) {
// 拿到object与property, 比如console.log语句的object name为console, property name为log
const { object, property } = path.node.expression.callee
// 如果表达式语句的object name不为console, 则不作处理
if (object.name !== 'console') return
// 判断property name是不是插件配置里被忽略的
const isIgnore = (opts.ignore || []).find(ele => ele === property.name)
// 如果不是, 删除该语句
if (!isIgnore) path.remove()
}
}
}
}

源代码文件为console-demo.js

1
2
3
console.log('hello')
console.warn('warn')
console.error('error')

在根目录内新建一个.babelrc文件,配置内容为

1
2
3
4
5
6
7
8
9
10
11
12
{
"plugins": [
[
"./console-remove",
{
"ignore": [
"error"
]
}
]
]
}

命令行执行

1
npx babel console-demo.js

输出只有console.error('error')一行啦,插件编写成功

3. 编写一个Babel插件,将箭头函数转换为普通函数

这里需要引入babel-types库来帮助我们做AST代码块的转换

1
npm i babel-types -D

创建1个名为function-transform.js的文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const { blockStatement, returnStatement, functionExpression} = require("babel-types");
module.exports = function ({ types: t }) {
return {
visitor: { // 检测到ArrowFunctionExpression时执行
ArrowFunctionExpression(path) {
let node = path.node; // 父节点
let params = node.params;
let block = blockStatement([
returnStatement(node.body),
]);
let func = functionExpression(
null,
params,
block,
false,
false
); // 生成es5函数
path.replaceWith(func);
},
},
}
}

  • returnStatement 声明一个 return ... 的 AST
  • blockStatement 声明一个block块 { ... } 的 AST
  • functionExpression 声明一个 function xx 的 AST

.babelrc加入配置该插件

1
2
3
4
5
6
"plugins": [
...
[
"./function-transform" // 这里
]
]

源代码文件为func.js

1
const func1 = (a, b) => a + b

命令行执行

1
npx babel func.js

输出函数,已成功转换:

1
2
3
const func1 = function (a, b) {
return a + b;
};

CATALOG
  1. 1. 什么是AST?
  2. 2. AST的用途
  3. 3. AST是如何生成的
    1. 3.1. AST的编译过程是怎样的?
  4. 4. AST三板斧
  5. 5. AST的运用实践
    1. 5.1. 1. 实现一个代码转换工具, 将let/const转换成var
    2. 5.2. 2. 编写一个Babel插件,去除console的输出,可配置忽略项
    3. 5.3. 3. 编写一个Babel插件,将箭头函数转换为普通函数