抽象语法树(AST),是一个很是基础而重要的知识点,但国内的文档却几乎一片空白。javascript
本文将带你们从底层了解AST,而且经过发布一个小型前端工具,来带你们了解AST的强大功能前端
Javascript就像一台精妙运做的机器,咱们能够用它来完成一切天马行空的构思。vue
咱们对javascript生态了如指掌,却常忽视javascript自己。这台机器,到底是哪些零部件在支持着它运行?java
AST在平常业务中也许很难涉及到,但当你不止于想作一个工程师,而想作工程师的工程师,写出vue、react之类的大型框架,或相似webpack、vue-cli前端自动化的工具,或者有批量修改源码的工程需求,那你必须懂得AST。AST的能力十分强大,且能帮你真正吃透javascript的语言精髓。node
事实上,在javascript世界中,你能够认为抽象语法树(AST)是最底层。 再往下,就是关于转换和编译的“黑魔法”领域了。react
人生第一次拆解Javascript 小时候,当咱们拿到一个螺丝刀和一台机器,人生中最使人怀念的梦幻时刻便开始了:webpack
咱们把机器,拆成一个一个小零件,一个个齿轮与螺钉,用巧妙的机械原理衔接在一块儿...web
当咱们把它从新照不一样的方式组装起来,这时,机器从新又跑动了起来——世界在你眼中如获新生。vue-cli
经过抽象语法树解析,咱们能够像童年时拆解玩具同样,透视Javascript这台机器的运转,而且从新按着你的意愿来组装。express
如今,咱们拆解一个简单的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 里面装的,是Identifer对象 b 就这样,咱们把一个简单的add函数拆解完毕,用图表示就是
那么,上面咱们提到的Identifier、Blockstatement、ReturnStatement、BinaryExpression, 这一个个小部件的说明书去哪查?
请查看 AST对象文档
送给你的AST螺丝刀:recast 输入命令:
npm i recast -S
复制代码
你便可得到一把操纵语法树的螺丝刀
接下来,你能够在任意js文件下操纵这把螺丝刀,咱们新建一个parse.js示意:
parse.js
// 给你一把"螺丝刀"——recast
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函数的结构,与以前所述一致,经过AST对象文档可查到它的具体属性:
FunctionDeclaration{
type: 'FunctionDeclaration',
id: ...
params: ...
body: ...
}
复制代码
你也能够继续使用console.log透视它的更内层,如:
console.log(add.params[0])
复制代码
console.log(add.body.body[0].argument.left)
复制代码
recast.types.builders 制做模具 一个机器,你只会拆开重装,不算本事。
拆开了,还能改装,才算上得了台面。
recast.types.builders里面提供了很多“模具”,让你能够轻松地拼接成新的机器。
最简单的例子,咱们想把以前的function add(a, b){...}声明,改为匿名函数式声明const add = function(a ,b){...}
如何改装?
第一步,咱们建立一个VariableDeclaration变量声明对象,声明头为const, 内容为一个即将建立的VariableDeclarator对象。
第二步,建立一个VariableDeclarator,放置add.id在左边, 右边是将建立的FunctionDeclaration对象
第三步,咱们建立一个FunctionDeclaration,如前所述的三个组件,id params body中,由于是匿名函数id设为空,params使用add.params,body使用add.body。
这样,就建立好了const add = function(){}的AST对象。
在以前的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;
};
复制代码
如今,你是否是已经产生了“我能够经过AST树生成任何js代码”的幻觉? 我郑重告诉你,这不是幻觉。 实战进阶:命令行修改js文件 除了parse/print/builder之外,Recast的三项主要功能:
run: 经过命令行读取js文件,并转化成ast以供处理。 tnt: 经过assert()和check(),能够验证ast对象的类型。 visit: 遍历ast树,获取有效的AST对象并进行更改。 咱们经过一个系列小务来学习所有的recast工具库:
建立一个用来示例文件,假设是demo.js
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.
复制代码
recast.run —— 命令行文件读取 新建一个名为read.js的文件,写入 read.js
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)
命令行输入node read demo.js
进行测试。
#!/usr/bin/env node 在全部使用 recast.run()的文件顶部都须要加入这一行,它的意义咱们最后再讨论。
复制代码
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);
}
});
});
复制代码
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.node
// 判断是否为ExpressionStatement,正确不输出,错误则全局报错
TNT.ExpressionStatement.assert(node)
this.traverse(path);
}
});
});
复制代码
命令行输入node read demo.js
进行测试。
实战:用AST修改源码,导出所有方法 exportific.js
如今,咱们但愿将demo中的function所有
咱们想让这个文件中的函数改写成可以所有导出的形式,例如
function add (a, b) {
return a + b
}
复制代码
想改变为
exports.add = (a, b) => {
return a + b
}
复制代码
除了使用fs.read读取文件、正则匹配替换文本、fs.write写入文件这种笨拙的方式外,咱们能够==用AST优雅地解决问题==。
查询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运行可查看结果。
接下来,只须要在得到的最终的表达式中,把id('add')替换成遍历获得的函数名,把参数替换成遍历获得的函数参数,把blockStatement([])替换为遍历获得的函数块级做用域,就成功地改写了全部函数!
另外,咱们须要注意,在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的源码编辑工具。
如下代码添加做了两个小改动
1.添加说明书--help,以及添加了--rewrite模式,能够直接覆盖文件或默认为导出*.export.js文件。 2.将以前代码最后的 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文件了。
npm发包 编辑一下package.json文件
{
"name": "exportific",
"version": "0.0.1",
"description": "改写源码中的函数为可exports.XXX形式",
"main": "exportific.js",
"bin": {
"exportific": "./exportific.js"
},
"keywords": [],
"author": "wanthering",
"license": "ISC",
"dependencies": {
"recast": "^0.15.3"
}
}
复制代码
注意bin选项,它的意思是将全局命令exportific指向当前目录下的exportific.js
这时,输入npm link 就在本地生成了一个exportific命令。
以后,只要哪一个js文件想导出来使用,就exportific XXX.js一下。
这是在本地的玩法,想和你们一块儿分享这个前端小工具,只须要发布npm包就好了。
同时,必定要注意exportific.js文件头有
#!/usr/bin/env node
复制代码
不然在使用时将报错。
接下来,正式发布npm包! 若是你已经有了npm 账号,请使用npm login登陆
若是你尚未npm账号 www.npmjs.com/signup 很是简单就能够注册npm
而后,输入 npm publish
没有任何繁琐步骤,丝毫审核都没有,你就发布了一个实用的前端小工具exportific 。任何人均可以经过
npm i exportific -g
复制代码
全局安装这一个插件。
提示:==在试验教程时,请不要和个人包重名,修改一下发包名称。==
结语 咱们对javascript再熟悉不过,但透过AST的视角,最普通的js语句,却焕发出精心动魄的美感。你能够经过它批量构建任何javascript代码!
这里推荐一下个人前端学习交流群:784783012,里面都是学习前端的,若是你想制做酷炫的网页,想学习编程。本身整理了一份2018最全面前端学习资料,从最基础的HTML+CSS+JS【炫酷特效,游戏,插件封装,设计模式】到移动端HTML5的项目实战的学习资料都有整理,送给每一位前端小伙伴,有想学习web前端的,或是转行,或是大学生,还有工做中想提高本身能力的,正在学习的小伙伴欢迎加入学习。