保姆級教學!這次一定學會開發babel外掛!

a磊的前端夢發表於2021-09-28

如果你有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 兩個關鍵字。
image.png

除了這些關鍵字以為,還可以找到例如行號等等的重要資訊,這裡就不一一展開闡述。總之,這就是我們最終得到的 AST 模樣。

那問題來了,babel裡該如何將code 轉為 AST 呢?
在這個階段我們會用到 babel 提供的解析器 @babel/parser,之前叫 Babylon,它並非由babel團隊自己開發的,而是基於fork的 acorn 專案。

它為我們提供了將code轉換為AST的方法,基本用法如下:

image.png

更多資訊可以訪問官方文件檢視@babel/parser

轉換(transform)

在 parse 階段後,我們已經成功得到了AST。 babel接收到 AST後,會使用 @babel/traverse 對其進行深度優先遍歷,外掛會在這個階段被觸發,以vistor 函式的形式訪問每種不同型別的AST節點。
以上面程式碼為例, 我們可以編寫 VariableDeclaration 函式對 VariableDeclaration節點進行訪問,每當遇到該型別節點時都會觸發該方法。
如下:

image.png

該方法接受兩個引數,

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 ?既然如此,我們來嘗試一下

image.png

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)

我們再來看看效果:

image.png

執行完成,成功了,是我們想要的效果~

如何開發外掛

通過上面這個經典案例, 大概瞭解了 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)
}

思路整理

瞭解了大概的需求,先不著急動手,我們要先想想要怎麼開始做,已經設想一下過程中需要處理的問題。

  1. 找到帶有 @inject 標記的函式,再檢視其內部是否有 @inject:code 的位置標記。
  2. 匯入所有插入函式的相應包。
  3. 匹配到了標記,要做的就是插入函式,同時我們還要需要處理各種情況下的函式,如:物件方法、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" 形式
  • 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') 此時就在裡面,我們等會程式碼的插入操作就是需要操作它

image.png

好,知道了可以通過 leadingComments 來獲知函式是否需要被插入, 對 body 操作可以實現我們的程式碼插入需求。。

首先我們得先找到 FunctionDeclaration 這一層,因為只有這一層才有 leadingComments 屬性, 然後我們需要遍歷它,匹配出需要插入的函式。再將匹配到的函式插入至 body 只中, 但我們這裡需要注意可插入的body 所在層級, FunctionDeclaration 內的body 他不是一個陣列而是 BlockStatement,這表示函式的函式體,並且它也有body , 所以我們實際操作位置就在這個BlockStatement 的 body 內

image.png

程式碼如下:

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所以就預設插入在了第一行
image.png

然後我們試試使用 @code:log 識別符號, 我們將 demo 的程式碼改為如下

// @inject:log
function fn() {
    console.log('fn')
    // @code:log
}

再次執行程式碼檢視結果, 確實是在 @code:log 位置成功插入了
image.png

處理完了我們第一個案例函式宣告,這時候可能有人會問了, 那箭頭函式這種沒有函式體的你怎麼辦,
比如:

// @inject:log
() => true

這有問題嗎? 沒有問題!

image.png

沒有函式體我們給它一個函式體就是了,怎麼做呢?

首先我們還是先學會來分析一下 AST 結構, 首先看到最外層其實是一個ExpressionStatement表示式宣告,然後其內部才是 ArrowFunctionExpression箭頭函式表示式, 可見跟我們之前的函式宣告生成的結構是大有不同, 其實我們不用被這麼多層結構迷了眼睛,我們只需要找對我們有用的資訊就可以了,一句話:哪一層有 leadingComments 我們就找哪一層。 這裡的 leadingCommentsExpressionStatement 上,所以我們找它就行

image.png

分析完了結構,那怎麼判斷是否有函式體呢?
還記得上面處理函式宣告時,我們在 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'

此時,我們可以思考一下,我們需要處理以下兩種情況

  1. log 已經被匯入過了
  2. log 變數名已經被佔用

針對 問題1 我們需要先檢索一下是否有匯入過 log4js ,並且以 named 的形式匯入了 log
針對 問題2 我們需要給 log 一個唯一的別名, 並且要保證在後續的程式碼插入中也使用這個別名。所以這就要求了我們要在檔案的一開始就處理完成自動引入的邏輯。

有了大概的思路,但是我們如何提前完成自動引入邏輯呢。抱著疑問,我們再來看看 AST 的結構。
可以看到 AST 最外層是 File 節點, 他有一個 comments 屬性,它包含了當前檔案裡所有的註釋,有了這個我們就可以解析出檔案裡需要插入的函式,並提前進行引入。 我們再往下看, 內部是一個 Program, 我們將首先訪問它, 因為它會在其他型別的節點之前被呼叫,所以我們要在此階段實現自動引入邏輯。

小知識:babel 提供了 path.traverse 方法,可以用來同步訪問處理當前節點下的子節點。

如圖:
image.png

程式碼如下:

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);
  }
}

最終效果如下:
image.png

我們實現了函式自動插入並自動引入依賴包。

結尾

本篇文章是對自己學習 “Babel 外掛通關祕籍” 小冊子後的一個記錄總結,我開始和大部分想寫babel外掛卻無從下手的同學一樣,所以這篇文章主要也是按自己寫外掛時摸索的思路去寫。希望也是能給大家提供一個思路。

完整版已支援 自定義程式碼片段 的插入,完整程式碼已上傳至 github,同時也釋出至了 npm
歡迎大家 star 和 issue。

給 star 是人情,不給是事故,哈哈。

image.png

相關文章