AST 與前端工程化實戰
- 2019 年 10 月 4 日
- 筆記
AST : 全稱為 Abstract Syntax Tree,意為抽象語法樹,它是源程式碼語法結構的一種抽象表示。
AST 是一個非常基礎但是同時非常重要的知識點,我們熟知的 TypeScript、babel、webpack、vue-cli 都是依賴 AST 進行開發的。本文將通過 AST 與前端工程化的實戰向大家展示 AST 的強大以及重要性。
直播分享影片地址:AST 與前端工程化實戰
一、初識 AST
1、demo-1
第一次看見 AST 這個概念的時候還是在《你不知道的 JavaScript》一書中看到的。我們先看個例子
const a = 1
傳統編譯語言中,源程式碼執行會先經歷三個階段
- 詞法分析階段:將字元組成的字元串分解成一個個程式碼塊(詞法單元),例子中程式碼會被解析成 const、a、=、1 四個詞法單元。
- 語法分析階段:將詞法單元流轉換成一個由元素逐級嵌套組成的語法結構樹,即所謂的抽象語法樹。例子中被解析出來的 const、a、=、1 這四個詞法單元組成的詞法單元流則會被轉換成如下結構樹

- 程式碼生成階段:將 AST 轉換成一系列可執行的機器指令程式碼,對應例子的話就是機器通過執行指令會在記憶體中創建一個變數 a,並將值 1 賦值給它。
2、demo-2
我們再來拆解一個 recast
官方的例子,相對來說也會複雜一些
function add (a, b) { return a + b }
- 首先,進入到詞法分析階段,我們會拿到
function、add、(、a、,、b、)、{、return、a、+、b、}
13 個程式碼塊 - 然後進入語法分析階段,具體如圖所示

上圖中的 FunctionDeclaration
、Identifier
、BlockStatement
等這些程式碼塊的類型的說明請點擊鏈接自行查看:AST 對象文檔
二、recast
由於文章中用到的 AST 相關的依賴包是 recast
,加上它本身是木有文檔的,只有一個非常簡短的 README.md
文件,所以這裡單獨開一篇對其常見的一些 API 做個介紹。開始之前,先給大家推薦一個在線查看 AST 結構的平台,非常好用
- AST Explorer
相信對 babel
稍有了解的同學都知道,babel
有一系列包對 AST 進行了封裝,專門來處理編譯這塊的事宜。而 recast
也是基於 @babel/core
、@babel/parser
、@babel/types
等包進行封裝開發的。
引入
引入 recast
有兩種方法,一種是 import
的形式,一種則是 CommonJs
的形式,分別如下
import
形式
import { parse, print } from 'recast' console.log(print(parse(source)).code) import * as recast from 'recast' console.log(recast.print(recast.parse(source)).code)
CommonJs
形式
const { parse, print } = require('recast') console.log(print(parse(source)).code) const recast = require('recast') console.log(recast.print(recast.parse(source)).code)
引入了 recast
之後,我們一起來看看 recast
都能做些什麼吧
1、recast.parse
我們回到我們例子,我們直接對它進行 parse ,看看 parse 後的 AST 結構是如何的
// 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: Identifier..., params: [Identifier...], body: BlockStatement... }
當然你想看更多內容直接去 AST Explorer 平台 將模式調成 recast
模式即可看到 ast 的全覽了,和我們上面分析的內容基本是一致的。

2、recast.print
目前為止,我們只是對其進行了拆解,如果將 ast 組裝成我們能執行的程式碼呢?OK,這就需要用到 recast.print
了,我們對上面拆解好的程式碼原封不動的組裝起來
// print.js const recast = require('recast') const code = `function add (a, b) { return a + b }` const ast = recast.parse(code) console.log(recast.print(ast).code)
然後執行 node print.js
,可以看到,我們列印出了
function add (a, b) { return a + b }
官方給的解釋就是,這就只是一個逆向處理而已,即
recast.print(recast.parse(source)).code === source
3、recast.prettyPrint
除了我們上面提及的 recast.print
外,recast
還提供一個程式碼美化的 API 叫 recast.prettyPrint
// prettyPrint.js const recast = require('recast') const code = `function add (a, b) { return a + b }` const ast = recast.parse(code) console.log(recast.prettyPrint(ast, { tabWidth: 2 }).code)
執行 node prettyPrint.js
,會發現 code 裡面的 N 多空格都能被格式化掉,輸出如下
function add(a, b) { return a + b; }
詳細的配置請自行查看:prettyPrint
4、recast.types.builders
i. API
關於 builder
的 API ,別擔心,我肯定是不會講的,因為太多了。

想要具體了解每一個 API 能做什麼的,可以直接在 Parser API – Builders 中進行查看,或者直接查看 recast builders 定義
ii. 實戰階段
OK,終於進入到 recast
操作相關的核心了。我們要想改造我們的程式碼,那麼 recast.types.builders
則是我們最重要的工具了。這裡我們繼續通過改造 recast
官方案例來了解 recast.types.builders
構建工具。
搞個最簡單的例子,現在我們要做一件事,那就是將 function add (a, b) {...}
改成 const add = function (a, b) {...}
。
我們從第一章節了解到,如果我們需要將其做成 const
聲明式的話,需要先一個 VariableDeclaration
以及一個 VariableDeclarator
,然後我們聲明一個 function
則有需要創建一個 FunctionDeclaration
,剩下的則是填充表達式的參數和內容體了。具體操作如下
// builder1.js const recast = require('recast') const { variableDeclaration, variableDeclarator, functionExpression } = recast.types.builders const code = `function add (a, b) { return a + b }` const ast = recast.parse(code) const add = ast.program.body[0] ast.program.body[0] = variableDeclaration('const', [ variableDeclarator(add.id, functionExpression( null, // 這裡弄成匿名函數即可 add.params, add.body )) ]) const output = recast.print(ast).code console.log(output)
執行 node builder1.js
,輸出如下
const add = function(a, b) { return a + b };
看到這,是不是覺得很有趣。真正好玩的才剛開始呢,接下來,基於此例子,我們做個小的延伸。將其直接改成 const add = (a, b) => {...}
的格式。
這裡出現了一個新的概念,那就是箭頭函數,當然,recast.type.builders
提供了 arrowFunctionExpression
來允許我們創建一個箭頭函數。所以我們第一步先來創建一個箭頭函數
const arrow = arrowFunctionExpression([], blockStatement([])
列印下 console.log(recast.print(arrow))
,輸出如下
() => {}
OK,我們已經獲取到一個空的箭頭函數了。接下來我們需要基於上面改造的基礎進一步進行改造,其實只要將 functionExpression
替換成 arrowFunctionExpression
即可。
ast.program.body[0] = variableDeclaration('const', [ variableDeclarator(add.id, b.arrowFunctionExpression( add.params, add.body )) ])
列印結果如下
const add = (a, b) => { return a + b };
OK,到這裡,我們已經知道 recast.types.builders
能為我們提供一系列 API,讓我們可以瘋狂輸出。
5、recast.run
讀取文件命令行。首先,我新建一個 read.js
,內容如下
// read.js recast.run((ast, printSource) => { printSource(ast) })
然後我再新建一個 demo.js
,內容如下
// demo.js function add (a, b) { return a + b }
然後執行 node read demo.js
,輸出如下
function add (a, b) { return a + b }
我們能看出來,我們直接在 read.js
中讀出了 demo.js
裡面的程式碼內容。那麼具體是如何實現的呢?
其實,原理非常簡單,無非就是直接通過 fs.readFile
進行文件讀取,然後將獲取到的 code
進行 parse
操作,至於我們看到的 printSource
則提供一個默認的列印函數 process.stdout.write(output)
,具體程式碼如下
import fs from "fs"; export function run(transformer: Transformer, options?: RunOptions) { return runFile(process.argv[2], transformer, options); } function runFile(path: any, transformer: Transformer, options?: RunOptions) { fs.readFile(path, "utf-8", function(err, code) { if (err) { console.error(err); return; } runString(code, transformer, options); }); } function defaultWriteback(output: string) { process.stdout.write(output); } function runString(code: string, transformer: Transformer, options?: RunOptions) { const writeback = options && options.writeback || defaultWriteback; transformer(parse(code, options), function(node: any) { writeback(print(node, options).code); }); }
6、recast.visit
這是一個 AST 節點遍歷的 API,如果你想要遍歷 AST 中的一些類型,那麼你就得靠 recast.visit
了,這裡可以遍歷的類型與 recast.types.builders
中的能構造出來的類型一致,builders
做的事是類型構建,recast.visit
做事的事則是遍歷 AST 中的類型。不過使用的時候需要注意以下幾點
- 每個 visit,必須加上
return false
,或者this.traverse(path)
,否則報錯。
if (this.needToCallTraverse !== false) { throw new Error( "Must either call this.traverse or return false in " + methodName ); }
- 在需要遍歷的類型前面加上 visit 即可遍歷,如需要遍歷 AST 中的箭頭函數,那麼直接這麼寫即可
recast.run((ast, printSource) => { recast.visit(ast, { visitArrowFunctionExpression (path) { printSource(path.node) return false } }) })
7、recast.types.namedTypes
一個用來判斷 AST 對象是否為指定類型的 API。
namedTypes 下有兩個 API,一個是 namedTypes.Node.assert
:當類型不配置的時候,直接報錯退出。另外一個則是 namedTypes.Node.check
:判斷類型是否一致,並輸出 true 或 false。
其中 Node 為任意 AST 對象,比如我想對箭頭函數做一個函數類型判定,程式碼如下
// namedTypes1.js const recast = require('recast') const t = recast.types.namedTypes const arrowNoop = () => {} const ast = recast.parse(arrowNoop) recast.visit(ast, { visitArrowFunctionExpression ({ node }) { console.log(t.ArrowFunctionExpression.check(node)) return false } })
執行 node namedTypes1.js
,能看出列印台輸出結果為 true。
同理,assert 用法也差不多。
const recast = require('recast') const t = recast.types.namedTypes const arrowNoop = () => {} const ast = recast.parse(arrowNoop) recast.visit(ast, { visitArrowFunctionExpression ({ node }) { t.ArrowFunctionExpression.assert(node) return false } })
你想判斷更多的 AST 對象類型的,直接做替換 Node 為其它 AST 對象類型即可。
三、前端工程化
現在,咱來聊聊前端工程化。
前段工程化可以分成四個塊來說,分別為
- 模組化:將一個文件拆分成多個相互依賴的文件,最後進行統一的打包和載入,這樣能夠很好的保證高效的多人協作。其中包含
- JS 模組化:CommonJS、AMD、CMD 以及 ES6 Module。
- CSS 模組化:Sass、Less、Stylus、BEM、CSS Modules 等。其中預處理器和 BEM 都會有的一個問題就是樣式覆蓋。而 CSS Modules 則是通過 JS 來管理依賴,最大化的結合了 JS 模組化和 CSS 生態,比如 Vue 中的 style scoped。
- 資源模組化:任何資源都能以模組的形式進行載入,目前大部分項目中的文件、CSS、圖片等都能直接通過 JS 做統一的依賴關係處理。
- 組件化:不同於模組化,模組化是對文件、對程式碼和資源拆分,而組件化則是對 UI 層面的拆分。
- 通常,我們會需要對頁面進行拆分,將其拆分成一個一個的零件,然後分別去實現這一個個零件,最後再進行組裝。
- 在我們的實際業務開發中,對於組件的拆分我們需要做不同程度的考量,其中主要包括細粒度和通用性這兩塊的考慮。
- 對於業務組件,你更多需要考量的是針對你負責業務線的一個適用度,即你設計的業務組件是否成為你當前業務的 「通用」 組件,比如我之前分析過的許可權校驗組件,它就是一個典型的業務組件。感興趣的小夥伴可以點擊 傳送門 自行閱讀。
- 規範化:正所謂無規矩不成方圓,一些好的規範則能很好的幫助我們對項目進行良好的開發管理。規範化指的是我們在工程開發初期以及開發期間制定的系列規範,其中又包含了
- 項目目錄結構
- 編碼規範:對於編碼這塊的約束,一般我們都會採用一些強制措施,比如 ESLint、StyleLint 等。
- 聯調規範:這塊可參考我以前知乎的回答,前後端分離,後台返回的數據前端沒法寫,怎麼辦?
- 文件命名規範
- 樣式管理規範:目前流行的樣式管理有 BEM、Sass、Less、Stylus、CSS Modules 等方式。
- git flow 工作流:其中包含分支命名規範、程式碼合併規範等。
- 定期 code review
- … 等等 以上這些,我之前也寫過一篇文章做過一些點的詳細說明,TypeScript + 大型項目實戰
- 自動化:從最早先的 grunt、gulp 等,再到目前的 webpack、parcel。這些自動化工具在自動化合併、構建、打包都能為我們節省很多工作。而這些前端自動化其中的一部分,前端自動化還包含了持續集成、自動化測試等方方面面。
而,處於其中任何一個塊都屬於前端工程化。
四、實戰:AST & webpack loader
而本文提及的實戰,則是通過 AST 改造書寫一個屬於我們自己的 webpack loader,為我們項目中的 promise 自動注入 catch 操作,避免讓我們手動書寫那些通用的 catch 操作。
1、AST 改造
講了這麼多,終於進入到我們的實戰環節了。那麼我們實戰要做一個啥玩意呢?
場景:日常的中台項目中,經常會有一些表單提交的需求,那麼提交的時候就需要做一些限制,防止有人手抖多點了幾次導致請求重複發出去。此類場景有很多解決方案,但是個人認為最佳的交互就是點擊之後為提交按鈕加上 loading 狀態,然後將其 disabled 掉,請求成功之後再解除掉 loading 和 disabled 的狀態。具體提交的操作如下
this.axiosFetch(this.formData).then(res => { this.loading = false this.handleClose() }).catch(() => { this.loading = false })
這樣看著好像還算 OK,但是如果類似這樣的操作一多,或多或少會讓你項目整體的程式碼看起來有些重複冗餘,那麼如何解決這種情況呢?
很簡單,咱直接使用 AST 編寫一個 webpack loader,讓其自動完成一些程式碼的注入,若我們項目中存在下面的程式碼的時候,會自動加上 catch 部分的處理,並將 then 語句第一段處理主動作為 catch 的處理邏輯
this.axiosFetch(this.formData).then(res => { this.loading = false this.handleClose() })
我們先看看,沒有 catch 的這段程式碼它的 AST 結構是怎樣的,如圖

其 MemberExpression 為
this.axiosFetch(this.formData).then
arguments 為
res => { this.loading = false this.handleClose() }
OK,我們再來看看有 catch 處理的程式碼它的 AST 結構又是如何的,如圖

其 MemberExpression 為
this.axiosFetch(this.formData).then(res => { this.loading = false this.handleClose() }).catch
其中有兩個 ArrowFunctionExpression,分別為
// ArrowFunctionExpression 1 res => { this.loading = false this.handleClose() } // ArrowFunctionExpression 2 () => { this.loading = false }
所以,我們需要做的事情大致分為以下幾步
- 對 ArrowFunctionExpression 類型進行遍歷,獲得其 BlockStatement 中的第一個 ExpressionStatement,並保存為 firstExp
- 使用 builders 新建一個空的箭頭函數,並將保存好的 firstExp 賦值到該空箭頭函數的 BlockStatement 中
- 對 CallExpression 類型進行遍歷,將 AST 的 MemberExpression 修改成為有 catch 片段的格式
- 將改造完成的 AST 返回
現在,按照我們的思路,我們一步一步來做 AST 改造
首先,我們需要獲取到已有箭頭函數中的第一個 ExpressionStatement,獲取的時候我們需要保證當前 ArrowFunctionExpression 類型的 parent 節點是一個 CallExpression 類型,並且保證其 property 為 promise 的then 函數,具體操作如下
// promise-catch.js const recast = require('recast') const { identifier: id, memberExpression, callExpression, blockStatement, arrowFunctionExpression } = recast.types.builders const t = recast.types.namedTypes const code = `this.axiosFetch(this.formData).then(res => { this.loading = false this.handleClose() })` const ast = recast.parse(code) let firstExp recast.visit(ast, { visitArrowFunctionExpression ({ node, parentPath }) { const parentNode = parentPath.node if ( t.CallExpression.check(parentNode) && t.Identifier.check(parentNode.callee.property) && parentNode.callee.property.name === 'then' ) { firstExp = node.body.body[0] } return false } })
緊接著,我們需要創建一個空的箭頭函數,並將 firstExp 賦值給它
const arrowFunc = arrowFunctionExpression([], blockStatement([firstExp]))
隨後,我們則需要對 CallExpression 類型的 AST 對象進行遍歷,並做最後的 MemberExpression 改造工作
recast.visit(ast, { visitCallExpression (path) { const { node } = path const arrowFunc = arrowFunctionExpression([], blockStatement([firstExp])) const originFunc = callExpression(node.callee, node.arguments) const catchFunc = callExpression(id('catch'), [arrowFunc]) const newFunc = memberExpression(originFunc, catchFunc) return false } })
最後我們在 CallExpression 遍歷的時候將其替換
path.replace(newFunc)
初版的全部程式碼如下
// promise-catch.js const recast = require('recast') const { identifier: id, memberExpression, callExpression, blockStatement, arrowFunctionExpression } = recast.types.builders const t = recast.types.namedTypes const code = `this.axiosFetch(this.formData).then(res => { this.loading = false this.handleClose() })` const ast = recast.parse(code) let firstExp recast.visit(ast, { visitArrowFunctionExpression ({ node, parentPath }) { const parentNode = parentPath.node if ( t.CallExpression.check(parentNode) && t.Identifier.check(parentNode.callee.property) && parentNode.callee.property.name === 'then' ) { firstExp = node.body.body[0] } return false } }) recast.visit(ast, { visitCallExpression (path) { const { node } = path const arrowFunc = arrowFunctionExpression([], blockStatement([firstExp])) const originFunc = callExpression(node.callee, node.arguments) const catchFunc = callExpression(id('catch'), [arrowFunc]) const newFunc = memberExpression(originFunc, catchFunc) path.replace(newFunc) return false } }) const output = recast.print(ast).code console.log(output)
執行 node promise-catch.js
,列印台輸出結果
this.axiosFetch(this.formData).then(res => { this.loading = false this.handleClose() }).catch(() => { this.loading = false })
所以能看出來,我們已經是完成了我們想要完成的樣子了

- 但是我們還得對一些情況做處理,第一件就是需要在 CallExpression 遍歷的時候保證其 arguments 為箭頭函數。
- 緊接著,我們需要判定我們獲取到的 firstExp 是否存在,因為我們的 then 處理中可以是一個空的箭頭函數。
- 然後防止 promise 擁有一些自定義的 catch 操作,則需要保證其 property 為 then。
- 最後為了防止多個 CallExpression 都需要做自動注入的情況,然後其操作又不同,則需要在其內部進行 ArrowFunctionExpression 遍歷操作
經過這些常見情況的兼容後,具體程式碼如下
recast.visit(ast, { visitCallExpression (path) { const { node } = path const arguments = node.arguments let firstExp arguments.forEach(item => { if (t.ArrowFunctionExpression.check(item)) { firstExp = item.body.body[0] if ( t.ExpressionStatement.check(firstExp) && t.Identifier.check(node.callee.property) && node.callee.property.name === 'then' ) { const arrowFunc = arrowFunctionExpression([], blockStatement([firstExp])) const originFunc = callExpression(node.callee, node.arguments) const catchFunc = callExpression(id('catch'), [arrowFunc]) const newFunc = memberExpression(originFunc, catchFunc) path.replace(newFunc) } } }) return false } })
然後由於之後需要做成一個 webpack-loader,用在我們的實際項目中。所以我們需要對 parse 的解析器做個替換,其默認的解析器為 recast/parsers/esprima
,而一般我們項目中都會用到 babel-loader
,所以我們這也需要將其解析器改為 recast/parsers/babel
const ast = recast.parse(code, { parser: require('recast/parsers/babel') })
2、webpack loader
到這裡,我們對於程式碼的 AST 改造已經是完成了,但是如何將其運用到我們的實際項目中呢?
OK,這個時候我們就需要自己寫一個 webpack loader 了。
其實,關於如何開發一個 webpack loader,webpack 官方文檔 已經講的很清楚了,下面我為小夥伴們做個小總結。
i. 本地進行 loader 開發
首先,你需要本地新建你開發 loader 的文件,比如,我們這將其丟到 src/index.js
下,webpack.config.js
配置則如下
const path = require('path') module.exports = { // ... module: { rules: [ { test: /.js$/, use: [ // ... 其他你需要的 loader { loader: path.resolve(__dirname, 'src/index.js') } ] } ] } }
src/index.js
內容如下
const recast = require('recast') const { identifier: id, memberExpression, callExpression, blockStatement, arrowFunctionExpression } = recast.types.builders const t = recast.types.namedTypes module.exports = function (source) { const ast = recast.parse(source, { parser: require('recast/parsers/babel') }) recast.visit(ast, { visitCallExpression (path) { const { node } = path const arguments = node.arguments let firstExp arguments.forEach(item => { if (t.ArrowFunctionExpression.check(item)) { firstExp = item.body.body[0] if ( t.ExpressionStatement.check(firstExp) && t.Identifier.check(node.callee.property) && node.callee.property.name === 'then' ) { const arrowFunc = arrowFunctionExpression([], blockStatement([firstExp])) const originFunc = callExpression(node.callee, node.arguments) const catchFunc = callExpression(id('catch'), [arrowFunc]) const newFunc = memberExpression(originFunc, catchFunc) path.replace(newFunc) } } }) return false } }) return recast.print(ast).code }
然後,搞定收工。
ii. npm 發包
這裡我在以前的文章中提及過,這裡不談了。如果還沒搞過 npm 發包的小夥伴,可以點擊下面鏈接自行查看
揭秘組件庫一二事(發布 npm 包片段)
OK,到這一步,我的 promise-catch-loader
也是已經開發完畢。接下來,只要在項目中使用即可
npm i promise-catch-loader -D
由於我的項目是基於 vue-cli3.x 構建的,所以我需要在我的 vue.config.js
中這樣配置
// js 版本 module.exports = { // ... chainWebpack: config => { config.module .rule('js') .test(/.js$/) .use('babel-loader').loader('babel-loader').end() .use('promise-catch-loader').loader('promise-catch-loader').end() } } // ts 版本 module.exports = { // ... chainWebpack: config => { config.module .rule('ts') .test(/.ts$/) .use('cache-loader').loader('cache-loader').end() .use('babel-loader').loader('babel-loader').end() .use('ts-loader').loader('ts-loader').end() .use('promise-catch-loader').loader('promise-catch-loader').end() } }
然後我項目裡面擁有以下 promise 操作
<script lang="ts"> import { Component, Vue } from 'vue-property-decorator' import { Action } from 'vuex-class' @Component export default class HelloWorld extends Vue { loading: boolean = false city: string = '上海' @Action('getTodayWeather') getTodayWeather: Function getCityWeather (city: string) { this.loading = true this.getTodayWeather({ city: city }).then((res: Ajax.AjaxResponse) => { this.loading = false const { low, high, type } = res.data.forecast[0] this.$message.success(`${city}今日:${type} ${low} - ${high}`) }) } } </script>
然後在瀏覽器中查看 source 能看到如下結果

關於程式碼,我已經託管到 GitHub 上了,promise-catch-loader
總結
到這步,我們的實戰環節也已經是結束了。當然,文章只是個初導篇,更多的類型還得小夥伴自己去探究。
AST 它的用處還非常的多,比如我們熟知的 Vue,它的 SFC(.vue) 文件的解析也是基於 AST 去進行自動解析的,即 vue-loader,它保證我們能正常的使用 Vue 進行業務開發。再比如我們常用的 webpack 構建工具,也是基於 AST 為我們提供了合併、打包、構建優化等非常實用的功能的。
總之,掌握好 AST,你真的可以做很多事情。
最後,希望文章的內容能夠幫助小夥伴了解到:什麼是 AST?如何藉助 AST 讓我們的工作更加效率?AST 又能為前端工程化做些什麼?
文章轉載自公眾號 合格前端 , 作者 qiangdada