初探AST-抽象语法树
Javascript
就像一台精妙运作的机器,我们可以用它来完成一切天马行空的构思。
我们对Javascript
生态了如指掌,却常忽视Javascript
本身,究竟是哪些零部件在支持着它运行?
AST
在日常业务中也许很难涉及到,但当你不止于想做一个工程师,而想做工程师的工程师,写出vue、react
之类的大型框架,或类似webpack、vue-cli
前端的自动化工具,或者有批量修改源码的工程需求,那你必须懂得AST
。AST
的能力十分强大,且能帮你真正吃透Javascript
的语言精髓。
事实上,在Javascript
世界中,你可以认为抽象语法树(AST)
是最底层。 再往下,就是关于转换和编译的“黑魔法”领域了。
拆解JavaScript
拆解一个简单的add函数
function add(a, b) {
return a + b
}
- 首先,这个语法块是一个
FunctionDeclaration(函数定义)
对象。
拆开成了三块- 一个
id
,也就是它的名字:add
- 两个
params
,参数:[a, b]
- 一块
body
,函数体
- 一个
至此,add
就无法继续拆解了,它是一个最基础Identifier(标志)
对象,用来作为函数的唯一标志。
{
name: 'add'
type: 'identifier'
...
}
-
params
继续拆解成两个Identifier
组成的数组,之后也没办法拆下去了。
[
{
name: 'a'
type: 'identifier'
...
},
{
name: 'b'
type: 'identifier'
...
}
]
- 拆解
body
body
其实是一个BlockStatement(块状域)
对象,用来表示是{ return a + b }
打开Blockstatement
,里面藏着一个ReturnStatement(Return域)
对象,用来表示return a + b
继续打开ReturnStatement
,里面是一个BinaryExpression(二项式)
对象,用来表示a + b
继续打开BinaryExpression
,它成了三部分:left,operator,right
operator:+
-
left
里面装的,是Identifier
对象a
-
right
里面装的,是Identifier
对象b
拆解之后,用图表示:
拆解图.pngLook!抽象语法树(Abstract Syntax Tree,AST)
的确是一种标准的树结构。
至于 Identifier、Blockstatement、ReturnStatement、BinaryExpression
等部件的说明书,可查看
AST对象文档
AST螺丝刀:recast
安装 npm i recast -S
即可获得一把操纵语法树的螺丝刀;
新建一个文件parse.js
const recast = require("recast");
// 我们使用了很奇怪格式的代码,想测试是否能维持代码结构
const code =
`
function add(a, b) {
return a +
// 有什么奇怪的东西混进来了
b
}
`
// 用螺丝刀解析机器
const ast = recast.parse(code);
// ast可以处理很巨大的代码文件
// 但我们现在只需要代码块的第一个body,即add函数
const add = ast.program.body[0]
console.log(add)
运行 node parse.js
可以查看add
函数的结构:
FunctionDeclaration {
type: 'FunctionDeclaration',
id: ...
params: ...
body: ...
...
}
还可以继续透视它的更内层:
console.log(add.params[0])
Identifier {
type: 'Identifier',
name: 'a'
loc: ...
}
console.log(add.body.body[0].argument.left)
Identifier {
type: 'Identifier',
name: 'a'
loc: ...
}
制作模具
一个机器,你只会拆开重装,不算本事。拆开了,还能改装,才算上得了台面。
recast.types.builders
里面提供了不少“模具”,让你可以轻松地拼接成新的机器。
最简单的例子,把之前的函数add
声明,改成匿名函数式声明const add = function(a ,b) {...}
- 创建一个
VariableDeclaration
变量声明对象,声明头为const
, 内容为一个即将创建的VariableDeclarator
对象。 - 创建一个
VariableDeclarator
,声明头为add.id
, 右边是将创建的FunctionDeclaration
对象。 - 创建一个
FunctionDeclaration
,如前所述的三个组件id、params、body
中,因为是匿名函数,id
设为空,params
使用add.params
,body
使用add.body
。
加入parse.js
中
// 引入变量声明,变量符号,函数声明三种“模具”
const { variableDeclaration, variableDeclarator, functionExpression } = recast.types.builders;
// 将准备好的组件置入模具,并组装回原来的ast对象。
ast.program.body[0] = variableDeclaration("const", [
variableDeclarator(add.id, functionExpression(
null, // Anonymize the function expression.
add.params,
add.body
))
]);
//将AST对象重新转回可以阅读的代码
const output = recast.print(ast).code;
console.log(output)
>>>>>打印所得:
const add = function(a, b) {
return a +
// 有什么奇怪的东西混进来了
b
};
其中,const output = recast.print(ast).code
其实是recast.parse
的逆向过程,具体公式为:
recast.print(recast.parse(source)).code === source
打印出美化格式的代码段:
const output = recast.prettyPrint(ast, { tabWidth: 2 }).code;
>>>>
const add = function(a, b) {
return a + b;
};
命令行修改JS文件
除了parse/print/builder
之外,recast
的三项主要功能:
-
run
:通过命令行读取JS文件,并转化成ast
以供处理; -
tnt
:通过assert()
和check()
,可以验证ast
对象的类型; -
visit
:遍历ast
树,获取有效的AST
对象并进行更改。
新建 demo.js
function add(a, b) {
return a + b
}
function sub(a, b) {
return a - b
}
function commonDivision(a, b) {
while (b !== 0) {
if (a > b) {
a = sub(a, b)
} else {
b = sub(b, a)
}
}
return a
}
-
recast.run ——
命令行文件读取
新建一个名为read.js
的文件#!/usr/bin/env node const recast = require('recast'); recast.run(function(ast, printSource){ printSource(ast); });
命令行输入
node read demo.js
,可以看到JS文件内容打印在了控制台上。
我们可以知道,node read
可以读取demo.js
文件,并将demo.js
内容转化为ast
对象。
同时它还提供了一个printSource
函数,随时可以将ast
的内容转换回源码,以方便调试。 -
recast.visit ——
AST节点遍历
read.js
#!/usr/bin/env node const recast = require('recast'); recast.run(function(ast, printSource){ recast.visit(ast, { visitExpressionStatement: function({node}) { console.log(node); return false; } }); });
recast.visit
将AST
对象内的节点进行逐个遍历。- 如果想操作函数声明,就使用
visitFunctionDelaration
遍历,想操作赋值表达式,就使用visitExpressionStatement
。 只要在 AST对象文档中定义的对象,在前面加visit
,即可遍历。 - 通过
node
可以取到AST
对象。 - 每个遍历函数后必须加上
return false
,或者选择以下写法,否则报错:
#!/usr/bin/env node const recast = require('recast'); recast.run(function(ast, printSource){ recast.visit(ast, { visitExpressionStatement: function(path) { const node = path.node; printSource(node); this.traverse(path); } }); });
调试时,如果你想输出AST对象,可以
console.log(node)
如果想输出AST对象对应的源码,可以printSource(node)
在所有使用recast.run()
的文件顶部都需要加入#!/usr/bin/env node
。 - 如果想操作函数声明,就使用
-
TNT ——
判断AST对象类型
TNT,即recast.types.namedTypes
,用来判断AST对象是否为指定的类型。
TNT.Node.assert()
,就像在机器里埋好的炸药,当机器不能完好运转时(类型不匹配),就炸毁机器(报错退出)TNT.Node.check()
,则可以判断类型是否一致,并输出False 和 True
Node
可以替换成任意AST对象,例如TNT.ExpressionStatement.check(),TNT.FunctionDeclaration.assert()
read.js
#!/usr/bin/env node const recast = require('recast'); const TNT = recast.types.namedTypes; recast.run(function(ast, printSource){ recast.visit(ast, { visitExpressionStatement: function(path) { const node = path.value; // 判断是否为ExpressionStatement,正确则输出一行字。 if(TNT.ExpressionStatement.check(node)){ console.log('这是一个ExpressionStatement') } this.traverse(path); } }); }); -------------------------------------------------------- recast.run(function(ast, printSource){ recast.visit(ast, { visitExpressionStatement: function(path) { const node = path.node; // 判断是否为ExpressionStatement,正确不输出,错误则全局报错 TNT.ExpressionStatement.assert(node); this.traverse(path); } }); });
命令行输入
node read demo.js
进行测试。
用AST修改源码,导出demo.js
的全部方法
除了使用 fs.read
读取文件、正则匹配替换文本、fs.write
写入文件这种笨拙的方式外,我们可以用AST
优雅地解决问题。
-
首先,我们先用builders凭空实现一个键头函数:
exportific.js
#!/usr/bin/env node const recast = require('recast'); const { identifier:id, expressionStatement, memberExpression, assignmentExpression, arrowFunctionExpression, blockStatement } = recast.types.builders; recast.run(function(ast, printSource) { // 一个块级域 {} console.log('\n\nstep1:'); printSource(blockStatement([])); // 一个键头函数 ()=>{} console.log('\n\nstep2:'); printSource(arrowFunctionExpression([],blockStatement([]))); // add赋值为键头函数 add = ()=>{} console.log('\n\nstep3:'); printSource(assignmentExpression('=',id('add'),arrowFunctionExpression([],blockStatement([])))); // exports.add赋值为键头函数 exports.add = ()=>{} console.log('\n\nstep4:'); printSource(expressionStatement(assignmentExpression('=', memberExpression(id('exports'), id('add')), arrowFunctionExpression([], blockStatement([]))))); });
我们一步一步推断出
exports.add = ()=>{}
的过程,从而得到具体的AST结构体。
执行node exportific demo.js
命令查看:step1: {} step2: () => {} step3: add = () => {} step4: exports.add = () => {};
-
接下来,只需要在获得的最终的表达式中,把
id('add')
替换成遍历得到的函数名,把参数替换成遍历得到的函数参数,把blockStatement([])
替换为遍历得到的函数块级作用域,就成功地改写了所有函数!另外,需要注意的是,在
demo.js
的commonDivision
函数内,引用了sub
函数,应改写成exports.sub
exportific.js
```
#!/usr/bin/env node
const recast = require("recast");
const {
identifier: id,
expressionStatement,
memberExpression,
assignmentExpression,
arrowFunctionExpression
} = recast.types.builders
recast.run(function (ast, printSource) {
// 用来保存遍历到的全部函数名
let funcIds = []
recast.types.visit(ast, {
// 遍历所有的函数定义
visitFunctionDeclaration(path) {
//获取遍历到的函数名、参数、块级域
const node = path.node
const funcName = node.id
const params = node.params
const body = node.body
// 保存函数名
funcIds.push(funcName.name)
// 这是上一步推导出来的ast结构体
const rep = expressionStatement(assignmentExpression('=', memberExpression(id('exports'), funcName),
arrowFunctionExpression(params, body)))
// 将原来函数的ast结构体,替换成推导ast结构体
path.replace(rep)
// 停止遍历
return false
}
})
recast.types.visit(ast, {
// 遍历所有的函数调用
visitCallExpression(path){
const node = path.node;
// 如果函数调用出现在函数定义中,则修改ast结构
if (funcIds.includes(node.callee.name)) {
node.callee = memberExpression(id('exports'), node.callee)
}
// 停止遍历
return false
}
})
// 打印修改后的ast源码
printSource(ast)
})
```
一步到位,发一个最简单的exportific前端工具
上面讲了那么多,仍然只体现在理论阶段。
但通过简单的改写,就能通过 recast
制作成一个名为 exportific
的源码编辑工具。
以下代码添加作了两个小改动:
- 添加说明书
--help
,以及添加了--rewrite
模式,可以直接覆盖文件或默认为导出*.export.js
文件。 - 将之前代码最后的
printSource(ast)
替换成writeASTFile(ast, filename, rewriteMode)
exportific.js
#!/usr/bin/env node
const recast = require("recast");
const {
identifier: id,
expressionStatement,
memberExpression,
assignmentExpression,
arrowFunctionExpression
} = recast.types.builders
const fs = require('fs')
const path = require('path')
// 截取参数
const options = process.argv.slice(2)
//如果没有参数,或提供了-h 或--help选项,则打印帮助
if(options.length===0 || options.includes('-h') || options.includes('--help')){
console.log(`
采用commonjs规则,将.js文件内所有函数修改为导出形式。
选项: -r 或 --rewrite 可直接覆盖原有文件
`)
process.exit(0)
}
// 只要有-r 或--rewrite参数,则rewriteMode为true
let rewriteMode = options.includes('-r') || options.includes('--rewrite')
// 获取文件名
const clearFileArg = options.filter((item)=>{
return !['-r','--rewrite','-h','--help'].includes(item)
})
// 只处理一个文件
let filename = clearFileArg[0]
const writeASTFile = function(ast, filename, rewriteMode){
const newCode = recast.print(ast).code
if(!rewriteMode){
// 非覆盖模式下,将新文件写入*.export.js下
filename = filename.split('.').slice(0,-1).concat(['export','js']).join('.')
}
// 将新代码写入文件
fs.writeFileSync(path.join(process.cwd(),filename),newCode)
}
recast.run(function (ast, printSource) {
let funcIds = []
recast.types.visit(ast, {
visitFunctionDeclaration(path) {
//获取遍历到的函数名、参数、块级域
const node = path.node
const funcName = node.id
const params = node.params
const body = node.body
funcIds.push(funcName.name)
const rep = expressionStatement(assignmentExpression('=', memberExpression(id('exports'), funcName),
arrowFunctionExpression(params, body)))
path.replace(rep)
return false
}
})
recast.types.visit(ast, {
visitCallExpression(path){
const node = path.node;
if (funcIds.includes(node.callee.name)) {
node.callee = memberExpression(id('exports'), node.callee)
}
return false
}
})
writeASTFile(ast,filename,rewriteMode)
})
运行一下:node exportific demo.js
,得到文件demo.export.js
exports.add = (a, b) => {
return a + b
};
exports.sub = (a, b) => {
return a - b
};
exports.commonDivision = (a, b) => {
while (b !== 0) {
if (a > b) {
a = exports.sub(a, b)
} else {
b = exports.sub(b, a)
}
}
return a
};