前言
在之前的学习之中。我多次碰到过 AST,与它擦肩而过。例如:webpack、Taro、前端的逆向调试(碰上变态的混淆)、以及一些自己开发的“玩具”。但每次都是前面学完很久不用,后面就忘记!官方文档虽然写的非常详实,但是每次重读一遍确实令人揪心。这次决定,写下来一些,以免忘记。也给大家分享下。我们着重于动手操作,概念可以根据文末的参考文献及工具章节有比我更详尽的解释。
说到AST语法树,离不开 Babel ,它的工作流可以用下面一张图来表示,代码首先经由 babylon 解析成抽象语法树(AST),后经一些遍历和分析转换(主要过程),最后根据转换后的 AST 生成新的常规代码。
AST语法树长这样:
准备
以下列出来的清单,是开启本文的前置条件。
- ES6
- nodejs 以及vscode(熟悉并了解node脚本本调试方式)
- babel
- 深刻明白仔细阅读文档的重要性!
开始准备环境
mkdir AST_Test
cnpm init -y
cnpm i @babel/generator @babel/parser @babel/traverse @babel/types --save
touch index.js
package.json
注意加一个"type": "module"
,开启我们的 ESM 模块写法(注意 node 版本)。
其他没啥好讲的,解释下四个包到底干啥的:
- @babel/parser: 解析 Javascript 代码为 AST 结构。(解析包)
- @babel/traverse: 转换 AST 结构为自己需要的。(转换包)
- @babel/types: AST 结构有些比较复杂,这是个工具包帮助生成那些复杂语法对象。
- @babel/generator:解析 AST 结构为 Javascript 代码。(生成包)
@babel/types是个辅助包,其他的三个分别对应了我们 AST 的三个步骤:解析-转换-生成。我们后面也是根据这三个步骤展开!
解析
我们先修改 index.js
运行如下代码:
import parser from "@babel/parser";
const { parse } = parser;
const code = `var square = function (n) {
return n * n;
}`
const ast = parse(code);
console.log(ast.program.body)
我们先使用了一个 Javascript 转为 AST 结构,接下来打印出来的东西,如果你第一次看到,也许有点懵。
可以看到信息量很大很大,这里我们先只关注熟悉的内容。先看花括号,这里要记住一点:一个花括号就是一个 Node。
每个 Node 都包含有一个重要的属性,就是 type!
借助astexplorer
astexplorer.net/ 强烈推荐这个网址,我们可以直接在线查看某段代码的 AST 语法树结构,并且支持多种编程语言。
如红线所示区域,我将一些不重要的内容隐藏了起来(也有用,只是此处忽略)。这样一来,我们可以着重观察属性,分别代表着什么?
网站给出的 demo 代码:
let tips = [
"Click on any AST node with a '+' to expand it",
"Hovering over a node highlights the \
corresponding location in the source code",
"Shift click on an AST node to expand the whole subtree"
];
function printTips() {
tips.forEach((tip, i) => console.log(`Tip ${i}:` + tip));
}
首先映入眼帘的是:Program 这个对象,身上有一个 sourceType ,它有两个取值,一个是 module,另外一个则是script。意思想必也猜到了,一个是模块化的 Javascript ,另外一个是脚本形式的。
接下来 body 属性是一个数组,里面包含了一个变量声明(VariableDeclaration)和函数声明(FunctionDeclaration)。分别对应我们代码里面的两大块。
开始分析
变量声明(VariableDeclaration)
完全展开后,大概是长成这个样子。
- kind: "var" | "let" | "const" (必填)
- declarations: VariableDeclarator 形式的数组 (必填) 那么 VariableDeclarator 里面又是什么呢?
- id:标识符。
- init:表达式或者为空都可以。
函数声明(FunctionDeclaration) 为了简单起见,我这里简化了 demo 代码:
function printTips() {
return 1;
}
此时 AST 如下图所示:
- id: 标识符 (可选,可以为空)
- params: 数组标识符 (必填)
- body: 块语句 (必填)
在块语句里面,又包含了一个 return 语句,参数为1。
如何查阅
刚刚我们讲了那两个声明,我想你已经心里开始嘀咕了,好麻烦啊,这么多都要记住么?
当然不可能记住,我们需要依赖文档来进行查询,还记得前面我们提到的四个包么,有一个包叫@babel/types。点进去看看,是不是一目了然?所有的语句定义以及它们各自的 interface 都可以找到。
转换
接下来这里是一个大章节,这里也是我们平时对代码动手脚最多的地方。按照其他技术大佬的惯例,我们先来一个箭头函数转匿名函数(ES6>ES5)的 demo。
大家先行体会,我们再来解释
//index.js
import parser from "@babel/parser";
import traverse from "@babel/traverse";
import generate from "@babel/generator";
import t from '@babel/types';
const code = `
var a = () =>'Hello, 我是段需要转化的代码!';
`;
const ast = parser.parse(code);
traverse.default(ast, {
ArrowFunctionExpression(path) {
var func = t.functionExpression(
null,
[],
t.blockStatement([t.returnStatement(path.node.body)])
)
path.replaceWith(func)
}
})
const res = generate.default(ast)
console.log(res.code)
看到代码肯定是懵的,不要着急,从 const ast = parser.parse(code);
这段开始,我们前面都讲过了。
重点逻辑在于:
traverse.default(ast, {
ArrowFunctionExpression(path) {
var func = t.functionExpression(
null,
[],
t.blockStatement([t.returnStatement(path.node.body)])
)
path.replaceWith(func)
}
})
Visitor
在转化的过程中,我们要牢记一个叫做 Visitor(访问者) 的概念。
每个被解析的语法都会被遍历,在这个过程中遇到了例如:箭头函数,变量声明等等,就会运行里面的逻辑。
可以理解为是一个钩子函数,触发写好的逻辑。例如上面代码里面:ArrowFunctionExpression 类型就会被触发里面的逻辑。
如果想每个都触发,有对应的:enter(进入遍历) 和 exit(退出遍历)两个钩子函数 。同时每个钩子函数里面都有这两个小勾子:
traverse.default(ast, {
ArrowFunctionExpression: {
enter(path) {
console.log("Entered!");
},
exit(path) {
console.log("Exited!");
}
}
})
会先触发 enter 再触发 exit。一般来说不推荐使用全局触发,尽量以精确的方式定位到节点。如果你需要同时指定多种类型为同一个逻辑可以这样写:
traverse.default(ast, {
['ArrowFunctionExpression|Identifier'](path) {
console.log(path.node.type)
}
})
当箭头函数和标识符出现的时候,就会触发!需要查询哪些钩子?同样在前面给出的@babel/types都可以找到。
Path
这里一定要注意, path 和前面说的 AST 节点,不要搞混,是两个人,尽管是亲戚关系。也许官方的教程里面并没有强调(但意思也说明了)。
Path 是表示两个节点之间连接的对象。
我们拿它作为修改 AST 语法树的一个途径。要讲明白这个东西,我们必须要在 vscode 里面来做一个详细的调试,上图!
属性非常多,里面都是关于路径操作和路径相关的信息。 我们重点看:
- node 节点
- parentPath 父节点
- scope 作用域
可以看到,node里面包含的就是我们平时最需要的一些内容。再回到前面的代码里:
traverse.default(ast, {
ArrowFunctionExpression(path) {
var func = t.functionExpression(
null,
[],
t.blockStatement([t.returnStatement(path.node.body)])
)
path.replaceWith(func)
}
})
我用 t 对象调用,也就是 @babel/types这个工具包里的,来对照生成了一个普通的匿名函数。你可能会有疑问,这是怎么写出来的?答案是vscode:
当我们使用某个句子的时候,就会有提示输入的参数。关于使用什么句式比较好,可以直接用astexplorer在线一转就知道了!
我们对path进行操作,就可以实现我们想要的一些逻辑。例子里面我就使用了 path.replaceWith 这个方法。
除此之外还有例如:
- path.toString() 转化为代码
- path.traverse() 递归的形式消除全局状态(官网例子已经不错了)
- path.get() 更加方便的拿到路径,例如:
path.get('body.0');
可以理解为:path.node.body[0]
这样的形式,但是注意仅路径可以这样操作,访问属性是不允许的! - path.isXX() XX为节点类型,可以判断是否符合节点类型。(types包也可以)
- path.getFunctionParent() 查找最接近的父函数或程序
- path.getStatementParent() 向上遍历语法树,直到找到在列表中的父节点路径
- path.findParent((path) => path.isObjectExpression()) 对于每一个父路径调用callback并将其NodePath当作参数,当callback返回真值时,则将其NodePath返回。
- path.find((path) => path.isObjectExpression()) 如果也需要遍历当前节点
- path.inList 来判断路径是否有同级节点,
- path.getSibling(index) 来获得同级路径,
- path.key 获取路径所在容器的索引,
- path.container 获取路径的容器(包含所有同级节点的数组)
- path.listKey 获取容器的key
- path.skip() 不往下遍历,跳过该节点
- path.stop() 停止遍历
- path.replaceWithMultiple() 替换多个节点,传入数组
- path.replaceWithSourceString() 用字符串替换源码
- path.insertBefore(); 在此节点之前插入节点
- path.insertAfter();在此节点之后插入节点
- path.remove();删除节点
关于path的常规操作非常多,尽管我列出来了这些但很可惜,我并没有找到完整的文档去把每一个方法介绍的非常清楚。这里我给出我的方法(有更好的办法或者找到文档可以留言)
在整个 path 对象的 prototype 上面挂载了非常非常多的函数,replaceWith 就是其中之一,通过这个方式查询大概可以了解它的用法。
展开后也可以找到它的代码实现地方,参照源码查看使用。
举例,path.addComment 方法,源码核心逻辑如下:
function addComments(node, type, comments) {
if (!comments || !node) return node;
const key = `${type}Comments`;
if (node[key]) {
if (type === "leading") {
node[key] = comments.concat(node[key]);
} else {
node[key] = node[key].concat(comments);
}
} else {
node[key] = comments;
}
return node;
}
根据断点调试可知,第一个参数必然是注释类型,第二个参数是注释的内容,第三个是控制多行和单行注释。
甚至我们可以手工直接这样加,也是生效的!
path.node.leadingComments = [
{
type: "CommentBlock",
value: "这是我的一段注释2",
},
]
Scope
作用域相信大家熟悉的不能再熟悉了吧!先上代码!
//index.js
import parser from "@babel/parser";
import traverse from "@babel/traverse";
import generate from "@babel/generator";
import t from '@babel/types';
const code = `
function a() {
var str = 'Hello, 我是段需要转化的代码!'
return str;
};
`;
const ast = parser.parse(code);
traverse.default(ast, {
VariableDeclaration(path) {
console.log(path.scope)
}
})
const res = generate.default(ast)
console.log(res.code)
可以看到,描述一个作用域。先知道重要的一个属性 bindings 代表变量被绑定的情况。
来看 scope 上的方法:
- path.scope.hasBinding("n") 检查本地 n 变量是否被绑定(定义)。
- path.scope.hasOwnBinding("n") 检查自身作用域里面有没有绑定(定义)过 n 变量。
- path.scope.generateUidIdentifier("uid") 生成一个当前作用域下肯定不存在的变量名称。
- path.scope.rename("n", "x") 重命名变量。
- path.scope.parent.push(节点) 在父作用域里面插入一个节点。
这里重点要说下
path.scope.parent.push
这个方法,先引用官方文档里面的一个例子,看看大家是否会跟我一样有疑惑?
有时你可能想要推送一个 VariableDeclaration ,这样你就可以分配给它。
FunctionDeclaration(path) { const id = path.scope.generateUidIdentifierBasedOnNode(path.node.id); path.remove(); path.scope.parent.push({ id, init: path.node }); }
- function square(n) { + var _square = function square(n) { return n * n; - } + };
如果照着去做,你很快先会发现完全转出来的不是一回事:
var _square;
先贴出来我成功的转换代码:
import parser from "@babel/parser";
import traverse from "@babel/traverse";
import generate from "@babel/generator";
import t from '@babel/types';
const code = `
function square(n) {
return n * n;
}
`;
const ast = parser.parse(code);
traverse.default(ast, {
FunctionDeclaration(path) {
const funcName = path.node.id.name;//储存函数名称
const oldPath = path.node.body;//老的内部节点
const params = path.node.params[0].name;//参数名称
path.remove();//移出原先节点(注意前面我用变量储存了,如果这里移除了不用变量存起来,path将会丢失!)
path.scope.parent.push(
t.variableDeclarator(
path.scope.generateUidIdentifier(funcName),//生成一个唯一的变量名称
t.functionExpression(//定义函数
t.identifier(funcName),
[
t.identifier(params)
],
oldPath
)
)
);
}
})
const res = generate.default(ast)
console.log(res.code)
这样的转换才符合我们的预期!但这并不是我的问题所在,在第一次看到这个 API 的时候,我一直误以为官方例子当中
path.scope.parent.push({ id, init: path.node });
是需要传入一个特定结构的对象,然而经过我的测试,这里是用来传入节点的,因此大家通过我的例子,受到相同误导的也可以有些启发。
生成
关于生成其实没什么好说的,前往@babel/generator查阅更多。
const res = generate.default(ast)
console.log(res)
笔记
generateUid 系列api真香
之前只是知道它可以自动创建变量而已,并没有发现,在这里竟然派上大用!
//等待转换的代码如下
var n = 123//外部作用域n
function square(n) {
return n * n;//内部作用域的n
}
console.log(n)
需求:将这两个 n 分别重命名!
import parser from "@babel/parser";
import traverse from "@babel/traverse";
import generate from "@babel/generator";
import t from '@babel/types';
const code = `
var n = 123
function square(n) {
return n * n;
}
console.log(n)
`;
const ast = parser.parse(code);
traverse.default(ast, {
['FunctionDeclaration|VariableDeclaration'](path) {
path.scope.rename('n',path.scope.generateUid())//如果这里不用生成uid,而是写死某个值,那就实现不了这个功能!
}
})
const res = generate.default(ast)
console.log(res.code)
最后输出:
var _temp = 123;
function square(_temp2) {
return _temp2 * _temp2;
}
console.log(_temp);