导航
导航
文章目录
  1. 什么是AST(抽象语法树)?
  2. Babel运行原理
    1. 解析
    2. 转换
    3. 生成
  3. 实践
    1. 相关npm包
    2. 代码
  4. 总结

AST的实践

什么是AST(抽象语法树)?

It is a hierarchical program representation that presents source code structure according to the grammar of a programming language, each AST node corresponds to an item of a source code.

AST是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。

AST是一个非常基础但是同时非常重要的知识点,我们熟知的 TypeScript、babel、webpack、vue-cli 都是依赖 AST 进行开发的。

这里我们就以 babel 为例来实践一下 AST。

Babel运行原理

Babel 作为当今最为常用的 JavaScript 编译器,在前端开发中扮演着极为重要的角色。大多数情况下,Babel 被用来转译 ECMAScript 2015+ 至可兼容浏览器的版本。

Babel 的三个主要处理步骤分别是:

  • 解析(parse)
  • 转换(transform)
  • 生成(generate)

Babel处理步骤

整个过程中,parsing和generation是固定不变的,最关键的是transforming步骤,通过babel插件来支持,这是其扩展性的关键。

这三个阶段分别由 @babel/parser、@babel/core、@babel/generator 执行。Babel 本质上只是一个代码的搬运工,如果不给 Babel 装上插件,它将会把输入的代码原封不动地输出。正是因为有插件的存在, Babel 才能将输入的代码进行转变,从而生成新的代码。

解析

输入JS源码,输出AST

parsing(解析),对应于编译器的词法分析,及语法分析阶段。输入的源码字符序列经过词法分析,生成具有词法意义的token序列(能够区分出关键字、数值、标点符号等),接着经过语法分析,生成具有语法意义的AST(能够区分出语句块、注释、变量声明、函数参数等)。

利用 @babel/parser 对源代码进行解析 得到 AST。

栗如:

1
console.log(info)

经过parsing后,生成的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
{
"type": "CallExpression",
"callee": {
"type": "MemberExpression",
"object": {
"type": "Identifier",
"loc": {
"identifierName": "console",
},
"name": "console",
},
"property": {
"type": "Identifier",
"loc": {
"identifierName": "log",
},
"name": "log",
}
},
"arguments": [
"Identifier": {
"type": "Identifier",
"loc": {
"identifierName": "log",
},
"name": "info",
}
]
}

🔥Tip: JS代码对应的AST结构可以通过AST Explorer工具查看

仔细的小伙伴可能就会发现从我们的源代码到AST的过程其实就是一个分词的过程,将我们的 console.log(info) 分成 console、log、info。

有了这个 AST 树结构,我们就能进行语义层面转换了。

转换

输入AST,输出修改过的AST

利用 @babel/traverse 对 AST 进行遍历,并解析出整个树的 path,通过挂载的 metadataVisitor 读取对应的元信息,这一步叫 set AST 过程。

@babel/traverse 是一款用来自动遍历抽象语法树的工具,它会访问树中的所有节点,在进入每个节点时触发 enter 钩子函数,退出每个节点时触发 exit 钩子函数。开发者可在钩子函数中对 AST 进行修改。

1
2
3
4
5
6
7
8
9
10
import traverse from "@babel/traverse";

traverse(ast, {
enter(path) {
// 进入 path 后触发
},
exit(path) {
// 退出 path 前触发
},
});

transforming(转换),对应于编译器的机器无关代码优化阶段(稍微有点牵强,但二者工作内容都是修改AST),对 AST 做一些修改,比如针对上面的 log 增加一些信息方便我们调试:

1
console.log(info) => console.log('[info]', info)

修改过后的 AST 结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"type": "CallExpression",
"callee": {
// ....
},
"arguments": [
"StringLiteral": {
"type": "StringLiteral",
"value": "'[info]'",
},
"Identifier": {
"type": "Identifier",
"loc": {
"identifierName": "log",
},
"name": "info",
}
]
}

语义层面的转换具体而言就是对AST进行增、删、改操作,修改后的AST可能具有不同的语义,映射回代码字符串也不同

生成

输入AST,输出JS源码

generation(生成),对应于编译器的代码生成阶段,把AST映射回代码字符串。

利用 @babel/generator 将 AST 树输出为转码后的代码字符串。

实践

说了这么多接下来我们就用代码实践一下上面的例子

相关npm包

  • @babel/parser 解析输入源码,创建AST
  • @babel/traverse 遍历操作AST
  • @babel/generator 把AST转回JS代码
  • @babel/types 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
const parser = require('@babel/parser');
const traverse = require('@babel/traverse');
const generate = require('@babel/generator');
const t = require('@babel/types');

function compile(code) {
// 1. parse
const ast = parser.parse(code);

// 2. traverse
const visitor = {
CallExpression(path) {
const { callee, arguments } = path.node;
if (
t.isMemberExpression(callee)
&& callee.object.name === 'console'
&& callee.property.name === 'log'
&& arguments.length > 0
) {
const variableName = arguments[0].name;
path.node.arguments.unshift(
t.StringLiteral(`[${variableName}]`)
)
}
},
};
traverse.default(ast, visitor);

// 3. generate
return generate.default(ast, {}, code);
}

const code = `console.log(info)`;

const result = compile(code);
console.log(result.code);

总结

看到这,我们的 AST 实践也告一段落了。当然,文章所讲的只是一个简单的例子,但基本的原理思路八九不离十,更多的类型还得自己去探究。总之,掌握好 AST,你真的可以做很多事情。