如果你有babel相關知識基礎建議直接跳過 前置知識 部分,直接前往 "外掛編寫" 部分。
前置知識
什麼是AST
學習babel, 必備知識就是理解AST。
那什麼是AST呢?
先來看下維基百科的解釋:
在電腦科學中,抽象語法樹(Abstract Syntax Tree,AST),或簡稱語法樹(Syntax tree),是原始碼語法結構的一種抽象表示。它以樹狀的形式表現程式語言的語法結構,樹上的每個節點都表示原始碼中的一種結構
"原始碼語法結構的一種抽象表示
" 這幾個字要劃重點,是我們理解AST的關鍵,說人話就是按照某種約定好的規範,以樹形的資料結構把我們的程式碼描述出來,讓js引擎和轉譯器能夠理解。
舉個例子:
就好比現在框架會利用`虛擬dom`這種方式把`真實dom`結構描述出來再進行操作一樣,而對於更底層的程式碼來說,AST就是用來描述程式碼的好工具。
當然AST不是JS特有的,每個語言的程式碼都能轉換成對應的AST, 並且AST結構的規範也有很多, js裡所使用的規範大部分是 estree ,當然這個只做簡單瞭解即可。
AST到底長啥樣
瞭解了AST的基本概念, 那AST到底長啥樣呢?
astexplorer.net這個網站可以線上生成AST, 我們可以在裡面進行嘗試生成AST,用來學習一下結構
babel的處理過程
問:把冰箱塞進大象有幾個階段?
開啟冰箱 -> 塞進大象 -> 關上冰箱
babel也是如此,babel利用AST的方式對程式碼進行編譯,首先自然是需要將程式碼變為AST,再對AST進行處理,處理完以後呢再將AST 轉換回來
也就是如下的流程
code轉換為AST -> 處理AST -> AST轉換為code
然後我們再給它們一個專業一點的名字
解析 -> 轉換 -> 生成
解析(parse)
通過 parser 把原始碼轉成抽象語法樹(AST)
這個階段的主要任務就是將code轉為AST, 其中會經過兩個階段,分別是詞法分析和語法分析。
當parse階段開始時,首先會進行文件掃描,並在此期間進行詞法分析。那怎麼理解此法分析呢
如果把我們所寫的一段code比喻成句子,詞法分析所做的事情就是在拆分這個句子。
如同 “我正在吃飯”
這句話,可以被拆解為“我”
、“正在”
、“吃飯”
一樣, code也是如此。
比如:
const a = '1'
會被拆解為一個個最細粒度的單詞(tokon):
'const', 'a', '=', '1'
這就是詞法分析階段所做的事情。
詞法分析結束後,將分析所得到的 tokens 交給語法分析, 語法分析階段的任務就是根據 tokens 生成 AST。它會對 tokens 進行遍歷,最終按照特定的結構生成一個 tree 這個 tree 就是 AST。
如下圖, 可以看到上面語句的到的結構,我們找到了幾個重要資訊, 最外層是一個VariableDeclaration
意思是變數宣告,所使用的型別是 const
, 欄位declarations
內還有一個 VariableDeclarator
[變數宣告符] 物件,找到了 a
, 1
兩個關鍵字。
除了這些關鍵字以為,還可以找到例如行號等等的重要資訊,這裡就不一一展開闡述。總之,這就是我們最終得到的 AST 模樣。
那問題來了,babel裡該如何將code 轉為 AST 呢?
在這個階段我們會用到 babel 提供的解析器 @babel/parser,之前叫 Babylon,它並非由babel團隊自己開發的,而是基於fork的 acorn 專案。
它為我們提供了將code轉換為AST的方法,基本用法如下:
更多資訊可以訪問官方文件檢視@babel/parser
轉換(transform)
在 parse 階段後,我們已經成功得到了AST。 babel接收到 AST後,會使用 @babel/traverse 對其進行深度優先遍歷,外掛會在這個階段被觸發,以vistor 函式的形式訪問每種不同型別的AST節點。
以上面程式碼為例, 我們可以編寫 VariableDeclaration
函式對 VariableDeclaration
節點進行訪問,每當遇到該型別節點時都會觸發該方法。
如下:
該方法接受兩個引數,
path
path為當前訪問的路徑, 並且包含了節點的資訊、父節點資訊以及對節點操作許多方法。可以利用這些方法對 ATS 進行新增、更新、移動和刪除等等。
state
state包含了當前plugin的資訊和引數資訊等等,並且也可以用來自定義在節點之間傳遞資料。
生成(generate)
generate:把轉換後的 AST 列印成目的碼,並生成 sourcemap
這個階段就比較簡單了, 在 transform 階段處理 AST 結束後,該階段的任務就是將 AST 轉換回 code, 在此期間會對 AST 進行深度優先遍歷,根據節點所包含的資訊生成對應的程式碼,並且會生成對應的sourcemap。
經典案例嘗試
俗話說,最好的學習就是動手,我們來一起嘗試一個簡單的經典案例:
將上面案例中的 es6 的 const
轉變為 es5 的 var
第一步: 轉換為 AST
使用 @babel/parser
生成AST
比較簡單,跟上面的案例是一樣的, 此時我們ast變數中就是轉換後的 AST
const parser = require('@babel/parser');
const ast = parser.parse('const a = 1');
第二步:處理 AST
使用 @babel/traverse
處理 AST
在這個階段我們通過分析所生成的 AST 結構,確定了在 VariableDeclaration
中由 kind
欄位控制 const
,所以我們是不是可以嘗試著把 kind
改寫成我們想要的 var
?既然如此,我們來嘗試一下
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default
const ast = parser.parse('const a = 1');
traverse(ast, {
VariableDeclaration(path, state) {
// 通過 path.node 訪問實際的 AST 節點
path.node.kind = 'var'
}
});
好,此時我們憑藉著猜想修改了 kind
,將其改寫為了 var
, 但是我們還不能知道實際是否有效,所以我們需要將其再轉換回 code 看看效果。
第三步:生成 code
使用 @babel/generator
處理 AST
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default
const generate = require('@babel/generator').default
const ast = parser.parse('const a = 1');
traverse(ast, {
VariableDeclaration(path, state) {
path.node.kind = 'var'
}
});
// 將處理好的 AST 放入 generate
const transformedCode = generate(ast).code
console.log(transformedCode)
我們再來看看效果:
執行完成,成功了,是我們想要的效果~
如何開發外掛
通過上面這個經典案例, 大概瞭解了 babel 的使用,但我們平時的外掛該如何去寫呢?
實際上外掛的開發和上面的基本思路是一樣的, 只是作為外掛我們只需要關注這其中的 轉換
階段
我們的外掛需要匯出一個函式/物件, 如果是函式則需要返回一個物件, 我們只需要在改物件的 visitor 內做同樣的事情即可,並且函式會接受幾個引數, api繼承了babel提供的一系列方法, options 是我們使用外掛時所傳遞的引數,dirname 為處理時期的檔案路徑。
以上面的案例改造為如下:
module.exports = {
visitor: {
VariableDeclaration(path, state) {
path.node.kind = 'var'
}
}
}
// 或是函式形式
module.exports = (api, options, dirname) => {
return {
visitor: {
VariableDeclaration(path, state) {
path.node.kind = 'var'
}
}
}
}
外掛編寫
在有前置知識的基礎上,我們來一步步的講解開發一個 babel 外掛。
首先我們明確接下來要開發的外掛的核心需求:
- 可自動插入某個函式並呼叫。
- 自動匯入插入函式的相關依賴。
- 可以通過註釋指定需要插入的函式和需要被插入的函式,若未用註釋指定則預設插入位置在第一列。
基本效果展示如下:
處理前
// log 宣告需要被插入並被呼叫的方法
// @inject:log
function fn() {
console.log(1)
// 用 @inject:code指定插入行
// @inject:code
console.log(2)
}
處理後
// 匯入包 xxx 之後要在外掛引數內提供配置
import log from 'xxx'
function fn() {
console.log(1)
log()
console.log(2)
}
思路整理
瞭解了大概的需求,先不著急動手,我們要先想想要怎麼開始做,已經設想一下過程中需要處理的問題。
- 找到帶有 @inject 標記的函式,再檢視其內部是否有 @inject:code 的位置標記。
- 匯入所有插入函式的相應包。
- 匹配到了標記,要做的就是插入函式,同時我們還要需要處理各種情況下的函式,如:物件方法、iife、箭頭函式等等情況。
設計外掛引數
為了提升外掛的靈活度,我們需要設計一個較為合適的引數規則。
外掛引數接受一個物件。
- key 作為插入函式的函式名。
kind 表示匯入形式。 有三種匯入方式 named 、 default、 namespaced, 此設計參考 babel-helper-module-imports
- named 對應
import { a } from "b"
形式 - default 對應
import a from "b"
形式 - namespaced 對應
import * as a from "b"
形式
- named 對應
- require 為依賴的包名
比如,我需要插入 log
方法,它需要從 log4js
這個包裡匯入,並且是以 named
形式, 引數便為如下形式。
// babel.config.js
module.exports = {
plugins: [
// 填寫我們的plugin的js 檔案地址
['./babel-plugin-myplugin.js', {
log: {
// 匯入方式為 named
kind: 'named',
require: 'log4js'
}
}]
]
}
起步
好,知道了具體要做什麼事情並且設計好了引數的規則, 我們就可以開始動手了。
首先我們進入 https://astexplorer.net/ 將待處理的 code 生成 AST 方便我們梳理結構, 然後我們在進行具體編碼
首先是函式宣告語句,我們分析一下其 AST 結構以及該如何處理, 來看一下demo
// @inject:log
function fn() {
console.log('fn')
}
其生成的 AST 結構如下,可以看到有比較關鍵的兩個屬性:
- leadingComments 表示前方註釋,可以看到內部有一個元素,就是我們demo裡所寫的
@inject:log
- body 是函式體的具體內容, demo 所寫的
console.log('fn')
此時就在裡面,我們等會程式碼的插入操作就是需要操作它
好,知道了可以通過 leadingComments
來獲知函式是否需要被插入, 對 body
操作可以實現我們的程式碼插入需求。。
首先我們得先找到 FunctionDeclaration
這一層,因為只有這一層才有 leadingComments
屬性, 然後我們需要遍歷它,匹配出需要插入的函式。再將匹配到的函式插入至 body 只中, 但我們這裡需要注意可插入的body 所在層級, FunctionDeclaration
內的body 他不是一個陣列而是 BlockStatement
,這表示函式的函式體,並且它也有body , 所以我們實際操作位置就在這個BlockStatement
的 body 內
程式碼如下:
module.exports = (api, options, dirname) => {
return {
visitor: {
// 匹配函式宣告節點
FunctionDeclaration(path, state) {
// path.get('body') 相當於 path.node.body
const pathBody = path.get('body')
if(path.node.leadingComments) {
// 過濾出所有匹配 @inject:xxx 字元 的註釋
const leadingComments = path.node.leadingComments.filter(comment => /\@inject:(\w+)/.test(comment.value) )
leadingComments.forEach(comment => {
const injectTypeMatchRes = comment.value.match(/\@inject:(\w+)/)
// 匹配成功
if( injectTypeMatchRes ) {
// 匹配結果的第一個為 @inject:xxx 中的 xxx , 我們將它取出來
const injectType = injectTypeMatchRes[1]
// 獲取外掛引數的 key, 看xxx 是否在外掛的引數中宣告過
const sourceModuleList = Object.keys(options)
if( sourceModuleList.includes(injectType) ) {
// 搜尋body 內部是否有 @code:xxx 註釋
// 因為無法直接訪問到 comment,所以需要訪問 body內每個 AST 節點的 leadingComments 屬性
const codeIndex = pathBody.node.body.findIndex(block => block.leadingComments && block.leadingComments.some(comment => new RegExp(`@code:\s?${injectType}`).test(comment.value) ))
// 未宣告則預設插入位置為第一行
if( codeIndex === -1 ) {
// 操作`BlockStatement` 的 body
pathBody.node.body.unshift(api.template.statement(`${state.options[injectType].identifierName}()`)());
}else {
pathBody.node.body.splice(codeIndex, 0, api.template.statement(`${state.options[injectType].identifierName}()`)());
}
}
}
})
}
}
}
})
編寫完後我們看看結果, log
成功被插入了, 因為我們沒有使用 @code:log
所以就預設插入在了第一行
然後我們試試使用 @code:log
識別符號, 我們將 demo 的程式碼改為如下
// @inject:log
function fn() {
console.log('fn')
// @code:log
}
再次執行程式碼檢視結果, 確實是在 @code:log
位置成功插入了
處理完了我們第一個案例函式宣告,這時候可能有人會問了, 那箭頭函式這種沒有函式體的你怎麼辦,
比如:
// @inject:log
() => true
這有問題嗎? 沒有問題!
沒有函式體我們給它一個函式體就是了,怎麼做呢?
首先我們還是先學會來分析一下 AST 結構, 首先看到最外層其實是一個ExpressionStatement
表示式宣告,然後其內部才是 ArrowFunctionExpression
箭頭函式表示式, 可見跟我們之前的函式宣告生成的結構是大有不同, 其實我們不用被這麼多層結構迷了眼睛,我們只需要找對我們有用的資訊就可以了,一句話:哪一層有 leadingComments 我們就找哪一層。 這裡的 leadingComments
在 ExpressionStatement
上,所以我們找它就行
分析完了結構,那怎麼判斷是否有函式體呢?
還記得上面處理函式宣告時,我們在 body 中看到的 BlockStatement
嗎,而你看到我們箭頭函式的 body 卻是 BooleanLiteral
。 所以,我們可以判斷其 body 型別來得知是否有函式體 具體方法可以使用babel 提供的型別判斷方法 path.isBlockStatement()
來區分是否有函式體。
module.exports = (api, options, dirname) => {
return {
visitor: {
ExpressionStatement(path, state) {
// 訪問到 ArrowFunctionExpression
const expression = path.get('expression')
const pathBody = expression.get('body')
if(path.node.leadingComments) {
// 正則匹配 comment 是否有 @inject:xxx 字元
const leadingComments = path.node.leadingComments.filter(comment => /\@inject:(\w+)/.test(comment.value) )
leadingComments.forEach(comment => {
const injectTypeMatchRes = comment.value.match(/\@inject:(\w+)/)
// 匹配成功
if( injectTypeMatchRes ) {
// 匹配結果的第一個為 @inject:xxx 中的 xxx , 我們將它取出來
const injectType = injectTypeMatchRes[1]
// 獲取外掛引數的 key, 看xxx 是否在外掛的引數中宣告過
const sourceModuleList = Object.keys(options)
if( sourceModuleList.includes(injectType) ) {
// 判斷是否有函式體
if (pathBody.isBlockStatement()) {
// 搜尋body 內部是否有 @code:xxx 註釋
// 因為無法直接訪問到 comment,所以需要訪問 body內每個 AST 節點的 leadingComments 屬性
const codeIndex = pathBody.node.body.findIndex(block => block.leadingComments && block.leadingComments.some(comment => new RegExp(`@code:\s?${injectType}`).test(comment.value) ))
// 未宣告則預設插入位置為第一行
if( codeIndex === -1 ) {
pathBody.node.body.unshift(api.template.statement(`${injectType}()`)());
}else {
pathBody.node.body.splice(codeIndex, 0, api.template.statement(`${injectType}()`)());
}
}else {
// 無函式體情況
// 使用 ast 提供的 `@babel/template` api , 用程式碼段生成 ast
const ast = api.template.statement(`{${injectType}();return BODY;}`)({BODY: pathBody.node});
// 替換原本的body
pathBody.replaceWith(ast);
}
}
}
})
}
}
}
}
}
可以看到除了新增的函式體判斷,生成函式體插入程式碼再用新的 AST 替換原本的節點,除掉這些之外,大體上的邏輯跟之前的函式宣告的處理過程沒有區別。
生成 AST 所使用的 @babel/template
的 API 相關用法可以檢視文件 @babel/template
針對不同情況的下的函式大體上相同,總結就是:
分析 AST 找到 leadingComments
所在節點 -> 找到可插入的 body 所在節點 -> 編寫插入邏輯
實際處理的情況還有很多,如:物件屬性、iife、函式表示式等很多, 處理思路都是一樣的,這裡就不過重複闡述。我會將外掛完整程式碼發在文章底部。
自動引入
第一條完成了,那需求的第二條,我們使用的包如何自動引入呢, 如上面案例使用的 log4js
, 那麼我們處理後的程式碼就應該自動加上:
import { log } from 'log4js'
此時,我們可以思考一下,我們需要處理以下兩種情況
- log 已經被匯入過了
- log 變數名已經被佔用
針對 問題1 我們需要先檢索一下是否有匯入過 log4js
,並且以 named
的形式匯入了 log
針對 問題2 我們需要給 log
一個唯一的別名, 並且要保證在後續的程式碼插入中也使用這個別名。所以這就要求了我們要在檔案的一開始就處理完成自動引入的邏輯。
有了大概的思路,但是我們如何提前完成自動引入邏輯呢。抱著疑問,我們再來看看 AST 的結構。
可以看到 AST 最外層是 File
節點, 他有一個 comments
屬性,它包含了當前檔案裡所有的註釋,有了這個我們就可以解析出檔案裡需要插入的函式,並提前進行引入。 我們再往下看, 內部是一個 Program
, 我們將首先訪問它, 因為它會在其他型別的節點之前被呼叫,所以我們要在此階段實現自動引入邏輯。
小知識:babel 提供了 path.traverse 方法,可以用來同步訪問處理當前節點下的子節點。
如圖:
程式碼如下:
const importModule = require('@babel/helper-module-imports');
// ......
{
visitor: {
Program(path, state) {
// 拷貝一份options 掛在 state 上, 原本的 options 不能操作
state.options = JSON.parse(JSON.stringify(options))
path.traverse({
// 首先訪問原有的 import 節點, 檢測 log 是否已經被匯入過
ImportDeclaration (curPath) {
const requirePath = curPath.get('source').node.value;
// 遍歷options
Object.keys(state.options).forEach(key => {
const option = state.options[key]
// 判斷包相同
if( option.require === requirePath ) {
const specifiers = curPath.get('specifiers')
specifiers.forEach(specifier => {
// 如果是預設type匯入
if( option.kind === 'default' ) {
// 判斷匯入型別
if( specifier.isImportDefaultSpecifier() ) {
// 找到已有 default 型別的引入
if( specifier.node.imported.name === key ) {
// 掛到 identifierName 以供後續呼叫獲取
option.identifierName = specifier.get('local').toString()
}
}
}
// 如果是 named 形式的匯入
if( option.kind === 'named' ) {
//
if( specifier.isImportSpecifier() ) {
// 找到已有 default 型別的引入
if( specifier.node.imported.name === key ) {
option.identifierName = specifier.get('local').toString()
}
}
}
})
}
})
}
});
// 處理未被引入的包
Object.keys(state.options).forEach(key => {
const option = state.options[key]
// 需要require 並且未找到 identifierName 欄位
if( option.require && !option.identifierName ) {
// default形式
if( option.kind === 'default' ) {
// 增加 default 匯入
// 生成一個隨機變數名, 大致上是這樣 _log2
option.identifierName = importModule.addDefault(path, option.require, {
nameHint: path.scope.generateUid(key)
}).name;
}
// named形式
if( option.kind === 'named' ) {
option.identifierName = importModule.addNamed(path, key, option.require, {
nameHint: path.scope.generateUid(key)
}).name
}
}
// 如果沒有傳遞 require 會認為是全域性方法,不做匯入處理
if( !option.require ) {
option.identifierName = key
}
})
}
}
}
Program
節點內我們先將接收到的外掛配置 options
拷貝了一份,掛到了 state
上, 之前有說過 state
可以用作 AST 節點之間的資料傳遞,然後我們首先訪問 Program
下的 ImportDeclaration
也就是 import
語句, 看看 log4js
是否有被匯入過, 若引入過便會記錄到 identifierName
欄位上,完成對 import
語句的訪問後,我們就可根據 identifierName
欄位判斷是否已被引入,若未引入則使用 @babel/helper-module-imports 建立 import
,並用 babel 提供的 generateUid
方法建立唯一的變數名。
這樣在之前的程式碼我們也需要略微調整, 不能直接使用從註釋 @inject:xxx
提取出的方法名,
而是應該使用 identifierName
, 關鍵部分程式碼修改如下:
if( sourceModuleList.includes(injectType) ) {
// 判斷是否有函式體
if (pathBody.isBlockStatement()) {
// 搜尋body 內部是否有 @code:xxx 註釋
// 因為無法直接訪問到 comment,所以需要訪問 body內每個 AST 節點的 leadingComments 屬性
const codeIndex = pathBody.node.body.findIndex(block => block.leadingComments && block.leadingComments.some(comment => new RegExp(`@code:\s?${injectType}`).test(comment.value) ))
// 未宣告則預設插入位置為第一行
if( codeIndex === -1 ) {
// 使用 identifierName
pathBody.node.body.unshift(api.template.statement(`${state.options[injectType].identifierName}()`)());
}else {
// 使用 identifierName
pathBody.node.body.splice(codeIndex, 0, api.template.statement(`${state.options[injectType].identifierName}()`)());
}
}else {
// 無函式體情況
// 使用 ast 提供的 `@babel/template` api , 用程式碼段生成 ast
// 使用 identifierName
const ast = api.template.statement(`{${state.options[injectType].identifierName}();return BODY;}`)({BODY: pathBody.node});
// 替換原本的body
pathBody.replaceWith(ast);
}
}
最終效果如下:
我們實現了函式自動插入並自動引入依賴包。
結尾
本篇文章是對自己學習 “Babel 外掛通關祕籍” 小冊子後的一個記錄總結,我開始和大部分想寫babel外掛卻無從下手的同學一樣,所以這篇文章主要也是按自己寫外掛時摸索的思路去寫。希望也是能給大家提供一個思路。
完整版已支援 自定義程式碼片段
的插入,完整程式碼已上傳至 github,同時也釋出至了 npm。
歡迎大家 star 和 issue。
給 star 是人情,不給是事故,哈哈。