摘要:抽象語法樹,是一個非常基礎(chǔ)而重要的知識點,但國內(nèi)的文檔卻幾乎一片空白。事實上,在世界中,你可以認為抽象語法樹是最底層。通過抽象語法樹解析,我們可以像童年時拆解玩具一樣,透視這臺機器的運轉(zhuǎn),并且重新按著你的意愿來組裝。
抽象語法樹(AST),是一個非常基礎(chǔ)而重要的知識點,但國內(nèi)的文檔卻幾乎一片空白。本文將帶大家從底層了解AST,并且通過發(fā)布一個小型前端工具,來帶大家了解AST的強大功能
Javascript就像一臺精妙運作的機器,我們可以用它來完成一切天馬行空的構(gòu)思。
我們對javascript生態(tài)了如指掌,卻常忽視javascript本身。這臺機器,究竟是哪些零部件在支持著它運行?
AST在日常業(yè)務(wù)中也許很難涉及到,但當(dāng)你不止于想做一個工程師,而想做工程師的工程師,寫出vue、react之類的大型框架,或類似webpack、vue-cli前端自動化的工具,或者有批量修改源碼的工程需求,那你必須懂得AST。AST的能力十分強大,且能幫你真正吃透javascript的語言精髓。
事實上,在javascript世界中,你可以認為抽象語法樹(AST)是最底層。 再往下,就是關(guān)于轉(zhuǎn)換和編譯的“黑魔法”領(lǐng)域了。
人生第一次拆解Javascript小時候,當(dāng)我們拿到一個螺絲刀和一臺機器,人生中最令人懷念的夢幻時刻便開始了:
我們把機器,拆成一個一個小零件,一個個齒輪與螺釘,用巧妙的機械原理銜接在一起...
當(dāng)我們把它重新照不同的方式組裝起來,這時,機器重新又跑動了起來——世界在你眼中如獲新生。
通過抽象語法樹解析,我們可以像童年時拆解玩具一樣,透視Javascript這臺機器的運轉(zhuǎn),并且重新按著你的意愿來組裝。
現(xiàn)在,我們拆解一個簡單的add函數(shù)
function add(a, b) { return a + b }
首先,我們拿到的這個語法塊,是一個FunctionDeclaration(函數(shù)定義)對象。
用力拆開,它成了三塊:
一個id,就是它的名字,即add
兩個params,就是它的參數(shù),即[a, b]
一塊body,也就是大括號內(nèi)的一堆東西
add沒辦法繼續(xù)拆下去了,它是一個最基礎(chǔ)Identifier(標志)對象,用來作為函數(shù)的唯一標志,就像人的姓名一樣。
{ name: "add" type: "identifier" ... }
params繼續(xù)拆下去,其實是兩個Identifier組成的數(shù)組。之后也沒辦法拆下去了。
[ { name: "a" type: "identifier" ... }, { name: "b" type: "identifier" ... } ]
接下來,我們繼續(xù)拆開body
我們發(fā)現(xiàn),body其實是一個BlockStatement(塊狀域)對象,用來表示是{return a + b}
打開Blockstatement,里面藏著一個ReturnStatement(Return域)對象,用來表示return a + b
繼續(xù)打開ReturnStatement,里面是一個BinaryExpression(二項式)對象,用來表示a + b
繼續(xù)打開BinaryExpression,它成了三部分,left,operator,right
operator 即+
left 里面裝的,是Identifier對象 a
right 里面裝的,是Identifer對象 b
就這樣,我們把一個簡單的add函數(shù)拆解完畢,用圖表示就是
看!抽象語法樹(Abstract Syntax Tree),的確是一種標準的樹結(jié)構(gòu)。
那么,上面我們提到的Identifier、Blockstatement、ReturnStatement、BinaryExpression, 這一個個小部件的說明書去哪查?
請查看 AST對象文檔
送給你的AST螺絲刀:recast輸入命令:
npm i recast -S
你即可獲得一把操縱語法樹的螺絲刀
接下來,你可以在任意js文件下操縱這把螺絲刀,我們新建一個parse.js示意:
parse.js
// 給你一把"螺絲刀"——recast const recast = require("recast"); // 你的"機器"——一段代碼 // 我們使用了很奇怪格式的代碼,想測試是否能維持代碼結(jié)構(gòu) const code = ` function add(a, b) { return a + // 有什么奇怪的東西混進來了 b } ` // 用螺絲刀解析機器 const ast = recast.parse(code); // ast可以處理很巨大的代碼文件 // 但我們現(xiàn)在只需要代碼塊的第一個body,即add函數(shù) const add = ast.program.body[0] console.log(add)
輸入node parse.js你可以查看到add函數(shù)的結(jié)構(gòu),與之前所述一致,通過AST對象文檔可查到它的具體屬性:
FunctionDeclaration{ type: "FunctionDeclaration", id: ... params: ... body: ... }
你也可以繼續(xù)使用console.log透視它的更內(nèi)層,如:
console.log(add.params[0])
console.log(add.body.body[0].argument.left)recast.types.builders 制作模具
一個機器,你只會拆開重裝,不算本事。
拆開了,還能改裝,才算上得了臺面。
recast.types.builders里面提供了不少“模具”,讓你可以輕松地拼接成新的機器。
最簡單的例子,我們想把之前的function add(a, b){...}聲明,改成匿名函數(shù)式聲明const add = function(a ,b){...}
如何改裝?
第一步,我們創(chuàng)建一個VariableDeclaration變量聲明對象,聲明頭為const, 內(nèi)容為一個即將創(chuàng)建的VariableDeclarator對象。
第二步,創(chuàng)建一個VariableDeclarator,放置add.id在左邊, 右邊是將創(chuàng)建的FunctionDeclaration對象
第三步,我們創(chuàng)建一個FunctionDeclaration,如前所述的三個組件,id params body中,因為是匿名函數(shù)id設(shè)為空,params使用add.params,body使用add.body。
這樣,就創(chuàng)建好了const add = function(){}的AST對象。
在之前的parse.js代碼之后,加入以下代碼
// 引入變量聲明,變量符號,函數(shù)聲明三種“模具” 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對象重新轉(zhuǎn)回可以閱讀的代碼 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
打印出來還保留著“原裝”的函數(shù)內(nèi)容,連注釋都沒有變。
我們其實也可以打印出美化格式的代碼段:
const output = recast.prettyPrint(ast, { tabWidth: 2 }).code
輸出為
const add = function(a, b) { return a + b; };
現(xiàn)在,你是不是已經(jīng)產(chǎn)生了“我可以通過AST樹生成任何js代碼”的幻覺?實戰(zhàn)進階:命令行修改js文件我鄭重告訴你,這不是幻覺。
除了parse/print/builder以外,Recast的三項主要功能:
run: 通過命令行讀取js文件,并轉(zhuǎn)化成ast以供處理。
tnt: 通過assert()和check(),可以驗證ast對象的類型。
visit: 遍歷ast樹,獲取有效的AST對象并進行更改。
我們通過一個系列小務(wù)來學(xué)習(xí)全部的recast工具庫:
創(chuàng)建一個用來示例文件,假設(shè)是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.run —— 命令行文件讀取
新建一個名為read.js的文件,寫入
read.js
recast.run( function(ast, printSource){ printSource(ast) })
命令行輸入
node read demo.js
我們查以看到j(luò)s文件內(nèi)容打印在了控制臺上。
我們可以知道,node read可以讀取demo.js文件,并將demo.js內(nèi)容轉(zhuǎn)化為ast對象。
同時它還提供了一個printSource函數(shù),隨時可以將ast的內(nèi)容轉(zhuǎn)換回源碼,以方便調(diào)試。
recast.visit —— AST節(jié)點遍歷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對象內(nèi)的節(jié)點進行逐個遍歷。
注意
你想操作函數(shù)聲明,就使用visitFunctionDelaration遍歷,想操作賦值表達式,就使用visitExpressionStatement。 只要在 AST對象文檔中定義的對象,在前面加visit,即可遍歷。
通過node可以取到AST對象
每個遍歷函數(shù)后必須加上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) } }) });
調(diào)試時,如果你想輸出AST對象,可以console.log(node)
如果你想輸出AST對象對應(yīng)的源碼,可以printSource(node)
命令行輸入`
node read demo.js`進行測試。
#!/usr/bin/env node 在所有使用recast.run()的文件頂部都需要加入這一行,它的意義我們最后再討論。TNT —— 判斷AST對象類型
TNT,即recast.types.namedTypes,就像它的名字一樣火爆,它用來判斷AST對象是否為指定的類型。
TNT.Node.assert(),就像在機器里埋好的炸藥,當(dāng)機器不能完好運轉(zhuǎn)時(類型不匹配),就炸毀機器(報錯退出)
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`進行測試。
exportific.js
現(xiàn)在,我們想讓這個文件中的函數(shù)改寫成能夠全部導(dǎo)出的形式,例如
function add (a, b) { return a + b }
想改變?yōu)?/p>
exports.add = (a, b) => { return a + b }
除了使用fs.read讀取文件、正則匹配替換文本、fs.write寫入文件這種笨拙的方式外,我們可以用AST優(yōu)雅地解決問題。
查詢AST對象文檔
首先,我們先用builders憑空實現(xiàn)一個鍵頭函數(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) { // 一個塊級域 {} console.log(" step1:") printSource(blockStatement([])) // 一個鍵頭函數(shù) ()=>{} console.log(" step2:") printSource(arrowFunctionExpression([],blockStatement([]))) // add賦值為鍵頭函數(shù) add = ()=>{} console.log(" step3:") printSource(assignmentExpression("=",id("add"),arrowFunctionExpression([],blockStatement([])))) // exports.add賦值為鍵頭函數(shù) exports.add = ()=>{} console.log(" step4:") printSource(expressionStatement(assignmentExpression("=",memberExpression(id("exports"),id("add")), arrowFunctionExpression([],blockStatement([]))))) });
上面寫了我們一步一步推斷出exports.add = ()=>{}的過程,從而得到具體的AST結(jié)構(gòu)體。
使用node exportific demo.js運行可查看結(jié)果。
接下來,只需要在獲得的最終的表達式中,把id("add")替換成遍歷得到的函數(shù)名,把參數(shù)替換成遍歷得到的函數(shù)參數(shù),把blockStatement([])替換為遍歷得到的函數(shù)塊級作用域,就成功地改寫了所有函數(shù)!
另外,我們需要注意,在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ù)、塊級域 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ā)一個最簡單的exportific前端工具
上面講了那么多,仍然只體現(xiàn)在理論階段。
但通過簡單的改寫,就能通過recast制作成一個名為exportific的源碼編輯工具。
以下代碼添加作了兩個小改動
添加說明書--help,以及添加了--rewrite模式,可以直接覆蓋文件或默認為導(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選項,則打印幫助 if(options.length===0 || options.includes("-h") || options.includes("--help")){ console.log(` 采用commonjs規(guī)則,將.js文件內(nèi)所有函數(shù)修改為導(dǎo)出形式。 選項: -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) }) // 只處理一個文件 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ù)、塊級域 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) })
現(xiàn)在嘗試一下
node exportific demo.js
已經(jīng)可以在當(dāng)前目錄下找到源碼變更后的demo.export.js文件了。
npm發(fā)包編輯一下package.json文件
{ "name": "exportific", "version": "0.0.1", "description": "改寫源碼中的函數(shù)為可exports.XXX形式", "main": "exportific.js", "bin": { "exportific": "./exportific.js" }, "keywords": [], "author": "wanthering", "license": "ISC", "dependencies": { "recast": "^0.15.3" } }
注意bin選項,它的意思是將全局命令exportific指向當(dāng)前目錄下的exportific.js
這時,輸入npm link 就在本地生成了一個exportific命令。
之后,只要哪個js文件想導(dǎo)出來使用,就exportific XXX.js一下。
這是在本地的玩法,想和大家一起分享這個前端小工具,只需要發(fā)布npm包就行了。
同時,一定要注意exportific.js文件頭有
#!/usr/bin/env node
否則在使用時將報錯。
接下來,正式發(fā)布npm包!如果你已經(jīng)有了npm 帳號,請使用npm login登錄
如果你還沒有npm帳號 https://www.npmjs.com/signup 非常簡單就可以注冊npm
然后,輸入
npm publish
沒有任何繁瑣步驟,絲毫審核都沒有,你就發(fā)布了一個實用的前端小工具exportific 。任何人都可以通過
npm i exportific -g
全局安裝這一個插件。
提示:==在試驗教程時,請不要和我的包重名,修改一下發(fā)包名稱。==
結(jié)語我們對javascript再熟悉不過,但透過AST的視角,最普通的js語句,卻煥發(fā)出精心動魄的美感。你可以通過它批量構(gòu)建任何javascript代碼!
童年時,這個世界充滿了新奇的玩具,再普通的東西在你眼中都如同至寶。如今,計算機語言就是你手中的大玩具,一段段AST對象的拆分組裝,構(gòu)建出我們所生活的網(wǎng)絡(luò)世界。
所以不得不說軟件工程師是一個幸福的工作,你心中住的仍然是那個午后的少年,永遠有無數(shù)新奇等你發(fā)現(xiàn),永遠有無數(shù)夢想等你構(gòu)建。
github地址:https://github.com/wanthering...
文章版權(quán)歸作者所有,未經(jīng)允許請勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請注明本文地址:http://m.specialneedsforspecialkids.com/yun/97249.html
摘要:而在編譯過程中通過語法和詞法的分析得出一顆語法樹,我們可以將它稱為抽象語法樹也稱為語法樹,指的是源代碼語法所對應(yīng)的樹狀結(jié)構(gòu)。而這個卻恰恰使我們分析打包工具的重點核心。 概述 眼下wepack似乎已經(jīng)成了前端開發(fā)中不可缺少的工具之一,而他的一切皆模塊的思想隨著webpack版本不斷的迭代(webpack 4)使其打包速度更快,效率更高的為我們的前端工程化服務(wù)showImg(https:/...
摘要:我們更多要去做的是去修改和改變生成的這個抽象語法樹。我們已經(jīng)知道會遍歷節(jié)點組成的抽象語法樹,每一個節(jié)點都會有自己對應(yīng)的比如變量節(jié)點等。 你有可能會聽到過這個詞 webpack工程師 ,這個看似像是一個專業(yè)很強的職位其實很多時候是一些前端對現(xiàn)在前端工作方式對一些吐槽,對于一個之前沒有接觸過webpack,nodejs,babel 之類的工具的人來說,看到大量的配置文件后很多人都會看懵 s...
摘要:本文總結(jié)了前端老司機經(jīng)常問題的一些問題并結(jié)合個人總結(jié)給出了比較詳盡的答案。網(wǎng)易阿里騰訊校招社招必備知識點。此外還有網(wǎng)絡(luò)線程,定時器任務(wù)線程,文件系統(tǒng)處理線程等等。線程核心是引擎。主線程和工作線程之間的通知機制叫做事件循環(huán)。 showImg(https://segmentfault.com/img/bVbu4aB?w=300&h=208); 本文總結(jié)了前端老司機經(jīng)常問題的一些問題并結(jié)合個...
摘要:本文總結(jié)了前端老司機經(jīng)常問題的一些問題并結(jié)合個人總結(jié)給出了比較詳盡的答案。網(wǎng)易阿里騰訊校招社招必備知識點。此外還有網(wǎng)絡(luò)線程,定時器任務(wù)線程,文件系統(tǒng)處理線程等等。線程核心是引擎。主線程和工作線程之間的通知機制叫做事件循環(huán)。 showImg(https://segmentfault.com/img/bVbu4aB?w=300&h=208); 本文總結(jié)了前端老司機經(jīng)常問題的一些問題并結(jié)合個...
摘要:在這里,詞法解析器應(yīng)用的規(guī)則即為詞匯語法的定義,語法解釋器應(yīng)用的規(guī)則即為表達式語句聲明和函數(shù)等的定義。如何編寫簡單的實踐篇 什么是parser? 簡單的說,parser的工作即是將代碼片段轉(zhuǎn)換成計算機可讀的數(shù)據(jù)結(jié)構(gòu)的過程。這個計算機可讀的數(shù)據(jù)結(jié)構(gòu)更專業(yè)的說法是抽象語法樹(abstract syntax tree),簡稱AST。AST是代碼片段具體語義的抽象表達,它不包含該段代碼的所有細...
閱讀 3299·2021-09-02 15:41
閱讀 2837·2021-09-02 09:48
閱讀 1377·2019-08-29 13:27
閱讀 1165·2019-08-26 13:37
閱讀 841·2019-08-26 11:56
閱讀 2486·2019-08-26 10:24
閱讀 1649·2019-08-23 18:07
閱讀 2624·2019-08-23 15:16