如何實現一個 Babel macros

本文首發於 Ahonn's Blog: 如何實現一個 Babel macros

通過 babel 插件,我們很容易的就在編譯時將某些代碼轉換成其他代碼以實現某些優化。例如 babel-plugin-lodash 可以幫我們將直接 import 的 lodash 替換成能夠進行 tree shaking 的代碼;通過 babel-plugin-preval 在編譯時執行腳本並使用返回值原位替換。

一切看起來都很美好,但實際上在使用 babel 插件時我們還需要對 .babelrc 或者 babel.config.js 進行配置。

{
  "plugins": ["preval"]
}

在暴露 babel 配置文件的項目下或許還能夠接受,但在 create-react-app 下就不得不破壞原來的和諧, eject 一下配置再進行相關的配置了。

有沒有什麼更好的方式呢?有的,我們可以用 babel-plugin-macros

babel-plugin-macros 是什麼?

babel-plugin-macros 顯而易見是一個 babel 插件,它提供了一種零配置編譯時替換代碼的方式。我們只需要在 babel 配置裏添加 babel-plugin-macros 插件配置就可以使用了。顯然這個 “零配置” 是把自身除外的。但別擔心,create-react-app 已經內置了這個插件,可以開箱即用。

{
  "plugins": ["macros"]
}

然後就可以開始真正的零配置體驗,引入我們需要的 macro 直接使用。

// 編譯前
import preval from 'preval.macro';
const one = preval`module.exports = 1 + 2 - 1 - 1`;

// 編譯後
const one = 3;

與 babel-plugin-preval 相比,我們不在需要再進行額外的配置,而是通過 import macro 來使用對應的功能。babel 在編譯期會讀取以 .macro 結尾的包,並執行對應的邏輯來替換代碼,這種方式比插件來的更加直觀,我們再也不會出現 “這個 preval 是哪裏引進來?” 的疑問了。

那麼怎麼實現一個 babel macros 呢?

實現一個 Babel macros

假設我們有這麼一個場景:我們的項目中包括前後端的代碼,後端的 Node.js 通過 dotenv 讀取項目根目錄下的 .env 獲取某些配置,現在我們有一些前端 JavaScript 代碼也需要使用到 .env 裏到某些配置,但不能把所有的配置都暴露到 JavaScript 中。

一般情況下,我們可以將 .env 中的某些配置傳入 webpack 的 DefinePlugin 插件中,前端代碼通過讀取全局變量的方式進行訪問。現在我們通過 Babel macros 的方式來實現如下效果:

# .env
NAME=ahonn
NUMBER=123
// 編譯前
import dotenv from 'dotenv.macro';

const NAME = dotenv('NAME');
const NUMBER = dotenv('NUMBER');

// 編譯後
const NAME = "ahonn";
const NUMBER = "123";

創建 Macro

babel-plugin-macros 會把引入的 .macro 或者 .macro.js 當成宏進行處理,所有首先我們需要創建一個名爲 dotenv.macro.js 的文件,並且這個文件導出的應該是一個通過 createMacro 包裝後的函數。

如果沒有通過 createMacro 進行包裝的話,執行 babel 就會提示:The macro imported from "../../dotenv.macro" must be wrapped in "createMacro" which you can get from "babel-plugin-macros".

const { createMacro } = require('babel-plugin-macros');

module.exports = createMacro(({ references, state, babel }) => {
  // TODO
});

傳入 createMacro 的函數接受三個參數:

  • references: 編譯的代碼中對該宏的引用
  • state: 編譯狀態信息
  • babel: babel-core 對象,與 require(‘@babel/core’) 相同

在我們的例子中 references 的值是 { default: [ NodePath {...} ] },這裏的 default 中的 NodePath 即是上面編譯前代碼中 dotenv 調用在 AST 中的節點。
(如果對 AST 或者 babel 插件開發不太熟悉的話,推薦閱讀 babel-handbook/plugin-handbook.md

判斷調用形式

拿到對應的 AST 節點(後面稱爲 path)之後,我們需要對調用形式進行判斷來確定如何轉換代碼,這裏我們通過判斷 path.parentPath 的節點類型來判斷。

我們可以通過傳入 createMacro 的函數的第三個參數 babel 來獲取一些用於判斷節點類型的函數,babel.types 等價於 @babel/types

  • 通過 babel.types.isCallExpression 來判斷是否爲函數形式調用
  • 通過 babel.types.isTaggedTemplateExpression 來判斷是否爲模版字符串形式調用

我們只對函數形式調用處理:

const { createMacro } = require('babel-plugin-macros');

module.exports = createMacro(({ references, state, babel }) => {
  references.default.forEach((path) => {
    if (path.parentPath && babel.types.isCallExpression(path.parentPath)) {
      // TODO
    }
  });
});

獲取目標值

做完前置的條件判斷之後,現在我們就可以通過 dotenv 來獲取 .env 中配置的值,然後將對應的值替換對應的 AST 節點,從而使得編譯後的代碼在 macro 引用位置被替換爲目標值。

const dotenv = require('dotenv');
const { createMacro } = require('babel-plugin-macros');

module.exports = createMacro(({ references, state, babel }) => {
  const env = dotenv.config();

  references.default.forEach((path) => {
    if (path.parentPath && babel.types.isCallExpression(path.parentPath)) {
      const args = path.parentPath.get('arguments'); 
      const key = args[0].evaluate().value;
      const value = env.parsed[key]; // ahonn
    }
  });
});

我們通過 path.parentPath.get('arguments') 獲取到父節點(即節點類型爲 CallExpression 的節點)中的 arguments 屬性(即函數調用參數列表)。然後通過 args[0].evaluate().value 來獲取第一個參數的值,即爲 dotenv('NAME') 中的 'NAME'。最後從 dotenv 解析的 env 對象中獲取目標值 'ahonn'

AST 節點替換

最後一步,我們需要判斷上一步獲取的目標值的類型,然後根據不同的類型進行 AST 轉換。以我們上面的例子來說就是:

  • const NAME = dotenv('NAME'); 轉換爲 const NAME = 'ahonn';
  • const NUMBER = dotenv('NUMBER'); 轉換爲 const NUMBER = 123;
const dotenv = require('dotenv');
const { createMacro } = require('babel-plugin-macros');

module.exports = createMacro(({ references, state, babel }) => {
  const env = dotenv.config();

  references.default.forEach((path) => {
    if (path.parentPath && babel.types.isCallExpression(path.parentPath)) {
      const args = path.parentPath.get('arguments');
      const key = args[0].evaluate().value;
      const value = env.parsed[key];

      if (typeof value === 'number') {
        path.parentPath.replaceWith(babel.types.numericLiteral(value));
      } else {
        path.parentPath.replaceWith(babel.types.stringLiteral(value));
      }
    }
  });
});

通過 typeof value 判斷目標值的類型,這裏只處理數字與字符串,非數字的值都當成字符串處理。然後再一次的通過 babel.types 中提供的 numericLiteralstringLiteral 來創建對應的 AST 節點。最後將 path.parentParh 替換爲生產的節點。

到這裏,一個讀取 .env 中對應的值並在編譯時替換相應的代碼的 macro 就完成了。上面我們提到的 preval.macro 的實現也與上面類似。

Q&A

  • 爲什麼是替換掉 path.parentPath ?

A: 因爲我們拿到的 references 中的引用只是對應的宏的 AST 節點,而一般 Babel macros 中我們通過函數調用或者模版字符串形式進行調用,因此需要往上一層進行替換。

  • 可以通過 Babel macros 拓展 JavaScript 語法麼?

不行,因爲 Babel 只能夠識別合法的 JavaScript 語法,即使使用 babel-plugin-macros 也無法改變這一事實。如果想要拓展 JavaScript 語法的話需要修改 babel-parser。具體怎麼做,可以查看這篇文章:Creating custom JavaScript syntax with Babel | Tan Li Hau

總結

看到這裏,可以發現實現一個 Babel macros 的過程與開發 Babel 插件的流程類似,都是對 AST 進行操作。babel-plugin-macro 只是提供一個在“外部”進行 AST 修改的方式,通過這種方式能夠靈活的對 Babel 編譯時進行拓展。但話又說回來,這種方式用多了會不會令代碼變得不好維護呢?歡迎留言討論。

參考

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章