Babel 插件:30 分鐘從入門到實戰

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

將源碼轉換成抽象語法樹(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

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

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

你會用到的一些工具庫

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

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

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

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

認識 Babel 插件

接下來讓我們開始認識 Babel 插件吧。

babel 插件是一個簡單的函數,它必須返回一個匹配以下接口的對象。如果 Babel 發現未知屬性,它將拋出錯誤。

以下是一個簡單的插件示例:

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

Babel 插件接受 3 個參數:

返回的對象有 name、manipulateOptions、pre、visitor、post、inherits 等屬性:

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

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

當 visitor.StringLiteral 是一個函數時,它將在向下遍歷的過程中被調用(即進入階段)。當 visitor.StringLiteral 是一個對象時({ enter(path, state) {}, exit(path, state) {} }),visitor.StringLiteral.enter 將在向下遍歷的過程中被調用(進入階段),visitor.StringLiteral.exit 將在向上遍歷的過程中被調用(退出階段)。

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

Path 之間的關係如圖所示:

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

在實際編寫插件的過程中,某一類型節點的處理可能需要依賴其他類型節點的處理結果,但由於 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/

AST Explorer

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

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

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

自己實現一個插件吧

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

  1. 將代碼裏重複的字符串字面量 (StringLiteral) 提升到頂層作用域。

  2. 接受一個參數 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<string, NodePath[]>;
};

const HoistCommonString = declare<Options>(({ 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 插件有三種常用的方法:

我們一般使用第二種方法,配合 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

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Nf4nnbhUDhf8V2sTkm85Xw