Babel 外掛:30分鐘從入門到實戰

語言: CN / TW / HK

Babel 是一個 source to source(原始碼到原始碼)的 JavaScript 編譯器,簡單來說,你為 Babel 提供一些 JavaScript 程式碼,Babel 可以更改這些程式碼,然後返回給你新生成的程式碼。Babel 主要用於將 ECMAScript 2015+ 程式碼轉換為能夠向後相容的 JavaScript 版本。Babel 使用外掛系統進行程式碼轉換,因此任何人都可以為 babel 編寫自己的轉換外掛,以支援實現廣泛的功能。

Babel 編譯流程

Babel 的編譯流程主要分為三個部分:解析(parse),轉換(transform),生成(generate)。

code -> AST -> transformed AST -> transformed code

  • 解析 Parse

將原始碼轉換成抽象語法樹(AST, Abstract Syntax Tree)。

比如:

function square(n) {   return n * n; }

以上的程式可以被轉換成類似這樣的抽象語法樹:

- FunctionDeclaration:   - id:     - Identifier:       - name: square   - params [1]     - Identifier       - name: n   - body:     - BlockStatement       - body [1]         - ReturnStatement           - argument             - BinaryExpression               - operator: *               - left                 - Identifier                   - name: n               - right                 - Identifier                   - name: n

  • 轉換 Transform

轉換階段接受一個 AST 並遍歷它,在遍歷的過程中對樹的節點進行增刪改。這也是執行 Babel 外掛的階段。

  • 生成 Generate

將經過一系列轉換之後的 AST 轉換成字串形式的程式碼,同時還會建立 sourcemap。

你會用到的一些工具庫

對於每一個階段,Babel 都提供了一些工具庫:

  • Parse 階段可以使用 @babel/parser 將原始碼轉換成 AST。
  • Transform 階段可以使用  @babel/traverse 呼叫 visitor 函式遍歷 AST,期間可以使用  @babel/types 建立 AST 和檢查 AST 節點的型別,批量建立 AST 的場景下可以使用  @babel/template 中途還可以使用  @babel/code-frame 列印報錯資訊。
  • Generate 階段可以使用  @babel/generator 根據 AST 生成程式碼字串和 sourcemap。

以上提及的包都是  @babel/core 的 dependencies,所以只需要安裝 @babel/core 就能訪問到它們。

除了上面提到的工具庫,以下工具庫也比較常用:

  • @babel/helper-plugin-utils:如果外掛使用者的 Babel 版本沒有您的外掛所需的 API,它能給使用者提供明確的錯誤資訊。
  • babel-plugin-tester:用於幫助測試 Babel 外掛的實用工具,通常配合 jest 使用。

本文不會深入討論它們的詳細用法,當你在編寫外掛的時候,可以根據功能需求找到它們,我們後文也會涉及到部分用法。

認識 Babel 外掛

接下來讓我們開始認識 Babel 外掛吧。

babel 外掛是一個簡單的函式,它必須返回一個匹配以下介面的物件。如果 Babel 發現未知屬性,它將丟擲錯誤。

圖片

以下是一個簡單的外掛示例:

export default function(api, options, dirname) {   return {     visitor: {       StringLiteral(path, state) {},     }   }; };

Babel 外掛接受 3 個引數:

  • api:一個物件,包含了 types (@babel/types)、traverse (@babel/traverse)、template(@babel/template) 等實用方法,我們能從這個物件中訪問到 @babel/core dependecies 中包含的方法。
  • options:外掛引數。
  • dirname:目錄名。

返回的物件有 name、manipulateOptions、pre、visitor、post、inherits 等屬性:

  • name:外掛名字。
  • inherits:指定繼承某個外掛,通過 Object.assign 的方式,和當前外掛的 options 合併。
  • visitor:指定 traverse 時呼叫的函式。
  • pre 和 post 分別在遍歷前後呼叫,可以做一些外掛呼叫前後的邏輯,比如可以往 file(表示檔案的物件,在外掛裡面通過 state.file 拿到)中放一些東西,在遍歷的過程中取出來。
  • manipulateOptions:用於修改 options,是在外掛裡面修改配置的方式。

我們上面提到了一些陌生的概念:visitor、path、state,現在讓我們一起來認識它們:

  • visitor 訪問者

這個名字來源於設計模式中的訪問者模式(https://en.wikipedia.org/wiki/Visitor_pattern) 簡單的說它就是一個物件,指定了在遍歷 AST 過程中,訪問指定節點時應該被呼叫的方法。

  • 假如我們有這樣一段程式:

    function foo() {       return 'string'     }

  • 這段程式碼對應的 AST 如下:

    - Program        - FunctionDeclaration (body[0])          - Identifier (id)          - BlockStatement (body)            - ReturnStatement (body[0])            - StringLiteral (arugument)

  • 當我們對這顆 AST 進行深度優先遍歷時,每次訪問 StringLiteral 都會呼叫 visitor.StringLiteral。

當 visitor.StringLiteral 是一個函式時,它將在向下遍歷的過程中被呼叫(即進入階段)。當 visitor.StringLiteral 是一個物件時({ enter(path, state) {}, exit(path, state) {} }),visitor.StringLiteral.enter 將在向下遍歷的過程中被呼叫(進入階段),visitor.StringLiteral.exit 將在向上遍歷的過程中被呼叫(退出階段)。

  • Path 路徑

Path 用於表示兩個節點之間連線的物件,這是一個可操作和訪問的巨大可變物件。

Path 之間的關係如圖所示:

圖片

除了能在 Path 物件上訪問到當前 AST 節點、父級 AST 節點、父級 Path 物件,還能訪問到新增、更新、移動和刪除節點等其他方法,這些方法提高了我們對 AST 增刪改的效率。

  • State 狀態

在實際編寫外掛的過程中,某一型別節點的處理可能需要依賴其他型別節點的處理結果,但由於 visitor 屬性之間互不關聯,因此需要 state 幫助我們在不同的 visitor 之間傳遞狀態。

一種處理方式是使用遞迴,並將狀態往下層傳遞:

``` const anotherVisitor = {   Identifier(path) {     console.log(this.someParam) // => 'xxx'   } };

const MyVisitor = {   FunctionDeclaration(path, state) {     // state.cwd: 當前執行目錄     // state.opts: 外掛 options     // state.filename: 當前檔名(絕對路徑)     // state.file: BabelFile 物件,包含當前整個 ast,當前檔案內容 code,etc.     // state.key: 當前外掛名字     path.traverse(anotherVisitor, { someParam: 'xxx' });   } }; ```

另外一種傳遞狀態的辦法是將狀態直接設定到 this 上,Babel 會給 visitor 上的每個方法繫結 this。在 Babel 外掛中,this 通常會被用於傳遞狀態:從 pre 到 visitor 再到 post。

export default function({ types: t }) {      return {        pre(state) {          this.cache = new Map();        },        visitor: {          StringLiteral(path) {            this.cache.set(path.node.value, 1);          }        },        post(state) {          console.log(this.cache);        }      };    }

常用的 API

Babel 沒有完整的文件講解所有的 api,因此下面會列舉一些可能還算常用的 api(並不是所有,主要是 path 和 types 上的方法或屬性),我們並不需要全部背下來,在你需要用的時候,能找到對應的方法即可。

你可以通過 babel 的 typescript 型別定義找到以下列舉的屬性和方法,還可以通過 Babel Handbook 找到它們的具體使用方法。

Babel Handbook:https://astexplorer.net/

  • 查詢
    • path.node:訪問當前節點
    • path.get():獲取屬性內部的 path
    • path.inList:判斷路徑是否有同級節點
    • path.key:獲取路徑所在容器的索引
    • path.container:獲取路徑的容器(包含所有同級節點的陣列)
    • path.listKey:獲取容器的key
    • path.getSibling():獲得同級路徑
    • path.findParent():對於每一個父路徑呼叫 callback 並將其 NodePath 當作引數,當 callback 返回真值時,則將其 NodePath 返回
    • path.find():與 path.findParent 的區別是,該方法會遍歷當前節點
  • 遍歷
    • path.stop():跳過遍歷當前路徑的子路徑
    • path.skip():完全停止遍歷
  • 判斷
    • types.isXxx():檢查節點的型別,如 types.isStringLiteral(path.node)
    • path.isReferencedIdentifier():檢查識別符號(Identifier)是否被引用
  • 增刪改
    • path.replaceWith():替換單個節點
    • path.replaceWithMultiple():用多節點替換單節點
    • path.replaceWithSourceString():用字串原始碼替換節點
    • path.insertBefore() / path.insertAfter():插入兄弟節點
    • path.get('listKey').unshiftContainer() / path.get('listKey').pushContainer():插入一個節點到陣列中,如 body
    • path.remove():刪除一個節點
  • 作用域
    • path.scope.hasBinding(): 從當前作用域開始向上查詢變數
    • path.scope.hasOwnBinding():僅在當前作用域中查詢變數
    • path.scope.generateUidIdentifier():生成一個唯一的識別符號,不會與任何本地定義的變數相沖突
    • path.scope.generateUidIdentifierBasedOnNode():基於某個節點建立唯一的識別符號
    • path.scope.rename():重新命名繫結及其引用

AST Explorer

在 @babel/types 的型別定義中,可以找到所有 AST 節點型別。我們不需要記住所有節點型別,社群內有一個 AST 視覺化工具能夠幫助我們分析 AST:axtexplorer.net。

在這個網站的左側,可以輸入我們想要分析的程式碼,在右側會自動生成對應的 AST。當我們在左側程式碼區域點選某一個節點,比如函式名 foo,右側 AST 會自動跳轉到對應的 Identifier AST 節點,並高亮展示。

圖片我們還可以修改要 parse 的語言、使用的 parser、parser 引數等。

自己實現一個外掛吧

現在讓我們來實現一個簡單的外掛吧!以下是外掛需要實現的功能:

  1. 將程式碼裡重複的字串字面量(StringLiteral)提升到頂層作用域。
  1. 接受一個引數 minCount,它是 number 型別,如果某個字串字面量重複次數大於等於 minCount 的值,則將它提升到頂層作用域,否則不做任何處理。

因此,對於以下輸入:

``` const s1 = "foo"; const s2 = "foo";

const s3 = "bar";

function f1() {   const s4 = "baz";   if (true) {     const s5 = "baz";   } } ```

應該輸出以下程式碼:

``` var _foo = "foo",   _baz = "baz"; const s1 = _foo; const s2 = _foo; const s3 = "bar";

function f1() {   const s4 = _baz;

if (true) {     const s5 = _baz;   } } ```

通過 https://astexplorer.net/,我們發現程式碼裡的字串在 AST 上對應的節點叫做 StringLiteral,如果想要拿到程式碼裡所有的字串並且統計每種字串的數量,就需要遍歷 StringLiteral 節點。

圖片

我們需要一個物件用於儲存所有 StringLiteral,key 是 StringLiteral 節點的 value 屬性值,value 是一個數組,用於儲存擁有相同 path.node.value 的所有 path 物件,最後把這個物件存到 state 物件上,以便於在遍歷結束時能統計相同字串的重複次數,從而可以判斷哪些節點需要被替換為一個識別符號。

export default function() {   return {     visitor: {       StringLiteral(path, state) {         state.stringPathMap = state.stringPathMap || {};         const nodes = state.stringPathMap[path.node.value] || [];         nodes.push(path);         state.stringPathMap[path.node.value] = nodes;       }     }   }; }

通過 https://astexplorer.net/ 我們發現如果想要往頂層作用域中插入一個變數,其實就是往 Program 節點的 body 上插入 AST 節點。Program 節點也是 AST 的頂層節點,在遍歷過程的退出階段,Program 節點是最後一個被處理的,因此我們需要做的事情是:根據收集到的字串字面量,分別建立一個位於頂層作用域的變數,並將它們統一插入到 Program 的 body 中,同時將程式碼中的字串替換為對應的變數。

圖片

export default function() {   return {     visitor: {       StringLiteral(path, state) { /** ... */ },       Program: {         exit(path, state) {           const { minCount = 2 } = state.opts || {};                  for (const [string, paths] of Object.entries(state.stringPathMap || {})) {             if (paths.length < minCount) {               continue;             }                    const id = path.scope.generateUidIdentifier(string);                    paths.forEach(p => {               p.replaceWith(id);             });                    path.scope.push({ id, init: types.stringLiteral(string) });           }         },       },     }   }; }

完整程式碼

``` import { PluginPass, NodePath } from '@babel/core'; import { declare } from '@babel/helper-plugin-utils';

interface Options {   /*    * 當字串字面量的重複次數大於或小於 minCount,將會被提升到頂層作用域    /   minCount?: number; }

type State = PluginPass & {   // 以 StringLiteral 節點的 value 屬性值為 key,存放所有 StringLiteral 的 Path 物件   stringPathMap?: Record; };

const HoistCommonString = declare(({ assertVersion, types }, options) => {   // 判斷當前 Babel 版本是否為 7   assertVersion(7);

return {     // 外掛名字     name: 'hoist-common-string',

visitor: {       StringLiteral(path, state: State) {         // 將所有 StringLiteral 節點對應的 path 物件收集起來,存到 state 物件裡,         // 以便於在遍歷結束時能統計相同字串的重複次數         state.stringPathMap = state.stringPathMap || {};

const nodes = state.stringPathMap[path.node.value] || [];         nodes.push(path);

state.stringPathMap[path.node.value] = nodes;       },

Program: {         // 將在遍歷過程的退出階段被呼叫         // Program 節點是頂層 AST 節點,可以認為 Program.exit 是最後一個執行的 visitor 函式         exit(path, state: State) {           // 外掛引數。還可以通過 state.opts 拿到外掛引數           const { minCount = 2 } = options || {};

for (const [string, paths] of Object.entries(state.stringPathMap || {})) {             // 對於重複次數少於 minCount 的 Path,不做處理             if (paths.length < minCount) {               continue;             }

// 基於給定的字串建立一個唯一的識別符號             const id = path.scope.generateUidIdentifier(string);

// 將所有相同的字串字面量替換為上面生成的識別符號             paths.forEach(p => {               p.replaceWith(id);             });

// 將識別符號新增到頂層作用域中             path.scope.push({ id, init: types.stringLiteral(string) });           }         },       },     },   }; }); ```

測試外掛

測試 Babel 外掛有三種常用的方法:

  • 測試轉換後的 AST 結果,檢查是否符合預期
  • 測試轉換後的程式碼字串,檢查是否符合預期(通常使用快照測試)
  • 執行轉換後的程式碼,檢查執行結果是否符合預期

我們一般使用第二種方法,配合 babel-plugin-tester 可以很好地幫助我們完成測試工作。配合 babel-plugin-tester,我們可以對比輸入輸出的字串、檔案、快照。

``` import pluginTester from 'babel-plugin-tester'; import xxxPlugin from './xxxPlugin';

pluginTester({   plugin: xxxPlugin,   fixtures: path.join(dirname, '__fixtures'),   tests: {     // 1. 對比轉換前後的字串     // 1.1 輸入輸出完全一致時,可以簡寫     'does not change code with no identifiers': '"hello";',     // 1.2 輸入輸出不一致     'changes this code': {       code: 'var hello = "hi";',       output: 'var olleh = "hi";',     },     // 2. 對比轉換前後的檔案     'using fixtures files': {       fixture: 'changed.js',       outputFixture: 'changed-output.js',     },     // 3. 與上一次生成的快照做對比     'using jest snapshots': {       code: function sayHi(person) {           return 'Hello ' + person + '!'         },       snapshot: true,     },   }, }); ```

本文將以快照測試為例,以下是測試我們外掛的示例程式碼:

``` import pluginTester from 'babel-plugin-tester'; import HoistCommonString from '../index';

pluginTester({   // 外掛   plugin: HoistCommonString,   // 外掛名,可選   pluginName: 'hoist-common-string',   // 外掛引數,可選   pluginOptions: {     minCount: 2,   },   tests: {     'using jest snapshots': {       // 輸入       code: `const s1 = "foo";       const s2 = "foo";

const s3 = "bar";

function f1() {         const s4 = "baz";         if (true) {           const s5 = "baz";         }       }`,       // 使用快照測試       snapshot: true,     },   }, }); ```

當我們執行 jest 後(更多關於 jest 的介紹,可以檢視 jest 官方文件https://jestjs.io/docs/getting-started) 會生成一個 snapshots 目錄:

圖片

有了快照以後,每次迭代外掛都可以跑一下單測以快速檢查功能是否正常。快照的更新也很簡單,只需要執行 jest --updateSnapshot

使用外掛

如果想要使用 Babel 外掛,需要在配置檔案裡新增 plugins 選項,plugins 選項接受一個數組,值為字串或者陣列。以下是一些例子:

// .babelrc {     "plugins": [         "babel-plugin-myPlugin1",         ["babel-plugin-myPlugin2"],         ["babel-plugin-myPlugin3", { /** 外掛 options */ }],         "./node_modules/asdf/plugin"     ] }

Babel 對外掛名字的格式有一定的要求,比如最好包含 babel-plugin,如果不包含的話也會自動補充。以下是 Babel 外掛名字的自動補全規則:

圖片

到這裡,Babel 外掛的學習就告一段落了,如果大家想繼續深入學習 Babel 外掛,可以訪問 Babel 的倉庫(https://github.com/babel/babel/tree/main/packages) 這是一個 monorepo,裡面包含了很多真實的外掛,通過閱讀這些外掛,相信你一定能對 Babel 外掛有更深入的理解!

參考文件

Babel plugin handbook:https://github.com/jamiebuilds/babel-handbook/blob/master/translations/en/plugin-handbook.md

Babel 官方文件:https://babeljs.io/docs/en/

Babel 外掛通關祕籍:https://juejin.cn/book/6946117847848321055

🙋 加入我們

我們來自位元組跳動飛書商業應用研發部(Lark Business Applications),目前我們在北京、深圳、上海、武漢、杭州、成都、廣州、三亞都設立了辦公區域。我們關注的產品領域主要在企業經營管理軟體上,包括飛書 OKR、飛書績效、飛書招聘、飛書人事等 HCM 領域系統,也包括飛書審批、OA、法務、財務、採購、差旅與報銷等系統。歡迎各位加入我們。

內推:歡迎掃碼投遞簡歷

圖片