抽象语法树是什么?
抽象语法树(Abstract Syntax Tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。各种编程语言都有抽象语法树,本文的抽象语法树特指JS的抽象语法树。借用网上流传甚广的一幅图说明一下JS的抽象语法树:
虽然我们不直接使用抽象语法树,可是我们使用的许多工具,都用到了抽象语法树。比如说Webpack
,Babel
,UglifyJS2
,rollup
,ESLint
,IDE
等。如果你想了解这些工具的工作原理或者也想写一个类似的工具,那么你需要了解一下抽象语法树相关的知识。
AST是如何生成的?
众所周知,js是解释型语言,边解释边执行。可是JavaScript 引擎解释的是什么呢? 没错,就是今天我们要讨论的主题-抽象语法树。抽象语法树产生的过程是:字符流 -> 词法分析 -> 语法分析 -> 语法树。我们通过下面的例子, 看看每个阶段的产物:
js
const test="hello,ast!";
词法分析(Lexical Analysis)
词法分析是把字符流(char stream)转换为记号流(token stream),将字符串组成的字符分解成有意义的代码块,这些代码块被称为词法单元。JS源代码字符流可以分为下面几类:
- 空白字符(WhiteSpace) 包括缩进符号,分页符,非断行空格,零宽非断行空格,普通空格等。
- 换行符(LineTerminator) 包含ASCII和Unicode中的各种换行符
- 注释(Comment) 行注释或块注释
- 词法单元(Token)
其中Token又可分以下几类:
Token分类 | 示例 |
---|---|
标识符名称-IdentifierName | 包含变量名,js关键字等 |
符号-Punctuator | +,-,*,/ 运算符,大括号等 |
数字直接量-NumbericLiteral | 就是代码中的数字 |
字符串直接量-StringLiteral | 用单引号或双引号括起来的内容 |
字符串模板-Template | 用反括号括起来的内容 |
想进一步了解JS源代码字符流每种分类的详细内容,可阅读此文。 在esprima网站,输入一段js代码,可以实时查看解析出来的词法分析结果。const test="hello,ast!";
这句的词法分析结果如下:
json
[
{
"type": "Keyword",
"value": "const"
},
{
"type": "Identifier",
"value": "test"
},
{
"type": "Punctuator",
"value": "="
},
{
"type": "String",
"value": "\"hello,ast!\""
}
]
从中可以看出,空白字符,换行,注释都被丢弃。还有一点要说明一下,上面我们归类中的标识符名称(IndentifierName)可以是ldentifier,Nullliteral,BooleanLiteral 或者 Keyword,当不是Nullliteral,BooleanLiteral 或者 Keyword的时候,IndentifierName 就会被解析成ldentifier,否则会分别解析成Null
,Boolean
,Keyword
类型。
语法分析(Syntax Analysis)
语法分析的过程就是把词法分析所产生的记号生成语法树,从结果看,就是把从程序中收集的信息存储到数据结构中。词法分析和语法分析不是完全独立的,而是交错进行,也就是说,词法分析器不会在读取所有的词法记号后再使用语法分析器来处理。在通常情况下,每取得一个词法记号,就将其送入语法分析器进行分析。
const test="hello,ast!";
这句经过语法分析生成的语法树结果如下:
json
{
"type": "Program",
"body": [
{
"type": "VariableDeclaration",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "test"
},
"init": {
"type": "Literal",
"value": "hello,ast!",
"raw": "\"hello,ast!\""
}
}
],
"kind": "const"
}
],
"sourceType": "script"
}
JS的抽象语法树都遵循ESTree规范,ESTree规范如何解读,请查看此文的后半部分。 这里仅列举一下上面例子中用到ast的一些type含义。
类型 | 含义 |
---|---|
Program | 程序主题,整段代码的主体 |
VariableDeclaration | 变量声明,例如var、let、 const |
FunctionDeclaration | 函数声明,例如如function |
ldentifier | 标识符,例如申明变量时 const name = 'tank'; 中的 name |
Literal | 字面量,通常指字符串型的字面量 |
BinaryExpression | 二进制表达式,例如1+1 |
BockStatement | 块语句,包裹在块内的代码 |
ReturnStatement | 返回语句,通常指return |
CallExpression | 调用表达式,通常指调用一个函数 |
MemberExpression | 成员表达式,通常指调用对象的成员,例如console对象到的log成员 |
使用AST如何修改源代码?
使用ast修改源代码分为三步实现: parse(代码解析)、transform(代码转换)、generate(代码生成)。
- 用社区成熟的工具(参见下面的JS解析器章节) ,用JS解析器 parse 源代码,经过词法分析、语法分析生成ast,就好比把机器拆解成零件。
- 用社区成熟的工具对ast进行分析与变换,就好比按照机器图纸,对拆开的零部件,进行重新加工改造,transform 这一步是重点,需要开发者自己实现。
- 用社区成熟的工具,将第二步变换之后的AST,generate为最终的代码,就好比把加工改造过的机器零部件重新组装成机器。
解析器将js转换为ast,常见的js解析器有:
名称 | 说明 |
---|---|
Esprima | 第一个用JS编写的遵循ESTree规范的JS解析器 |
Acorn | fork自Esprima,代码更精简,webpack使用的解析器 |
babel-parser | fork自Acorn,babel使用的解析器 |
Espree | ESLint默认的解析器 |
uglify-js | 多用于代码混淆压缩 |
这个网站可以查询各种解析器转换之后的结果。Esprima 官网上有一个JS解析器性能测试页面, 在chrome中运行了一下,发现同样是解析jQuery.Mobile, Angular 和 React 代码,综合下来,耗时比较短的是Esprima
<Acorn
<UglifyJS2
。
动手练习
光说不练假把式,本来想实现一个代码压缩功能,原以为代码压缩功能就是去除多余的空格与换行,还有对函数名,变量名进行简短化处理。看完这篇文章,才发现还会对代码预解析,进行压缩与优化。对于初学者,写起来还是有点难度。我们挑一个难度稍低一点的功能,练练手。通过实现一个给函数中的console.log
日志追加一个打印函数名的功能,熟悉一下ast的用法。从前面的js解析器性能测试我们知道,Esprima
解析器的性能比较好,所以js解析器我们就选择它了。js解析器有了,根据上一章节讲到的用ast修改源代码三部曲,还差一个ast分析工具和一个将ast还原成代码的工具。经过查找,找到了estraverse
和escodegen
, 从下面的表格可以看出,这三个工具之间有关联,要按照一定的顺序使用。
npm包 | 用途 |
---|---|
esprima | 将源代码解析抽象语法树 |
estraverse | 遍历修改抽象语法树 |
escodegen | 将抽象语法树内容转换成代码 |
现在安装一下这三个npm工具包。
bash
pnpm add esprima estraverse escodegen
实现思路是:通过一个简单的例子比如说改一下变量的名称,跑一下ast修改源代码的整个流程。然后再实现我们真正想实现的功能。先写一个声明语句,把esprima
解析的抽象语法树打印出来看一下,查看ast对象的数据特征,用estraverse
遍历这个ast对象,修改一下前面定义的变量名称,接着用escodegen
工具把修改之后的ast语法树复原成代码,看看生成的代码变量名称是否已变更为刚刚修改的变量名称。
主流程文件index.js内容如下:
js
import fs from "fs/promises";
import esprima from "esprima";
import estraverse from "estraverse";
import escodegen from "escodegen";
// 读取源代码
const code = await fs.readFile("./source.js", { encoding: "utf8" });
// 将源代码解析成抽象语法树
const ast = esprima.parseScript(code);
console.log(ast);
// 遍历抽象语法树修改内容
estraverse.traverse(ast, {
enter: function (node) {
if (node.name === "dream") {
node.name = "work";
}
},
});
// 将抽象语法树内容转换成代码
const transformCode = escodegen.generate(ast);
// 将转换之后的代码写入目标文件
await fs.writeFile("./dest.js", transformCode);
主流程文件中引入的source.js内容如下:
js
const dream="doctor";
执行node index.js
, 可以看到在node终端控制台打印出来的信息不完整, 如下图红框所圈部分,打印时并不会展开显示。
要通过VSCode的Debug功能,才能看到AST的完整内容。
发现在调试模式下看到的内容与在esprima.org看到的内容完全吻合,利用这一点,后续我们可以在esprima
官网上分析ast
数据。
执行完之后,生成的dest.js的内容如下所示,符合预期。至此,我们已经跑通了用ast
修改源代码的完整流程。
js
const work = 'doctor';
现在我们实现一下给函数中的console.log语句添加打印函数名的功能,如果输入文件是这样的:
js
const multiply = function (prev, cur) {
console.log(prev, cur);
return prev * cur;
};
function sum(prev, cur) {
console.log(prev, cur);
return prev + cur;
}
我们期望最后生成的文件是这样的:
js
const multiply = function (prev, cur) {
console.log('multiply', prev, cur);
return prev * cur;
};
function sum(prev, cur) {
console.log('sum', prev, cur);
return prev + cur;
}
通过在esprima.org网站上观察生成的ast对象的特点
找到了要修改的节点是:
要添加的打印参数值的获取节点是:
根据上面的观察,经过一番调试和对箭头函数表达式,立即执行表达式中函数声明和表达式这两种场景的完善,最终主流程文件内容修改如下:
js
import fs from "fs/promises";
import esprima from "esprima";
import estraverse from "estraverse";
import escodegen from "escodegen";
// 读取源代码
const code = await fs.readFile("./source.js", { encoding: "utf8" });
// 将源代码解析成抽象语法树
const ast = esprima.parseScript(code);
// 遍历抽象语法树修改内容
estraverse.traverse(ast, {
enter: function (node) {
if (node.type === "Program") {
node.body.forEach((nodeItem) => {
// 给console.log打印加上函数名名称
logAddFunName(nodeItem);
});
}
},
});
// 将抽象语法树内容转换成代码
const transformCode = escodegen.generate(ast);
// 将转换之后的代码写入目标文件
await fs.writeFile("./dest.js", transformCode);
/**
* 给函数中的console.log添加函数名
* @param {*} node
*/
function logAddFunName(node) {
// 正常定义的函数声明和表达式
if (["FunctionDeclaration", "VariableDeclaration"].includes(node.type)) {
nodeType(node);
}
// 在立即执行函数中定义的函数声明和表达式
else if (
node.type === "ExpressionStatement" &&
["FunctionExpression", "ArrowFunctionExpression"].includes(node.expression.callee.type)
) {
node.expression.callee.body.body.forEach((item) => {
nodeType(item);
});
}
}
/**
* 节点类型判断
* @param {*} item
*/
function nodeType(item) {
// 函数声明
if (item.type === "FunctionDeclaration") {
addFunName(item.body.body, item.id.name);
}
// 函数表达式--普通函数+箭头函数
else if (item.type === "VariableDeclaration") {
item.declarations.forEach((item) => {
if (["ArrowFunctionExpression", "FunctionExpression"].includes(item.init.type)) {
addFunName(item.init.body.body, item.id.name);
}
});
}
}
/**
* 找到抽象语法树中的console.log参数,追加函数名称打印参数
* @param {*} nodeArr 表达式节点
* @param {*} funName 函数名称
*/
function addFunName(nodeArr, funName) {
nodeArr.forEach((item) => {
if (item.type === "ExpressionStatement" && item.expression.callee.object.name === "console") {
item.expression.arguments.unshift({
type: "Literal",
value: `${funName}`,
raw: `${funName}`,
});
}
});
}
运行效果如下:
js
const multiply = function (prev, cur) {
console.log('multiply', prev, cur);
return prev * cur;
};
function sum(prev, cur) {
console.log('sum', prev, cur);
return prev + cur;
}
const divide = (prev, cur) => {
console.log('divide', prev, cur);
return prev / cur;
};
(() => {
function subtract1(prev, cur) {
console.log('subtract1', prev, cur);
return prev - cur;
}
const subtract2 = (prev, cur) => {
console.log('subtract2', prev, cur);
return prev - cur;
};
subtract1();
subtract2();
})();
本文的练习代码已经上传到码云,你可以点击这里下载学习。写完本文我有一个感悟,人对于自己不太了解,不太熟悉,不太擅长的事情,提不起任何兴趣,一看到,一听到,一说起就犯困。对于这一点可以反向合理利用一下,把这些事情当做一个学习方向导航,按图索骥,一点一点扩大自己的技术视野。