Javascript
就像一臺(tái)精妙運(yùn)作的機(jī)器为黎,我們可以用它來完成一切天馬行空的構(gòu)思躺孝。
我們對(duì)Javascript
生態(tài)了如指掌澈驼,卻常忽視Javascript
本身魂仍,究竟是哪些零部件在支持著它運(yùn)行捉貌?
AST
在日常業(yè)務(wù)中也許很難涉及到讯壶,但當(dāng)你不止于想做一個(gè)工程師鼠渺,而想做工程師的工程師,寫出vue你辣、react
之類的大型框架巡通,或類似webpack、vue-cli
前端的自動(dòng)化工具舍哄,或者有批量修改源碼的工程需求宴凉,那你必須懂得AST
。AST
的能力十分強(qiáng)大表悬,且能幫你真正吃透Javascript
的語言精髓弥锄。
事實(shí)上,在Javascript
世界中蟆沫,你可以認(rèn)為抽象語法樹(AST)
是最底層籽暇。 再往下,就是關(guān)于轉(zhuǎn)換和編譯的“黑魔法”領(lǐng)域了饭庞。
拆解JavaScript
拆解一個(gè)簡單的add函數(shù)
function add(a, b) {
return a + b
}
- 首先戒悠,這個(gè)語法塊是一個(gè)
FunctionDeclaration(函數(shù)定義)
對(duì)象。
拆開成了三塊- 一個(gè)
id
但绕,也就是它的名字:add
- 兩個(gè)
params
救崔,參數(shù):[a, b]
- 一塊
body
,函數(shù)體
- 一個(gè)
至此捏顺,add
就無法繼續(xù)拆解了六孵,它是一個(gè)最基礎(chǔ)Identifier(標(biāo)志)
對(duì)象,用來作為函數(shù)的唯一標(biāo)志幅骄。
{
name: 'add'
type: 'identifier'
...
}
-
params
繼續(xù)拆解成兩個(gè)Identifier
組成的數(shù)組劫窒,之后也沒辦法拆下去了。
[
{
name: 'a'
type: 'identifier'
...
},
{
name: 'b'
type: 'identifier'
...
}
]
- 拆解
body
body
其實(shí)是一個(gè)BlockStatement(塊狀域)
對(duì)象拆座,用來表示是{ return a + b }
打開Blockstatement
主巍,里面藏著一個(gè)ReturnStatement(Return域)
對(duì)象冠息,用來表示return a + b
繼續(xù)打開ReturnStatement
,里面是一個(gè)BinaryExpression(二項(xiàng)式)
對(duì)象孕索,用來表示a + b
繼續(xù)打開BinaryExpression
逛艰,它成了三部分:left,operator搞旭,right
operator:+
-
left
里面裝的散怖,是Identifier
對(duì)象a
-
right
里面裝的,是Identifier
對(duì)象b
拆解之后肄渗,用圖表示:
Look镇眷!抽象語法樹(Abstract Syntax Tree,AST)
的確是一種標(biāo)準(zhǔn)的樹結(jié)構(gòu)翎嫡。
至于 Identifier欠动、Blockstatement、ReturnStatement惑申、BinaryExpression
等部件的說明書具伍,可查看
AST對(duì)象文檔
AST螺絲刀:recast
安裝 npm i recast -S
即可獲得一把操縱語法樹的螺絲刀;
新建一個(gè)文件parse.js
const recast = require("recast");
// 我們使用了很奇怪格式的代碼硝桩,想測(cè)試是否能維持代碼結(jié)構(gòu)
const code =
`
function add(a, b) {
return a +
// 有什么奇怪的東西混進(jìn)來了
b
}
`
// 用螺絲刀解析機(jī)器
const ast = recast.parse(code);
// ast可以處理很巨大的代碼文件
// 但我們現(xiàn)在只需要代碼塊的第一個(gè)body沿猜,即add函數(shù)
const add = ast.program.body[0]
console.log(add)
運(yùn)行 node parse.js
可以查看add
函數(shù)的結(jié)構(gòu):
FunctionDeclaration {
type: 'FunctionDeclaration',
id: ...
params: ...
body: ...
...
}
還可以繼續(xù)透視它的更內(nèi)層:
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: ...
}
制作模具
一個(gè)機(jī)器枚荣,你只會(huì)拆開重裝碗脊,不算本事。拆開了橄妆,還能改裝衙伶,才算上得了臺(tái)面。
recast.types.builders
里面提供了不少“模具”害碾,讓你可以輕松地拼接成新的機(jī)器矢劲。
最簡單的例子,把之前的函數(shù)add
聲明慌随,改成匿名函數(shù)式聲明const add = function(a ,b) {...}
- 創(chuàng)建一個(gè)
VariableDeclaration
變量聲明對(duì)象芬沉,聲明頭為const
, 內(nèi)容為一個(gè)即將創(chuàng)建的VariableDeclarator
對(duì)象阁猜。 - 創(chuàng)建一個(gè)
VariableDeclarator
丸逸,聲明頭為add.id
, 右邊是將創(chuàng)建的FunctionDeclaration
對(duì)象剃袍。 - 創(chuàng)建一個(gè)
FunctionDeclaration
黄刚,如前所述的三個(gè)組件id、params民效、body
中憔维,因?yàn)槭悄涿瘮?shù)涛救,id
設(shè)為空,params
使用add.params
业扒,body
使用add.body
检吆。
加入parse.js
中
// 引入變量聲明,變量符號(hào)程储,函數(shù)聲明三種“模具”
const { variableDeclaration, variableDeclarator, functionExpression } = recast.types.builders;
// 將準(zhǔn)備好的組件置入模具咧栗,并組裝回原來的ast對(duì)象。
ast.program.body[0] = variableDeclaration("const", [
variableDeclarator(add.id, functionExpression(
null, // Anonymize the function expression.
add.params,
add.body
))
]);
//將AST對(duì)象重新轉(zhuǎn)回可以閱讀的代碼
const output = recast.print(ast).code;
console.log(output)
>>>>>打印所得:
const add = function(a, b) {
return a +
// 有什么奇怪的東西混進(jìn)來了
b
};
其中虱肄,const output = recast.print(ast).code
其實(shí)是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
的三項(xiàng)主要功能:
-
run
:通過命令行讀取JS文件咏窿,并轉(zhuǎn)化成ast
以供處理斟或; -
tnt
:通過assert()
和check()
,可以驗(yàn)證ast
對(duì)象的類型集嵌; -
visit
:遍歷ast
樹萝挤,獲取有效的AST
對(duì)象并進(jìn)行更改。
新建 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 ——
命令行文件讀取
新建一個(gè)名為read.js
的文件#!/usr/bin/env node const recast = require('recast'); recast.run(function(ast, printSource){ printSource(ast); });
命令行輸入
node read demo.js
根欧,可以看到JS文件內(nèi)容打印在了控制臺(tái)上怜珍。
我們可以知道,node read
可以讀取demo.js
文件凤粗,并將demo.js
內(nèi)容轉(zhuǎn)化為ast
對(duì)象酥泛。
同時(shí)它還提供了一個(gè)printSource
函數(shù),隨時(shí)可以將ast
的內(nèi)容轉(zhuǎn)換回源碼嫌拣,以方便調(diào)試柔袁。 -
recast.visit ——
AST節(jié)點(diǎn)遍歷
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
對(duì)象內(nèi)的節(jié)點(diǎn)進(jìn)行逐個(gè)遍歷。- 如果想操作函數(shù)聲明异逐,就使用
visitFunctionDelaration
遍歷捶索,想操作賦值表達(dá)式,就使用visitExpressionStatement
灰瞻。 只要在 AST對(duì)象文檔中定義的對(duì)象腥例,在前面加visit
,即可遍歷酝润。 - 通過
node
可以取到AST
對(duì)象燎竖。 - 每個(gè)遍歷函數(shù)后必須加上
return false
,或者選擇以下寫法袍祖,否則報(bào)錯(cuò):
#!/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); } }); });
調(diào)試時(shí)底瓣,如果你想輸出AST對(duì)象,可以
console.log(node)
如果想輸出AST對(duì)象對(duì)應(yīng)的源碼,可以printSource(node)
在所有使用recast.run()
的文件頂部都需要加入#!/usr/bin/env node
捐凭。 - 如果想操作函數(shù)聲明异逐,就使用
-
TNT ——
判斷AST對(duì)象類型
TNT拨扶,即recast.types.namedTypes
,用來判斷AST對(duì)象是否為指定的類型茁肠。
TNT.Node.assert()
患民,就像在機(jī)器里埋好的炸藥,當(dāng)機(jī)器不能完好運(yùn)轉(zhuǎn)時(shí)(類型不匹配)垦梆,就炸毀機(jī)器(報(bào)錯(cuò)退出)TNT.Node.check()
磨总,則可以判斷類型是否一致笙各,并輸出False 和 True
Node
可以替換成任意AST對(duì)象廉赔,例如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('這是一個(gè)ExpressionStatement') } this.traverse(path); } }); }); -------------------------------------------------------- recast.run(function(ast, printSource){ recast.visit(ast, { visitExpressionStatement: function(path) { const node = path.node; // 判斷是否為ExpressionStatement京腥,正確不輸出赦肃,錯(cuò)誤則全局報(bào)錯(cuò) TNT.ExpressionStatement.assert(node); this.traverse(path); } }); });
命令行輸入
node read demo.js
進(jìn)行測(cè)試。
用AST修改源碼公浪,導(dǎo)出demo.js
的全部方法
除了使用 fs.read
讀取文件他宛、正則匹配替換文本、fs.write
寫入文件這種笨拙的方式外欠气,我們可以用AST
優(yōu)雅地解決問題厅各。
-
首先,我們先用builders憑空實(shí)現(xiàn)一個(gè)鍵頭函數(shù):
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) { // 一個(gè)塊級(jí)域 {} console.log('\n\nstep1:'); printSource(blockStatement([])); // 一個(gè)鍵頭函數(shù) ()=>{} console.log('\n\nstep2:'); printSource(arrowFunctionExpression([],blockStatement([]))); // add賦值為鍵頭函數(shù) add = ()=>{} console.log('\n\nstep3:'); printSource(assignmentExpression('=',id('add'),arrowFunctionExpression([],blockStatement([])))); // exports.add賦值為鍵頭函數(shù) exports.add = ()=>{} console.log('\n\nstep4:'); printSource(expressionStatement(assignmentExpression('=', memberExpression(id('exports'), id('add')), arrowFunctionExpression([], blockStatement([]))))); });
我們一步一步推斷出
exports.add = ()=>{}
的過程预柒,從而得到具體的AST結(jié)構(gòu)體队塘。
執(zhí)行node exportific demo.js
命令查看:step1: {} step2: () => {} step3: add = () => {} step4: exports.add = () => {};
-
接下來,只需要在獲得的最終的表達(dá)式中卫旱,把
id('add')
替換成遍歷得到的函數(shù)名人灼,把參數(shù)替換成遍歷得到的函數(shù)參數(shù),把blockStatement([])
替換為遍歷得到的函數(shù)塊級(jí)作用域顾翼,就成功地改寫了所有函數(shù)!另外奈泪,需要注意的是适贸,在
demo.js
的commonDivision
函數(shù)內(nèi),引用了sub
函數(shù)涝桅,應(yīng)改寫成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) {
// 用來保存遍歷到的全部函數(shù)名
let funcIds = []
recast.types.visit(ast, {
// 遍歷所有的函數(shù)定義
visitFunctionDeclaration(path) {
//獲取遍歷到的函數(shù)名拜姿、參數(shù)、塊級(jí)域
const node = path.node
const funcName = node.id
const params = node.params
const body = node.body
// 保存函數(shù)名
funcIds.push(funcName.name)
// 這是上一步推導(dǎo)出來的ast結(jié)構(gòu)體
const rep = expressionStatement(assignmentExpression('=', memberExpression(id('exports'), funcName),
arrowFunctionExpression(params, body)))
// 將原來函數(shù)的ast結(jié)構(gòu)體冯遂,替換成推導(dǎo)ast結(jié)構(gòu)體
path.replace(rep)
// 停止遍歷
return false
}
})
recast.types.visit(ast, {
// 遍歷所有的函數(shù)調(diào)用
visitCallExpression(path){
const node = path.node;
// 如果函數(shù)調(diào)用出現(xiàn)在函數(shù)定義中蕊肥,則修改ast結(jié)構(gòu)
if (funcIds.includes(node.callee.name)) {
node.callee = memberExpression(id('exports'), node.callee)
}
// 停止遍歷
return false
}
})
// 打印修改后的ast源碼
printSource(ast)
})
```
一步到位,發(fā)一個(gè)最簡單的exportific前端工具
上面講了那么多,仍然只體現(xiàn)在理論階段壁却。
但通過簡單的改寫批狱,就能通過 recast
制作成一個(gè)名為 exportific
的源碼編輯工具。
以下代碼添加作了兩個(gè)小改動(dòng):
- 添加說明書
--help
展东,以及添加了--rewrite
模式赔硫,可以直接覆蓋文件或默認(rèn)為導(dǎo)出*.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')
// 截取參數(shù)
const options = process.argv.slice(2)
//如果沒有參數(shù)盐肃,或提供了-h 或--help選項(xiàng)爪膊,則打印幫助
if(options.length===0 || options.includes('-h') || options.includes('--help')){
console.log(`
采用commonjs規(guī)則,將.js文件內(nèi)所有函數(shù)修改為導(dǎo)出形式砸王。
選項(xiàng): -r 或 --rewrite 可直接覆蓋原有文件
`)
process.exit(0)
}
// 只要有-r 或--rewrite參數(shù)推盛,則rewriteMode為true
let rewriteMode = options.includes('-r') || options.includes('--rewrite')
// 獲取文件名
const clearFileArg = options.filter((item)=>{
return !['-r','--rewrite','-h','--help'].includes(item)
})
// 只處理一個(gè)文件
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) {
//獲取遍歷到的函數(shù)名谦铃、參數(shù)小槐、塊級(jí)域
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)
})
運(yùn)行一下: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
};