前端也要懂編譯:AST 從入門到上手指南
大廠技術(shù) 堅持周更 精選好文
閱讀文章之前,不妨打開手頭項目中的 package.json ,我們會發(fā)現(xiàn)眾多工具已經(jīng)占據(jù)了我們開發(fā)日常的各個角落,例如 JavaScript 轉(zhuǎn)譯、CSS 預(yù)處理、代碼壓縮、ESLint、Prettier等等。這些工具模塊大都不會交付到生產(chǎn)環(huán)境中,但它們的存在于我們的開發(fā)而言是不可或缺的。
有沒有想過這些工具的功能是如何實現(xiàn)的呢?沒錯,抽象語法樹 (Abstract Syntax Tree) 就是上述工具的基石。
AST 是什么 & 如何生成
AST 是一種源代碼的抽象語法結(jié)構(gòu)的樹形表示。樹中的每個節(jié)點都表示源代碼中出現(xiàn)的一個構(gòu)造。
那么 AST 是如何生成的?為什么需要 AST ?
了解過編譯原理的同學(xué)知道計算機想要理解一串源代碼需要經(jīng)過“漫長”的分析過程:
詞法分析 (Lexical Analysis) 語法分析 (Syntax Analysis) ... 代碼生成 (Code Generation)

詞法分析 其中詞法分析階段掃描輸入的源代碼字符串,生成一系列的詞法單元 (tokens),這些詞法單元包括數(shù)字,標(biāo)點符號,運算符等。詞法單元之間都是獨立的,也即在該階段我們并不關(guān)心每一行代碼是通過什么方式組合在一起的。

詞法分析階段——仿佛最初學(xué)英語時,將一個句子拆分成很多獨立的單詞,我們首先記住每一個單詞的類型和含義,但并不關(guān)心單詞之間的具體聯(lián)系。
語法分析 接著,語法分析階段就會將上一階段生成的 token 列表轉(zhuǎn)換為如下圖右側(cè)所示的 AST,根據(jù)這個數(shù)據(jù)結(jié)構(gòu)大致可以看出轉(zhuǎn)換之前源代碼的基本構(gòu)造。

語法分析階段——老師教會我們每個單詞在整個句子上下文中的具體角色和含義。
代碼生成 最后是代碼生成階段,該階段是一個非常自由的環(huán)節(jié),可由多個步驟共同組成。在這個階段我們可以遍歷初始的 AST,對其結(jié)構(gòu)進行改造,再將改造后的結(jié)構(gòu)生成對應(yīng)的代碼字符串。

代碼生成階段——我們已經(jīng)弄清楚每一條句子的語法結(jié)構(gòu)并知道如何寫出語法正確的英文句子,通過這個基本結(jié)構(gòu)我們可以把英文句子完美地轉(zhuǎn)換成一個中文句子或是文言文(如果你會的話)。
AST 的基本結(jié)構(gòu)
拋開具體的編譯器和編程語言,在 “AST 的世界”里所有的一切都是 節(jié)點(Node),不同類型的節(jié)點之間相互嵌套形成一顆完整的樹形結(jié)構(gòu)。

{
"program": {
"type": "Program",
"sourceType": "module",
"body": [
{
"type": "FunctionDeclaration",
"id": {
"type": "Identifier",
"name": "foo"
},
"params": [
{
"type": "Identifier",
"name": "x"
}
],
"body": {
"type": "BlockStatement",
"body": [
{
"type": "IfStatement",
"test": {
"type": "BinaryExpression",
"left": {
"type": "Identifier",
"name": "x"
},
"operator": ">",
"right": {
"type": "NumericLiteral",
"value": 10
}
}
}
]
}
...
}
...
]
}
AST 的結(jié)構(gòu)在不同的語言編譯器、不同的編譯工具甚至語言的不同版本下是各異的,這里簡單介紹一下目前 JavaScript 編譯器遵循的通用規(guī)范 —— ESTree 中對于 AST 結(jié)構(gòu)的一些基本定義,不同的編譯工具都是基于此結(jié)構(gòu)進行了相應(yīng)的拓展。

AST 的用法 & 實戰(zhàn)??
應(yīng)用場景和用法
了解 AST 的概念和具體結(jié)構(gòu)后,你可能不禁會問:AST 有哪些使用場景,怎么用?
開篇有提到,其實我們項目中的依賴和 VSCode 插件已經(jīng)揭曉了答案,AST 的應(yīng)用場景非常廣泛,以前端開發(fā)為例:
代碼高亮、格式化、錯誤提示、自動補全等:ESlint、Prettier、Vetur等。
代碼壓縮混淆:uglifyJS等。
代碼轉(zhuǎn)譯:webpack、babel、TypeScript等。
至于如何使用 AST ,歸納起來可以把它的使用操作分為以下幾個步驟:

解析 (Parsing):這個過程由編譯器實現(xiàn),會經(jīng)過詞法分析過程和語法分析過程,從而生成 AST。
讀取/遍歷 (Traverse):深度優(yōu)先遍歷 AST,訪問樹上各個節(jié)點的信息(Node)。
修改/轉(zhuǎn)換 (Transform):在遍歷的過程中可對節(jié)點信息進行修改,生成新的 AST。
輸出 (Printing):對初始 AST進行轉(zhuǎn)換后,根據(jù)不同的場景,既可以直接輸出新的AST,也可以轉(zhuǎn)譯成新的代碼塊。
通常情況下使用 AST,我們重點關(guān)注步驟2和3,諸如 Babel、ESLint 等工具暴露出來的通用能力都是對初始 AST 進行訪問和修改。
這兩步的實現(xiàn)基于一種名為訪問者模式的設(shè)計模式,即定義一個 visitor 對象,在該對象上定義了對各種類型節(jié)點的訪問方法,這樣就可以針對不同的節(jié)點做出不同的處理。例如,編寫 Babel 插件其實就是在構(gòu)造一個 visitor 實例來處理各個節(jié)點信息,從而生成想要的結(jié)果。
const visitor = {
CallExpression(path) {
...
}
FunctionDeclaration(path) {
...
}
ImportDeclaration(path) {
...
}
...
}
traverse(AST, visitor)
實戰(zhàn)
《說了一堆,一行代碼沒看見》,最后一部分我們來看如何使用 Bable 在 AST 上做一些“手腳”。
開發(fā)工具
AST Explorer:在線 AST 轉(zhuǎn)換工具,集成了多種語言和解析器
@babel/parser :將 JS 代碼解析成對應(yīng)的 AST
@babel/traverse:對 AST 節(jié)點進行遞歸遍歷
@babel/types:集成了一些快速生成、修改、刪除 AST Node的方法
@babel/generator :根據(jù)修改過后的 AST 生成新的 js 代碼
??
目標(biāo):將所有函數(shù)中的普通 log 打印轉(zhuǎn)換成 error 打印,并在打印內(nèi)容前方附加函數(shù)名的字符串
// Before
function add(a, b) {
console.log(a + b)
return a + b
}
// => After
function add(a, b) {
console.error('add', a + b)
return a + b
}
思路:
遍歷所有的函數(shù)調(diào)用表達式(CallExpression)節(jié)點
將函數(shù)調(diào)用方法的屬性由 log 改為 error
找到函數(shù)聲明(FunctionDeclaration)父節(jié)點,提取函數(shù)名信息
將函數(shù)名信息包裝成字符串字面量(StringLiteral)節(jié)點,插入函數(shù)調(diào)用表達式的參數(shù)節(jié)點數(shù)組中
const compile = (code) => {
// 1. tokenizer + parser
const ast = parser.parse(code)
// 2. traverse + transform
const visitor = {
// 訪問函數(shù)調(diào)用表達式
CallExpression(path) {
const { callee } = path.node
if (types.isCallExpression(path.node) && types.isMemberExpression(callee)) {
const { object, property } = callee
// 將成員表達式的屬性由 log -> error
if (object.name === 'console' && property.name === 'log') {
property.name = 'error'
} else {
return
}
// 向上遍歷,在該函數(shù)調(diào)用節(jié)點的父節(jié)點中找到函數(shù)聲明節(jié)點
const FunctionDeclarationNode = path.findParent(parent => {
return parent.type === 'FunctionDeclaration'
})
// 提取函數(shù)名稱信息,包裝成一個字符串字面量節(jié)點,插入當(dāng)前節(jié)點的參數(shù)數(shù)組中
const funcNameNode = types.stringLiteral(FunctionDeclarationNode.node.id.name)
path.node.arguments.unshift(funcNameNode)
}
}
}
traverse.default(ast, visitor)
// 3. code generator
const newCode = generator.default(ast, {}, code).code
}
????
目標(biāo):為所有的函數(shù)添加錯誤捕獲,并在捕獲階段實現(xiàn)自定義的處理操作
// Before
function add(a, b) {
console.log('23333')
throw new Error('233 Error')
return a + b;
}
// => After
function add(a, b) {
// 這里只能捕獲到同步代碼的執(zhí)行錯誤
try {
console.log('23333')
throw new Error('233 Error')
return a + b;
} catch (myError) {
mySlardar(myError) // 自定義處理(eg:函數(shù)錯誤自動上報)
}
}
思路:
遍歷函數(shù)聲明(FunctionDeclaration)節(jié)點
提取該節(jié)點下整個代碼塊節(jié)點,作為 try 語句(tryStatement)處理塊中的內(nèi)容
構(gòu)造一個自定義的 catch 子句(catchClause)節(jié)點,作為 try 異常處理塊的內(nèi)容
將整個 try 語句節(jié)點作為一個新的函數(shù)聲明節(jié)點的子節(jié)點,用新生成的節(jié)點替換原有的函數(shù)聲明節(jié)點
const compile = (code) => {
// 1. tokenizer + parser
const ast = parser.parse(code)
// utils.writeAst2File(ast) // 查看 ast 結(jié)果
// 2. traverse
const visitor = {
FunctionDeclaration(path) {
const node = path.node
const { params, id } = node // 函數(shù)的參數(shù)和函數(shù)體節(jié)點
const blockStatementNode = node.body
// 已經(jīng)有 try-catch 塊的停止遍歷,防止 circle loop
if (blockStatementNode.body && types.isTryStatement(blockStatementNode.body[0])) {
return
}
// 構(gòu)造 cath 塊節(jié)點
const catchBlockStatement = types.blockStatement(
[types.expressionStatement(
types.callExpression(types.identifier('mySlardar'), [types.identifier('myError')])
)]
)
// catch 子句節(jié)點
const catchClause = types.catchClause(types.identifier('myError'), catchBlockStatement)
// try 語句節(jié)點
const tryStatementNode = types.tryStatement(blockStatementNode, catchClause)
// try-catch 節(jié)點作為新的函數(shù)聲明節(jié)點
const tryCatchFunctionDeclare = types.functionDeclaration(id, params, types.blockStatement([tryStatementNode]))
path.replaceWith(tryCatchFunctionDeclare)
}
}
traverse.default(ast, visitor)
// 3. code generator
const newCode = generator.default(ast, {}, code).code
}
??????
目標(biāo):在 webpack 中實現(xiàn) import 的按需導(dǎo)入(乞丐版 babel-import-plugin)
// Before
import { Button as Btn, Dialog } from '233_UI'
import { HHH as hhh } from '233_UI'
設(shè)置自定義參數(shù):
(moduleName) => `233_UI/lib/src/${moduleName}/${moduleName} `
// => After
import { Button as Btn } from "233_UI/lib/src/Button/Button"
import { Dialog } from "233_UI/lib/src/Dialog/Dialog"
import { HHH as hhh } from "233_UI/lib/src/HHH/HHH"
思路:
在插件運行的上下文狀態(tài)中指定自定義的查找文件路徑規(guī)則
遍歷 import 聲明節(jié)點(ImportDeclaration)
提取 import 節(jié)點中所有被導(dǎo)入的變量節(jié)點(ImportSpecifier)
將該節(jié)點的值通過查找文件路徑規(guī)則生成新的導(dǎo)入源路徑,有幾個導(dǎo)入節(jié)點就有幾個新的源路徑
組合被導(dǎo)入的節(jié)點和源頭路徑節(jié)點,生成新的 import 聲明節(jié)點并替換
// 乞丐版按需引入 Babel 插件
const visitor = ({types}) => {
return {
visitor: {
ImportDeclaration(path, {opts}) {
const _getModulePath = opts.moduleName // 獲取模塊指定路徑,通過插件的參數(shù)傳遞進來
const importSpecifierNodes = path.node.specifiers // 導(dǎo)入的對象節(jié)點
const importSourceNode = path.node.source // 導(dǎo)入的來源節(jié)點
const sourceNodePath = importSourceNode.value
// 已經(jīng)成功替換的節(jié)點不再遍歷
if (!opts.libaryName || sourceNodePath !== opts.libaryName) {
return
}
const modulePaths = importSpecifierNodes.map(node => {
return _getModulePath(node.imported.name)
})
const newImportDeclarationNodes = importSpecifierNodes.map((node, index) => {
return types.importDeclaration([node], types.stringLiteral(modulePaths[index]))
})
path.replaceWithMultiple(newImportDeclarationNodes)
}
}
}
}
const result = babel.transform(code, {
plugins: [
[
visitor,
{
libaryName: '233_UI',
moduleName: moduleName => `233_UI/lib/src/${moduleName}/${moduleName}`
}
]
]
})
上述三個??的詳細(xì)代碼和運行示例的倉庫地址見 https://github.com/xunhui/ast_js_demo[1]
總結(jié)
或許我們的日常工作和 AST 打交道的機會并不多,更不會刻意地去關(guān)注語言底層編譯器的原理,但了解 AST 可以幫助我們更好地理解日常開發(fā)工具的原理,更輕松地上手這些工具暴露的 API。
工作的每一天,我們的喜怒哀樂通過一行又一行的代碼向眼前的機器傾訴。它到底是怎么讀懂你的情愫,又怎么給予你相應(yīng)的回應(yīng),這是一件非常值得探索的事情:)
參考
ASTs - What are they and how to use them[2]
AST 實現(xiàn)函數(shù)錯誤自動上報[3]
Babel Handbook[4]
參考資料
https://github.com/xunhui/ast_js_demo: https://github.com/xunhui/ast_js_demo
[2]ASTs - What are they and how to use them: https%3A%2F%2Fwww.twilio.com%2Fblog%2Fabstract-syntax-trees
[3]AST 實現(xiàn)函數(shù)錯誤自動上報: https%3A%2F%2Fsegmentfault.com%2Fa%2F1190000037630766
[4]Babel Handbook: https%3A%2F%2Fgithub.com%2Fjamiebuilds%2Fbabel-handbook
