[第9期] 深入淺出 Babel 上篇:架構和原理 + 實戰
- 2020 年 3 月 3 日
- 筆記
放假了,我還在利用碎片時間在寫文章,不知道長假還有沒有人看,試試水吧!
這個文章系列將帶大家深入淺出 Babel, 這個系列將分為上下兩篇:上篇主要介紹 Babel 的架構和原理,順便實踐一下插件開發的;下篇會介紹 `babel-plugin-macros , 利用它來寫屬於 Javascript 的』宏『。
✨滿滿的乾貨,不容錯過哦. 寫文不易,點贊是最大的鼓勵。
注意: 本文不是 Babel 的基礎使用教程!如果你對 Babel 尚不了解,請查看官方網站, 或者這個用戶手冊
文章大綱
- Babel 的處理流程
- Babel 的架構
- 訪問者模式
- 節點的遍歷
- 節點的上下文
- 副作用的處理
- 作用域的處理
- 搞一個插件唄
- 最後
- 擴展
Babel 的處理流程

Babel 的處理流程
上圖是 Babel 的處理流程, 如果讀者學習過編譯器原理,這個過程就相當親切了.
首先從源碼 解析(Parsing) 開始,解析包含了兩個步驟:
1. ️⃣詞法解析(Lexical Analysis):詞法解析器(Tokenizer)在這個階段將字元串形式的程式碼轉換為Tokens(令牌). Tokens 可以視作是一些語法片段組成的數組. 例如for (const item of items) {} 詞法解析後的結果如下:

從上圖可以看,每個 Token 中包含了語法片段、位置資訊、以及一些類型資訊. 這些資訊有助於後續的語法分析。
2️⃣語法解析(Syntactic Analysis):這個階段語法解析器(Parser)會把Tokens轉換為抽象語法樹(Abstract Syntax Tree,AST)
什麼是AST?
它就是一棵'對象樹',用來表示程式碼的語法結構,例如console.log('hello world')會解析成為:

Program、CallExpression、Identifier 這些都是節點的類型,每個節點都是一個有意義的語法單元。這些節點類型定義了一些屬性來描述節點的資訊。
JavaScript的語法越來越複雜,而且 Babel 除了支援最新的JavaScript規範語法, 還支援 JSX、Flow、現在還有Typescript。想像一下 AST 的節點類型有多少,其實我們不需要去記住這麼多類型、也記不住. 插件開發者會利用 ASTExplorer 來審查解析後的AST樹, 非常強大?。
AST 是 Babel 轉譯的核心數據結構,後續的操作都依賴於 AST。
接著就是**轉換(Transform)**了,轉換階段會對 AST 進行遍歷,在這個過程中對節點進行增刪改查。Babel 所有插件都是在這個階段工作, 比如語法轉換、程式碼壓縮。
Javascript In Javascript Out, 最後階段還是要把 AST 轉換回字元串形式的Javascript,同時這個階段還會生成Source Map。
Babel 的架構
我在《透過現象看本質: 常見的前端架構風格和案例?》 提及 Babel 和 Webpack 為了適應複雜的訂製需求和頻繁的功能變化,都使用了微內核 的架構風格。也就是說它們的核心非常小,大部分功能都是通過插件擴展實現的。
所以簡單地了解一下 Babel 的架構和一些基本概念,對後續文章內容的理解, 以及Babel的使用還是有幫助的。
一圖勝千言。仔細讀過我文章的朋友會發現,我的風格就是能用圖片說明的就不用文字、能用文字的就不用程式碼。雖然我的原創文章篇幅都很長,圖片還是值得看看的。

Babel 是一個 MonoRepo 項目, 不過組織非常清晰,下面就源碼上我們能看到的模組進行一下分類, 配合上面的架構圖讓你對Babel有個大概的認識:
1️⃣ 核心:
@babel/core 這也是上面說的『微內核』架構中的『內核』。對於Babel來說,這個內核主要干這些事情:
- 載入和處理配置(config)
- 載入插件
- 調用 Parser 進行語法解析,生成 AST
- 調用 Traverser 遍歷AST,並使用訪問者模式應用'插件'對 AST 進行轉換
- 生成程式碼,包括SourceMap轉換和源程式碼生成
2️⃣ 核心周邊支撐
- Parser(@babel/parser):將源程式碼解析為 AST 就靠它了。它已經內置支援很多語法. 例如 JSX、Typescript、Flow、以及最新的ECMAScript規範。目前為了執行效率,parser是不支援擴展的,由官方進行維護。如果你要支援自定義語法,可以 fork 它,不過這種場景非常少。
- Traverser(@babel/traverse): 實現了訪問者模式,對 AST 進行遍歷,轉換插件會通過它獲取感興趣的AST節點,對節點繼續操作, 下文會詳細介紹訪問器模式。
- Generator(@babel/generator):將 AST 轉換為源程式碼,支援 SourceMap
3️⃣ 插件
打開 Babel 的源程式碼,會發現有好幾種類型的『插件』。
- 語法插件(@babel/plugin-syntax-*):上面說了 @babel/parser 已經支援了很多 JavaScript 語法特性,Parser也不支援擴展. 因此plugin-syntax-*實際上只是用於開啟或者配置Parser的某個功能特性。 一般用戶不需要關心這個,Transform 插件裡面已經包含了相關的plugin-syntax-*插件了。用戶也可以通過parserOpts配置項來直接配置 Parser
- 轉換插件:用於對 AST 進行轉換, 實現轉換為ES5程式碼、壓縮、功能增強等目的. Babel倉庫將轉換插件劃分為兩種(只是命名上的區別):
- @babel/plugin-transform-*:普通的轉換插件
- @babel/plugin-proposal-*:還在'提議階段'(非正式)的語言特性, 目前有這些
- 預定義集合(@babel/presets-*):插件集合或者分組,主要方便用戶對插件進行管理和使用。比如preset-env含括所有的標準的最新特性; 再比如preset-react含括所有react相關的插件.
4️⃣ 插件開發輔助
- @babel/template:某些場景直接操作AST太麻煩,就比如我們直接操作DOM一樣,所以Babel實現了這麼一個簡單的模板引擎,可以將字元串程式碼轉換為AST。比如在生成一些輔助程式碼(helper)時會用到這個庫
- @babel/types:AST 節點構造器和斷言. 插件開發時使用很頻繁
- @babel/helper-*:一些輔助器,用於輔助插件開發,例如簡化AST操作
- @babel/helper:輔助程式碼,單純的語法轉換可能無法讓程式碼運行起來,比如低版本瀏覽器無法識別class關鍵字,這時候需要添加輔助程式碼,對class進行模擬。
5️⃣ 工具
- @babel/node:Node.js CLI, 通過它直接運行需要 Babel 處理的JavaScript文件
- @babel/register:Patch NodeJs 的require方法,支援導入需要Babel處理的JavaScript模組
- @babel/cli:CLI工具
訪問者模式
轉換器會遍歷 AST 樹,找出自己感興趣的節點類型, 再進行轉換操作. 這個過程和我們操作DOM樹差不多,只不過目的不太一樣。AST 遍歷和轉換一般會使用訪問者模式。
想像一下,Babel 有那麼多插件,如果每個插件自己去遍歷AST,對不同的節點進行不同的操作,維護自己的狀態。這樣子不僅低效,它們的邏輯分散在各處,會讓整個系統變得難以理解和調試, 最後插件之間關係就糾纏不清,亂成一鍋粥。
所以轉換器操作 AST 一般都是使用訪問器模式,由這個訪問者(Visitor)來 ① 進行統一的遍歷操作,② 提供節點的操作方法,③ 響應式維護節點之間的關係;而插件(設計模式中稱為『具體訪問者』)只需要定義自己感興趣的節點類型,當訪問者訪問到對應節點時,就調用插件的訪問(visit)方法。
節點的遍歷
假設我們的程式碼如下:
br
解析後的 AST 結構如下:
br
複製程式碼
訪問者會以深度優先的順序, 或者說遞歸地對 AST 進行遍歷,其調用順序如下圖所示:

上圖中綠線表示進入該節點,紅線表示離開該節點。下面寫一個超簡單的'具體訪問者'來還原上面的遍歷過程:
br
查看程式碼執行結果
當訪問者進入一個節點時就會調用 enter(進入) 方法,反之離開該節點時會調用 exit(離開) 方法。一般情況下,插件不會直接使用enter方法,只會關注少數幾個節點類型,所以具體訪問者也可以這樣聲明訪問方法:
br
那麼 Babel 插件是怎麼被應用的呢?
Babel 會按照插件定義的順序來應用訪問方法,比如你註冊了多個插件,babel-core 最後傳遞給訪問器的數據結構大概長這樣:
br
當進入一個節點時,這些插件會按照註冊的順序被執行。大部分插件是不需要開發者關心定義的順序的,有少數的情況需要稍微注意下,例如
plugin-proposal-decorators:
br
複製程式碼
所有插件定義的順序,按照慣例,應該是新的或者說實驗性的插件在前面,老的插件定義在後面。因為可能需要新的插件將 AST 轉換後,老的插件才能識別語法(向後兼容)。下面是官方配置例子, 為了確保先後兼容,stage-*階段的插件先執行:
br
注意Preset的執行順序相反,詳見官方文檔
節點的上下文
訪問者在訪問一個節點時, 會無差別地調用 enter 方法,我們怎麼知道這個節點在什麼位置以及和其他節點的關聯關係呢?
通過上面的程式碼,讀者應該可以猜出幾分,每個visit方法都接收一個 Path 對象, 你可以將它當做一個『上下文』對象,類似於JQuery的 JQuery(const $el = $('.el')) 對象,這裡面包含了很多資訊:
- 當前節點資訊
- 節點的關聯資訊。父節點、子節點、兄弟節點等等
- 作用域資訊
- 上下文資訊
- 節點操作方法。節點增刪查改
- 斷言方法。isXXX, assertXXX
下面是它的主要結構:
br
你可以通過這個手冊來學習怎麼通過 Path 來轉換 AST. 後面也會有程式碼示例,這裡就不展開細節了
副作用的處理
實際上訪問者的工作比我們想像的要複雜的多,上面示範的是靜態 AST 的遍歷過程。而 AST 轉換本身是有副作用的,比如插件將舊的節點替換了,那麼訪問者就沒有必要再向下訪問舊節點了,而是繼續訪問新的節點, 程式碼如下。
br
上面的程式碼, 將console.log('hello' + v + '!')語句替換為return "hello" + v;, 下圖是遍歷的過程:

我們可以對 AST 進行任意的操作,比如刪除父節點的兄弟節點、刪除第一個子節點、新增兄弟節點… 當這些操作'污染'了 AST 樹後,訪問者需要記錄這些狀態,響應式(Reactive)更新 Path 對象的關聯關係, 保證正確的遍歷順序,從而獲得正確的轉譯結果。
作用域的處理
訪問者可以確保正確地遍歷和修改節點,但是對於轉換器來說,另一個比較棘手的是對作用域的處理,這個責任落在了插件開發者的頭上。插件開發者必須非常謹慎地處理作用域,不能破壞現有程式碼的執行邏輯。
br
比如你要將 add 函數的第一個參數 foo 標識符修改為a, 你就需要遞歸遍歷子樹,查出foo標識符的所有引用, 然後替換它:
br
?慢著,好像沒那麼簡單,替換成 a 之後, console.log(a, b) 的行為就被破壞了。所以這裡不能用 a,得換個標識符, 譬如c.
這就是轉換器需要考慮的作用域問題,AST 轉換的前提是保證程式的正確性。我們在添加和修改引用時,需要確保與現有的所有引用不衝突。Babel本身不能檢測這類異常,只能依靠插件開發者謹慎處理。
Javascript採用的是詞法作用域, 也就是根據源程式碼的詞法結構來確定作用域:

在詞法區塊(block)中,由於新建變數、函數、類、函數參數等創建的標識符,都屬於這個區塊作用域. 這些標識符也稱為綁定(Binding),而對這些綁定的使用稱為引用(Reference)
在Babel中,使用Scope對象來表示作用域。我們可以通過Path對象的scope欄位來獲取當前節點的Scope對象。它的結構如下:
br
Scope 對象和 Path 對象差不多,它包含了作用域之間的關聯關係(通過parent指向父作用域),收集了作用域下面的所有綁定(bindings), 另外還提供了豐富的方法來對作用域僅限操作。
我們可以通過bindings屬性獲取當前作用域下的所有綁定(即標識符),每個綁定由Binding類來表示:
br
複製程式碼
通過Binding對象我們可以確定標識符被引用的情況。
Ok,有了 Scope 和 Binding, 現在有能力實現安全的變數重命名轉換了。為了更好地展示作用域交互,在上面程式碼的基礎上,我們再增加一下難度:
br
現在你要重命名函數參數 foo, 不僅要考慮外部的作用域, 也要考慮下級作用域的綁定情況,確保這兩者都不衝突。
上面的程式碼作用域和標識符引用情況如下圖所示:

來吧,接受挑戰,試著將函數的第一個參數重新命名為更短的標識符:
// 用於獲取唯一的標識符
上面的例子雖然沒有什麼實用性,而且還有Bug(沒考慮label),但是正好可以揭示了作用域處理的複雜性。
Babel的 Scope 對象其實提供了一個generateUid方法來生成唯一的、不衝突的標識符。我們利用這個方法再簡化一下我們的程式碼:
br
能不能再短點!
br
查看generateUid的實現程式碼
非常簡潔哈?作用域操作最典型的場景是程式碼壓縮,程式碼壓縮會對變數名、函數名等進行壓縮… 然而實際上很少的插件場景需要跟作用域進行複雜的交互,所以關於作用域這一塊就先講到這裡。
搞一個插件唄
等等別走,還沒完呢,這才到2/3。
學了上面的知識,總得寫一個玩具插件試試水吧?
現在打算模仿babel-plugin-import, 寫一個極簡版插件,來實現模組的按需導入. 在這個插件中,我們會將類似這樣的導入語句:
br
轉換為:
br
首先通過 AST Explorer 看一下導入語句的 AST 節點結構:

通過上面展示的結果,我們需要處理 ImportDeclaration 節點類型,將它的specifiers拿出來遍歷處理一下。另外如果用戶使用了默認導入語句,我們將拋出錯誤,提醒用戶不能使用默認導入.
基本實現如下:
br
邏輯還算簡單,babel-plugin-import可比這複雜得多。
接下來,我們將它封裝成標準的 Babel 插件。按照規範,我們需要創建一個babel-plugin-*前綴的包名:
br
你也可以通過 generator-babel-plugin 來生成項目模板.
在 index.js 文件中填入我們的程式碼。index.js默認導出一個函數,函數結構如下:
br
我們可以從訪問器方法的第二個參數state中獲取用戶傳入的參數。假設用戶配置為:
br
我們可以這樣獲取用戶傳入的參數:
br
打完收工 ?,發布!
最後
新世界的大門已經打開: ⛩
本文主要介紹了 Babel 的架構和原理,還實踐了一下 Babel 插件開發,讀到這裡,你算是入了 Babel 的門了.

接下來你可以去熟讀Babel手冊, 這是目前最好的教程,ASTExplorer是最好的演練場,多寫程式碼多思考。你也可以去看Babel的官方插件實現, 邁向更高的台階。
本文還有下篇,我將在下篇文章中介紹 babel-plugin-macros, 敬請期待!
點贊是對我最好鼓勵。