前言
相信目前常與 ES6 程式碼打交道的同學對 Babel 應該不會陌生,在 ES6 程式碼被編譯轉化為 ES5 程式碼的過程中,Babel 外掛顯得尤為重要,我們最後經由 Babel 生成的程式碼取決於外掛在這一層中做了什麼事,在探索這其中的過程之前,我們先來了解下一些所需的基礎知識。
抽象語法樹
Babel 的工作流可以用下面一張圖來表示,程式碼首先經由 babylon
解析成抽象語法樹(AST),後經一些遍歷和分析轉換(主要過程),最後根據轉換後的 AST 生成新的常規程式碼。
在這其中,理解清楚 AST 十分重要,我們之所以需要將程式碼轉換為 AST 也是為了讓計算機能夠更好地進行理解。我們可以來看看下面這段程式碼被解析成 AST 後對應的結構圖:
1 2 3 4 5 6 7 |
function abs(number) { if (number >= 0) { // test return number; // consequent } else { return -number; // alternate } } |
所有的 AST 根節點都是 Program 節點,從上圖中我們可以看到解析生成的 AST 的結構的各個 Node 節點都很細微,Babylon AST 有個文件對每個節點型別都做了詳細的說明,你可以對照各個節點型別在這查詢到所需要的資訊。在這個例子中,我們主要關注函式宣告裡的內容, IfStatement
對應程式碼中的 if...else
區塊的內容,我們先對條件(test
)進行判斷,這裡是個簡單的二進位制表示式,我們的分支也會從這個條件繼續進行下去,consequent
代表條件值為 true 的分支,alternate
代表條件值為 false 的分支,最後兩條分支各自在 ReturnStatement
節點進行返回。
瞭解 AST 各個節點的型別是後續編寫外掛的關緊,AST 通常情況下都是比較複雜的,上述一段簡單的函式定義也生成了比較大的 AST,對於一些複雜的程式,我們可以藉助astexplorer 來幫我們分析 AST 的結構。
遍歷節點
在外掛裡進行節點遍歷需要先了解 visitor 和 path 的概念,前者相當於從眾多節點型別中選擇開發者所需要的節點,後者相當於對節點之間的關係的訪問。
visitor
Babel 使用 babel-traverse
進行樹狀的遍歷,對於 AST 樹上的每一個分支我們都會先向下遍歷走到盡頭,然後向上遍歷退出遍歷過的節點尋找下一個分支。Babel 提供我們一個visitor 物件供我們獲取 AST 裡所需的具體節點來進行訪問,比如我只想訪問 if...else
生成的節點,我們可以在 visitor 裡指定獲取它所對應的節點:
1 2 3 4 5 |
const visitor = { IfStatement() { console.log('get if'); } }; |
繼續上述所說的遍歷,其實這種遍歷會讓每個節點都會被訪問兩次,一次是向下遍歷代表進入(enter),一次是向上退出(exit)。因此實際上每個節點都會有 enter
和 exit
方法,在實際操作的時候需要注意這種遍歷方式可能會引起的一些問題,上述例子是省略掉enter
的簡寫。
1 2 3 4 5 6 |
const visitor = { IfStatement: { enter() {}, exit() {} } } |
path
visitor 模式中我們對節點的訪問實際上是對節點路徑的訪問,在這個模式中我們一般把path
當作引數傳入節點選擇器中。path
表示兩個節點之間的連線,通過這個物件我們可以訪問到節點、父節點以及進行一系列跟節點操作相關的方法(類似 DOM 的操作)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
var babel = require('babel-core'); var t = require('babel-types'); const code = `d = a + b + c`; const visitor = { Identifier(path) { console.log(path.node.name); // d a b c } }; const result = babel.transform(code, { plugins: [{ visitor: visitor }] }); |
替換節點
具備了 AST 相關知識和了解 visitor、path 後,就可以編寫一個簡單的 Babel 外掛了。我們要把上述的 abs
函式換成原生支援的 Math.abs
來進行呼叫 。
首先我們先解析下 abs(-8)
的 AST 結構,直接從表示式語句(ExpressionStatement)開始:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
{ type: "ExpressionStatement", expression: { type: "CallExpression", callee: { type: "Identifier", name: "abs" }, arguments: [{ type: "UnaryExpression", operator: "-", prefix: true, arguments: { type: "NumericLiteral", value: 8 } }] } } |
我們可以看到表示式語句下面的 expression
主要是函式呼叫表示式(CallExpression
),因此我們也需要建立一個函式呼叫表示式,此外,Math.abs
是一個二元操作表示式,屬於MemberExpression
型別。上述兩個 AST 節點我們可以藉助 babel-types 裡提供的一些方法幫我們快速建立。
1 2 3 4 5 6 7 |
// 建立函式呼叫表示式 t.CallExpression( // 建立物件屬性引用 t.MemberExpression(t.identifier('Math'), t.identifier('abs')), // 原始節點函式呼叫引數 path.node.arguments ) |
最後我們需要對此次函式呼叫不符合的節點進行過濾,過濾掉名字不等於 abs 的函式呼叫,因為 Babel 在遍歷的過程是遞迴的,如果不過濾做限制的話,程式將會一直執行最終報呼叫棧超過閾值的錯誤。
RangeError: unknown: Maximum call stack size exceeded
最終程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
var babel = require('babel-core'); var t = require('babel-types'); const code = `abs(-8);`; const visitor = { CallExpression(path) { if (path.node.callee.name !== 'abs') return; path.replaceWith(t.CallExpression( t.MemberExpression(t.identifier('Math'), t.identifier('abs')), path.node.arguments )); } }; const result = babel.transform(code, { plugins: [{ visitor: visitor }] }); // Math.abs(-8) console.log(result.code); |
上述例子使用了 transform
api 直接解析轉換生成了新的程式碼,另外在單獨編寫 Babel 外掛的時候,暴露的引數裡一般都含有常用的 babel-types
物件供使用。
1 2 3 4 5 |
export default function({ types: t }) { return { visitor: {} }; } |
總結
通過編寫 Babel 外掛我們能對 AST 有一定的瞭解,另外,我認為現階段 Babel 外掛不僅僅止於對 ES6 程式碼的轉換上,npm 上有一系列的外掛覆蓋了許多適合的應用場景,後續具有一定的探索性。