为什么是AST
和自然语言一样,计算机语言也是由语法和具体值组成的。我们在中学阶段都学习过句子的组成(主语、谓语、宾语),当我们想对句子进行语句分析,往往会把句子拆分成主谓宾三个部分,然后分别对这些部分里的值以及他们之间的关系来句子解析。除此之外,还需要对句子中的标点符号进行解析判断。
举个🌰:
解析一下:“我是阿威罗软蛋。”
如果我们只是简单的把这个句子通过正则匹配进行拆分,并平摊在一个数组中的话,会得到下面的内容
[
{ type: "主语", value: "我" },
{ type: "谓语", value: "是" },
{ type: "宾语", value: "阿威罗软蛋" },
{ type: "标点符号", value: "。" },
]
显然,我们通过拆分语句吧这个句子的主谓宾全部拆分了出来,在这个🌰中,我们能够完成想完成的工作(句子校验、语句提炼),但如果把这个🌰复杂化:“她说我是阿威罗软蛋”,按照上面的解析方式,我们将会获得:
[
{ type: "主语", value: "她" },
{ type: "谓语", value: "说" },
{ type: "主语", value: "我" },
{ type: "谓语", value: "是" },
{ type: "宾语", value: "阿威罗软蛋" },
{ type: "标点符号", value: "。" },
]
按照上述拆分结果,不难看出这是个病句(明显缺少一个宾语),但实际上,这个句子不存在实质上的问题(如果你也认为阿威罗软蛋是一个宾语)。
上述解析方法存在一个巨大的问题:缺少对上下文状态的保存。由于这些上下文在句子解析中非常重要,要在计算机语言中实现这样的解析方式,就需要考虑保存上下文内容,而不是简单的使用正则匹配来进行判断解析。显然如果我们仅仅用上述解析结果(转化为若干节点,并收集进入一个数组进行集中管理)来作为后续转译的根基的话,是无法完成关系校验等工作的,究其根本是因为我们现在选择的数据结构(一维数组)过于扁平化,没办法清晰的表达上下文内容。
需要保存自己的信息,同时还要能够查看上下文的信息,树,这个数据结构便脱引而出成为我们构造中间件的首选数据结构。没人比树节点更懂访问自己的老爹、兄弟和儿子。(双向链表:??)
在JS中,我们选择AST(Abstract Syntax Tree)作为解决方案,它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。在代码语法的检查、代码风格的检查、代码的格式化、代码的高亮、代码错误提示、代码自动补全等等场景均有广泛的应用。回到刚才的🌰中,把解析后的代码进行转译
{
type: "语句",
body: {
type: "陈述句",
weiyu:{
value:'说'
}
zhuYu: {
type: "person",
value: "她",
},
binYu: {
type: "语句",
body: {
type:"陈述句",
weiyu:{
value:"是"
}
zhuyu:{
type:"person",
value:"我"
},
binYu:{
type:"snack",
value:"阿威罗软蛋"
}
}
}
},
};
编译流程(code->AST->code)
说了那么多,那AST是如何帮助编译器进行代码语法检查、高亮、错误提示等操作的呢?
一个完整的编译器整体执行过程可以分为三个步骤:
- Parsing(解析过程) :这个过程包括词法分析->语法分析->构建AST(抽象语法树);
- Transformation(转化过程):这个过程就是将上一步解析后的内容AST,按照编译器指定的规则进行处理,形成一个新的AST。
- Code Generation(代码生成):将上一步处理后的AST再转换为新的代码;
换句话来说,AST是代码内容的一个信息摘要,根据语法将代码内容进行抽离,而编译器就是根据这样一个信息摘要,对摘要进行相应操作后,再最后转译为新的代码。
Parsing
解析过程包括两个过程:词法分析、语法分析。
词法分析(code->tokens)
词法分析是通过tokenizer(分词器) 将code拆分成token流,同样的,我们像之前拆分“我是阿威路软蛋!”一样拆分一下以下代码
let test = function(){
console.log('hello world')
}
[
{ type:'Program',kind:'let'},
{ type:'Identifier',value:'test'},
{ type:'FunctionExpression',optional:false},
{ type:'BlockStatement'},
{ type:'CallExpression'},
{ type:'Identifier',name:'console'},
{ type:'Identifier',name:'log'},
{ type:'Literal',value:'hello world'}
]
将代码拆分成tokens,实际上并没有完成我们希望的工作,tokens流仅仅表示了原代码的所有要素信息,但每个要素的关系其实是缺失的,所以我们还需要对tokens进行进一步加工。
语法分析(tokens->AST)
获得tokens后发现这好像元素间缺少关联,语法分析就完成了这一工作,将tokens整理成相互关联的语法表达形式。
还是上述🌰
let test = function(){
console.log('hello world')
}
把这个🌰进行解析,能得到下面的“树”结构👇:
{
"type": "Program",
"start": 0,
"end": 232,
"body": [
{
"type": "VariableDeclaration",
"start": 179,
"end": 232,
"declarations": [
{
"type": "VariableDeclarator",
"start": 183,
"end": 232,
"id": {
"type": "Identifier",
"start": 183,
"end": 187,
"name": "test"
},
"init": {
"type": "FunctionExpression",
"start": 190,
"end": 232,
"id": null,
"expression": false,
"generator": false,
"async": false,
"params": [],
"body": {
"type": "BlockStatement",
"start": 200,
"end": 232,
"body": [
{
"type": "ExpressionStatement",
"start": 204,
"end": 230,
"expression": {
"type": "CallExpression",
"start": 204,
"end": 230,
"callee": {
"type": "MemberExpression",
"start": 204,
"end": 215,
"object": {
"type": "Identifier",
"start": 204,
"end": 211,
"name": "console"
},
"property": {
"type": "Identifier",
"start": 212,
"end": 215,
"name": "log"
},
"computed": false,
"optional": false
},
"arguments": [
{
"type": "Literal",
"start": 216,
"end": 229,
"value": "hello world",
"raw": "'hello world'"
}
],
"optional": false
}
}
]
}
}
}
],
"kind": "let"
}
],
"sourceType": "module"
}
仔细整理,不难发现生成的AST都具有自己的类型描述(type),同时也有指向其子节点的指针(具体表现为数组)
常见的AST节点类型(type)
| 类型名称 | 中文名称 | 描述 |
|---|---|---|
| Program | 代码主体 | 程序段:包含start、end、body |
| VariableDeclaration | 变量声明 | 变量声明:包含start、end、kind(let、var、const)、declartion(声明内容) |
| FunctionDeclaration | 函数声明 | 声明一个函数(function):包含start、end、id、params等 |
| FunctionExpression | 函数表达式 | example: const fn = function(){ } |
| ArrowFunctionExpression | 箭头函数表达式 | example: const fn = () => { ] |
| AwaitExpression | await函数表达式 | example: const fn = await f() |
| ObjectMethod | 在对象中定义的方法 | example: const obj = { function () { } } |
| BlockStatement | 块函数段 | 包裹在 {} 块内的代码,包含start、end、body等 |
| ExpressionStatement | 表达式 | 函数调用(如console.warn()):包含start、end、expression等 |
| Identifier | 标识符 | example: const a = 1中,a就是Identifier |
Transformation
这个过程主要是改写AST,或者根据当前AST生成一个新的AST,也就是说、我们需要对这“信息摘要”进行读取并改写。即将经历遍历(traversal)和访问器(visitor)访问节点这两个步骤。
traversal
由于AST是一个类树结构(至少在我们心中它更像一棵树而不是链表),遍历(traversal)便成为我们读取信息的第一方案,在这里我们采用深度优先遍历来对AST进行遍历。(思考:为什么不优先使用广度优先遍历来遍历AST呢)
const traversal = (AST) => {
if(AST === null) return
AST.body.foreach(body => traversal(body))
//对该节点进行访问操作
}
visitor
访问器是一个“访问器”对象,这个对象可以处理不同类型的节点函数。
const visitor = {
LiteralVistor(currentNode,parentNode){}, //visit LiteralNode
ExpressionVistor(currentNode,parentNode){} //visit ExpressionNode
}
在遍历过程中进入该节点时,我们希望调用访问器对该节点进行访问操作(enter),此时需要传入当前节点信息和父节点信息。同时在离开的时候我们也希望能够调用访问器进行退出操作(exit),需要注意的是,由于我们遍历的时候遵循深度优先原则,所以我们将会依次遍历到最外层,然后依次进行递归的访问直到抵达根节点,此时再依次退出节点(由内向外)
const visitor = {
LiteralVistor:{
enter(currentNode,parentNode){}, // excute when get access
exit(currentNode,parentNode){} // excute when finish visit
}, //visit LiteralNode
ExpressionVistor:{
enter(currentNode,parentNode){}, // excute when get access
exit(currentNode,parentNode){} // excute when finish visit
}//visit ExpressionNode
}
Code Generation
最后就是代码生成阶段了,其实就是将生成的新AST树再转译为代码的过程。大部分的代码生成器主要过程是,不断的访问Transformation生成的AST(抽象语法树)或者再结合tokens,按照指定的规则,将“树”上的节点打印拼接最终还原为新的code,自此编译器的执行过程就结束了。
解决问题
前情提要:
假如你是一名普普通通的前端开发者,在回顾你的同事“没戏”的代码的时候发现他在发起网络请求的时候都没有进行错误捕捉,而“没戏”现在正好在休假,所以在被他当场气晕的同时,你还需要帮他完成错误捕捉的工作。项目里面的网络请求很多(假设都是用async和await实现的),你觉得一个一个去找并添加try...catch非常费劲,所以聪明的你打算写一个小小的脚本来完成这件事情。(虽然一般工程中不会这么搞)
问题分析:
我们可以通过修改AST的内容来实现try...catch的自动添加,由于async函数与try函数比较特殊,在FunctionDeclaration中存在特殊标记的字段,在这里就简化了我们定位的问题,先把二者的AST对比研究一下:
async function fn(){
await f()
}
async function fn() {
try {
await f()
} catch (e) {
console.warn(e)
}
}
对比之下不难看出,我们需要做的工作就是将async函数段的body中的ExpressionStatement放到TryStatement的block的body中去。
插件初始化:
我们通过babel.type拿到types对象,进而控制AST,同时,定义了一个访问者,可以设置需要访问的节点类型,当访问到目标节点后,做相应的处理来实现插件的功能
module.exports = function (babel) {
let t = babel.type
return {
visitor: {
// 设置需要范围的节点类型
CallExression: (path, state) => {
//对AST进行操作
}
}
}
}
获取async await节点
我们可以通过AwaitExpression函数来获取Await节点
module.exports = function (babel) {
let t = babel.type
return {
visitor: {
// 设置AwaitExpression
AwaitExpression(path) {
// 获取当前的await节点
let node = path.node;
}
}
}
}
然后使用findParent再向上对async节点进行查找,先前提到,async函数有标记字段,所以我们只需要对async字段进行匹配即可(true为async函数)
const asyncPath = path.findParent(p => p.node.async)
但需要注意的是,声明async函数的方式不尽相同,其对应的AST也不相同,所以我们需要对所有async函数声明进行适配
//函数式声明
async function fn (){ await f () }
//函数表达式声明
const fn = async function(){ await f () }
//箭头函数声明
const fn = async () =>{ await f () }
//声明在对象中
const obj = {
async fn() {await f() }
}
函数式声明
函数表达式声明
箭头函数声明
对象中声明
所以我们需要对上述这些情况分别进行判断处理
const asyncPath = path.findParent((p) => {
p.node.async && (p.isFunctionDeclartion || p.isFunctionExpression || p.isObjectExpression || p.isArrowFunctionExpression
})
module.exports = function (babel) {
let t = babel.type
return {
visitor: {
// 设置AwaitExpression
AwaitExpression(path) {
// 获取当前的await节点
let node = path.node;
// 获取当前await节点对应的父async节点
const asyncPath = path.findParent((p) => {p.node.async && (p.isFunctionDeclartion || p.isFunctionExpression || p.isObjectExpression || p.isArrowFunctionExpression})
}
}
}
}
将async节点的body内容替换成try
module.exports = function (babel) {
let t = babel.type
return {
visitor: {
AwaitExpression(path) {
let node = path.node;
const asyncPath = path.findParent((p) => p.node.async && (p.isFunctionDeclaration() || p.isArrowFunctionExpression() || p.isFunctionExpression() || p.isObjectMethod()));
// TODO:构造一个tryNode
let tryNode ;
// 获取父节点的函数体body
let info = asyncPath.node.body;
// 将函数体放到try语句的body中
tryNode.block.body.push(...info.body);
// 将父节点的body替换成新创建的try语句
info.body = [tryNode];
}
}
}
}
至此我们基本上完成了所有的工作:定位async与await,将await函数(async的函数体)放到tryNode中,并将tryNode替换到async的函数体,接下来我们需要构造一个tryNode
生成一个tryNode
完事具备只差tryNode,这里我们引入babel-template来完成模版的编写
// 引入babel-template
const template = require('babel-template');
// 定义try/catch语句模板
let tryCatchTemplate = `
try {
} catch (e) {
console.warn(catchError:e)
}`;
// 创建模板
const temp = template(tryTemplate);
// 给模版增加key,添加console.warn打印信息
let argumentObj = {
// 通过types.stringLiteral创建字符串字面量
catchError: types.stringLiteral('Error')
};
// 通过temp创建try语句的AST节点
let tryNode = temp(tempArgumentObj);
小小的集成一下
// 引入babel-template
const template = require('babel-template');
// 定义try/catch语句模板
let tryCatchTemplate = `
try {
} catch (e) {
console.warn(catchError:e)
}`;
// 创建模板
const temp = template(tryTemplate);
let argumentObj = {
catchError: types.stringLiteral('Error')
};
// 通过temp创建try语句的AST节点
let tryNode = temp(tempArgumentObj);
module.exports = function (babel) {
let t = babel.type
return {
visitor: {
AwaitExpression(path) {
let node = path.node;
const asyncPath = path.findParent((p) => p.node.async && (p.isFunctionDeclaration() || p.isArrowFunctionExpression() || p.isFunctionExpression() || p.isObjectMethod()));
// TODO:构造一个tryNode
let tryNode ;
// 获取父节点的函数体body
let info = asyncPath.node.body;
// 将函数体放到try语句的body中
tryNode.block.body.push(...info.body);
// 将父节点的body替换成新创建的try语句
info.body = [tryNode];
}
}
}
}
借助Babel干点啥
babel是啥
Babel其实就是一个最常用的Javascript编译器,它能够转译 ES6+的代码,使它在旧的浏览器或者环境中也能够运行,工作过程分为三个部分:Parsing -> Transformation -> Code Generation,也就是说babel内部完成了我们之前的全部工作。
- @babel/parser 可以把源码转换成AST
- @babel/traverse 用于对 AST 的遍历,维护了整棵树的状态,并且负责替换、移除和添加节点
- @babel/generate 可以把AST生成源码,同时生成sourcemap
- @babel/types 用于 AST 节点的 Lodash 式工具库, 它包含了构造、验证以及变换 AST 节点的方法,对编写处理 AST 逻辑非常有用
- @babel/core Babel 的编译器,核心 API 都在这里面,比如常见的 transform、parse,并实现了插件功能
来个简单的活
我们先解决一个'hello world'的🌰:
//把const hello = 'hello world'转译为 const world = 'hello world'
还是先查看一下这两者的AST区别吧:
前者:
后者:
也就是说需要把Identifier中的name进行替换即可
const parser = require("@babel/parser");
const traverse = require("@babel/traverse");
const generator = require("@babel/generator");
// 源代码
const code = `
const hello = 'hello world';
`;
// 源代码解析成 ast
const ast = parser.parse(code);
const visitor = {
// traverse 会遍历树节点,只要节点的 type 在 visitor 对象中出现,变化调用该方法
Identifier(path) {
const { node } = path; //从path中解析出当前 AST 节点
if (node.name === "hello") {
node.name = "world"; //找到hello的节点,替换成world
}
}
};
traverse.default(ast, visitor);
const result = generator.default(ast, {}, code);
console.log(result.code); //const world = 'hello world';
回到前情提要
那我们再尝试对前情提要的问题使用babel插件进行解决
const parser = require("@babel/parser");
const traverse = require("@babel/traverse");
const generator = require("@babel/generator");
const template = require('babel-template');
// 定义try/catch语句模板
let tryCatchTemplate = `
try {
} catch (e) {
console.warn(catchError:e)
}`;
// 创建模板
const temp = template(tryTemplate);
let argumentObj = {
catchError: types.stringLiteral('Error')
};
// 通过temp创建try语句的AST节点
let tryNode = temp(tempArgumentObj);
// 源代码
const code = `
async function fn(){
await f()
}
`;
// 源代码解析成 ast
const ast = parser.parse(code);
const visitor = {
// traverse 会遍历树节点,只要节点的 type 在 visitor 对象中出现便调用
AwaitExpression(path) {
let node = path.node;
const asyncPath = path.findParent((p) => p.node.async && (p.isFunctionDeclaration() || p.isArrowFunctionExpression() || p.isFunctionExpression() || p.isObjectMethod()));
// TODO:构造一个tryNode
let tryNode ;
// 获取父节点的函数体body
let info = asyncPath.node.body;
// 将函数体放到try语句的body中
tryNode.block.body.push(...info.body);
// 将父节点的body替换成新创建的try语句
info.body = [tryNode];
}
};
traverse.default(ast, visitor);
const result = generator.default(ast, {}, code);
console.log(result.code);
//async function fn() {
// try {
// await f()
// } catch (e) {
// console.warn(e)
// }
//};
参考文章: juejin.cn/post/715515…