在現代前端開發中,AST(Abstract Syntax Tree,抽象語法樹)指的是“用樹來表達一段程式碼的語法結構”。通俗理解:把程式碼先讀懂、拆解成一個個語法“節點”,再把這些節點按層級連成一棵樹,後續工具就能“對樹動手術”。
AST 的用途非常廣,是許多工具的基礎:
🌰 舉一個特別形象的例子:小時候學英語的時候,老師經常會告訴我們在閱讀長難句的時候,需要先找到主謂賓,然後再去理解句子中的其他成分。
在程式碼的世界裡,解析 AST 就有點像分析句子的“主謂賓定狀補”:先理清主幹再看細節。有了 AST,其他前端工具能準確理解程式碼結構,從而實現重構、優化、自動修復,甚至生成新程式碼。
例如這段程式碼:
const sum = 1 + 2;
AST(簡化後)會描述出:
實際上,我們編寫的程式碼都是以字串的形式儲存在檔案中的,而我們要想讓計算機理解這些程式碼,就需要將這些程式碼轉換成計算機能夠理解的資料結構,這個過程就是解析程式碼生成 AST(抽象語法樹)。
從字串到解析出抽象語法樹,中間會經歷兩個步驟:詞法分析(Lexical Analysis)和語法分析(Syntax Analysis)。
詞法分析(Lexical Analysis)是編譯器的第一步,它把源碼從“字元流”切分成有意義的最小單元:Token(記號)。
簡單理解:
type
(類型,如 Identifier 標識符、Punctuator 符號等)、value
(字面值)、以及位置信息(start
/end
或 loc
)。以簡單的 n * n
程式碼舉例,最終會形成如下 token 列表:
[
{ type: { ... }, value: "n", start: 0, end: 1, loc: { ... } },
{ type: { ... }, value: "*", start: 2, end: 3, loc: { ... } },
{ type: { ... }, value: "n", start: 4, end: 5, loc: { ... } },
]
詞法分析僅僅是編譯的第一步,通常情況下,開發者不會直接使用詞法分析的結果,因為僅僅將字串轉換成 token 還不夠,我們還需要將 token 轉換成抽象語法樹。
語法分析(Syntax Analysis)是編譯器的第二步,它把“線性的 token 列表”組裝成“有父子層級的抽象語法樹(AST)”。
現代前端開發中,能夠實現將源碼解析為抽象語法樹的工具有很多:Babel、Acorn、Espree、Esprima 等。本文將介紹如何使用 Espree 來實現將源碼解析為抽象語法樹。
Espree 提供了 parse
方法,可以傳入源碼與可選配置,然後返回 AST:
const espree = require("espree");
const ast = espree.parse("var a = 1;");
console.log("🚀 ~ ast:", ast);
🚀 ~ ast: Node {
type: 'Program',
start: 0,
end: 10,
body: [
Node {
type: 'VariableDeclaration',
start: 0,
end: 10,
declarations: [
{
type: 'VariableDeclarator',
start: 4,
end: 9,
id: Node { type: 'Identifier', start: 4, end: 5, name: 'a' },
init: Node { type: 'Literal', start: 8, end: 9, value: 1, raw: '1' }
}
],
kind: 'var'
}
],
sourceType: 'script'
}
Program
:整段程式碼的“根節點”(最外層容器)。body
裡按順序放著每一條語句。VariableDeclaration
:一條“變數聲明”語句,這裡的 kind
是 var
(也可能是 let
或 const
)。declarations
:聲明列表。因為 JS 允許 var a = 1, b = 2;
這樣的多聲明,所以這裡是陣列。VariableDeclarator
:一次具體的“聲明”。它有兩個關鍵子節點:
id
:被聲明的變數是誰(這裡是一個 Identifier
,名字為 a
)。init
:這個變數被賦的初始值(這裡是一個 Literal
,數值 1
)。Identifier
:標識符節點,表示變數名、函數名等(此處為 a
)。Literal
:字面量節點,表示具體的值(此處為 1
)。start/end
:在源碼字串中的起止位置(基於字元索引),有助於錯誤提示、高亮、程式碼修改定位等。sourceType
:腳本類型,script
表示是 CommonJS 模塊,module
表示是 ES Module。把上面的結構“壓扁”成一棵更直觀的小樹,可以這樣理解:
Program
└── VariableDeclaration (kind: var)
└── VariableDeclarator
├── id: Identifier (name: a)
└── init: Literal (value: 1)
需要特別注意的是:Espree 預設支持 ES5 語法,如果需要支持 ES6 語法,需要傳入 ecmaVersion
參數,指定 ES6 語法。
在上文我們提到過,能夠實現將源碼解析為抽象語法樹的工具有很多:Babel、Acorn、Espree、Esprima 等。我們轉為 AST 的目的是最終要使用 AST 進行程式碼的轉換,但是如何能確保 AST 的規範性呢?
簡單來說:不同解析器如果各自定義一套“樹的長相”,後續處理的工具就沒法通用。為了解決“同一種語言、不同解析器產出的 AST 長得不一樣”的問題,於是就制定了接口規範 — ESTree。
在上文我們使用 Espree 打印了 AST。可能有些同學會有這樣的疑問:不清楚這些“節點類型”從何而來、去哪查?ESTree 定義了常見節點的命名、字段和層級關係,可參考文檔:github.com/estree/estr…
與此同時,我們也可以使用 AST 調試工具 來快速查看 AST 結構,比如 AST Explorer(astexplorer.net)。 在實際開發中,我們會經常使用 AST Explorer 來查看 AST 結構,以便於我們更好地理解程式碼的結構。
目前,我們已經得到了一棵 AST 樹結構,但這還遠遠不夠,我們還需要對 AST 樹進行遍歷,以便於我們更好地理解程式碼的結構,甚至是對 AST 樹的特定節點進行修改。
AST 遍歷採用的是深度優先遍歷,簡單說就是:先一頭扎到底,走不通了再回頭看其他分支。我們用一個實際例子來理解:
function square(n) {
return n * n;
}
這段程式碼對應的 AST(簡化版)長這樣:
{
type: "FunctionDeclaration",
id: {
type: "Identifier",
name: "square"
},
params: [{
type: "Identifier",
name: "n"
}],
body: {
type: "BlockStatement",
body: [{
type: "ReturnStatement",
argument: {
type: "BinaryExpression",
operator: "*",
left: {
type: "Identifier",
name: "n"
},
right: {
type: "Identifier",
name: "n"
}
}
}]
}
}
我們從最外層的 FunctionDeclaration
(函數聲明)開始,它有三個重要屬性:id
(函數名)、params
(參數)、body
(函數體)。然後就像剝洋蔥一樣,一層層往裡走。下圖清晰展示了 AST 結構與遍歷的過程:
首先看 id 屬性—函數名:id
是一個 Identifier
節點,記錄著函數名叫 square
。這個節點沒有子節點了,所以看完就繼續往下。
接著看 params 屬性—參數:params
是個陣列,裡面有一個 Identifier
節點,代表參數 n
。
然後進入 body —函數體:body
是個 BlockStatement
(塊語句節點),也就是 {}
包裹的部分。它自己也有個 body
屬性,這個屬性下裝著函數體內的語句。
深入到 BlockStatement 的 body 屬性:陣列裡有個 ReturnStatement
節點,它的 argument
(返回值)指向一個 BinaryExpression
(二元表達式節點),也就是 n * n
這個乘法運算。
最後到達葉子節點:BinaryExpression
有 operator
(操作符 *
)、left
(左邊的 n
)和 right
(右邊的 n
)。注意 operator
只是個普通的字串值,不是節點;而 left
和 right
才是真正的 Identifier
節點。
很多初學者會困惑:節點和屬性到底是什麼關係?
可以這樣理解:只有帶 type
字段的物件才是"節點"。比如 { type: "Identifier", name: "n" }
是一個節點,而 id
、params
、body
這些只是"屬性名",它們的值才可能是節點。
舉個例子:FunctionDeclaration
這個節點下有 id
、params
、body
三個屬性,這三個屬性名本身不是節點,但它們的值(Identifier
、陣列裡的 Identifier
、BlockStatement
)都是節點。
所以遍歷 AST 的過程,就是沿著這些屬性(邊),不斷訪問子節點(頂點),直到走遍整棵樹。
手工遞歸遍歷 AST 太麻煩,我們可以借助現成的工具庫 Estraverse 來幫我們"走遍"整棵樹。
Estraverse 的核心概念:進入(enter)與離開(leave)
這種設計讓我們可以在"進入時"做一些準備工作,在"離開時"做一些收尾工作,非常靈活。
遍歷上文的 AST
Estraverse 提供了 traverse
方法,可以傳入 AST 樹和遍歷回調函數。回調函數的參數是當前遍歷到的節點。
我們繼續用剛才 function square(n) { return n * n; }
的例子,用 Estraverse 遍歷並打印每個節點的類型:
const espree = require("espree");
const estraverse = require("estraverse");
const ast = espree.parse(`function square(n) {
return n * n;
}`);
estraverse.traverse(ast, {
enter: function (node) {
console.log("🚀 ~ enter: ~ node.type:", node.type);
},
leave: function (node) {
console.log("🚀 ~ leave: ~ node.type:", node.type);
},
});
運行後的輸出是這樣的:
🚀 ~ enter: ~ node.type: Program
🚀 ~ enter: ~ node.type: FunctionDeclaration
🚀 ~ enter: ~ node.type: Identifier
🚀 ~ leave: ~ node.type: Identifier
🚀 ~ enter: ~ node.type: Identifier
🚀 ~ leave: ~ node.type: Identifier
🚀 ~ enter: ~ node.type: BlockStatement
🚀 ~ enter: ~ node.type: ReturnStatement
🚀 ~ enter: ~ node.type: BinaryExpression
🚀 ~ enter: ~ node.type: Identifier
🚀 ~ leave: ~ node.type: Identifier
🚀 ~ enter: ~ node.type: Identifier
🚀 ~ leave: ~ node.type: Identifier
🚀 ~ leave: ~ node.type: BinaryExpression
🚀 ~ leave: ~ node.type: ReturnStatement
🚀 ~ leave: ~ node.type: BlockStatement
🚀 ~ leave: ~ node.type: FunctionDeclaration
🚀 ~ leave: ~ node.type: Program
這也正好符合我們上文分析的 AST 結構與遍歷的過程。透過這種方式,我們就能訪問到 AST 中的每一個節點,做統計、收集資訊、分析程式碼結構等各種操作。
如何修改 AST 節點?
光是看還不夠,很多時候我們需要改。Estraverse 提供了 replace
方法,可以在遍歷的同時修改節點。但是生成的內容仍然是一棵 AST 樹,我們還需要借助 escodegen
庫將 AST 樹轉回程式碼(這裡就不進行過多講解)。比如我們想把程式碼中的乘法 *
改成加法 +
:
const espree = require("espree");
const estraverse = require("estraverse");
const escodegen = require("escodegen"); // 用於將 AST 轉回程式碼
const code = `function square(n) {
return n * n;
}`;
const ast = espree.parse(code);
// 遍歷並修改 AST
estraverse.replace(ast, {
enter: function (node) {
// 找到二元表達式節點,且操作符是 *
if (node.type === "BinaryExpression" && node.operator === "*") {
node.operator = "+"; // 改成加法
}
},
});
const newCode = escodegen.generate(ast);
console.log("修改後的程式碼:", newCode);
透過上述程式碼,我們成功把 n * n
改成了 n + n
。實際上,這就是 AST 轉換的基本原理:解析 → 遍歷 → 修改 → 生成新程式碼。Babel、ESLint 自動修復、程式碼重構工具,本質上都是在做這件事。
修改後的程式碼: function square(n) {
return n + n;
}
AST 在現代前端開發工具鏈中扮演著非常重要的角色,是程式碼檢查、程式碼格式化等工具的基礎。本文講解了 AST 抽象語法樹的 解析、遍歷與編輯,幫助大家從編譯原理的角度逐步理解抽象語法樹。
我們選擇的解析和遍歷庫是 espree 和 estraverse,相比於 Babel 等提供了更多上層封裝的庫而言,它們的返回內容更簡潔,使用場景更加通用,但實際上由於很多解析庫都遵循 ESTree 規範,所以使用方式和結果都大同小異。對於剛開始上手的讀者來說,讀懂 AST 結構是一件比較困難的事情,在這裡推薦大家多使用 AST Explorer 來對比查看簡單程式碼的 AST 結構來更好地理解抽象語法樹。