本文首發於 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
中提供的 numericLiteral
與 stringLiteral
來創建對應的 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 編譯時進行拓展。但話又說回來,這種方式用多了會不會令代碼變得不好維護呢?歡迎留言討論。