1. 程式人生 > >手把手教你開發一個babel-plugin

手把手教你開發一個babel-plugin

需求

在最近的開發過程中,不同的專案、不同的頁面都需要用到某種UI控制元件,於是很自然的將這些UI控制元件拆出來,單獨建立了一個程式碼庫進行維護。下面是我的元件庫大致的目錄結構如下:

...
- lib
    - components
        - componentA
            - index.vue
        - componentB
            - index.vue
        - componentC
            - index.vue
- index.js
...

整個元件庫的出口在index.js,裡面的內容差不多是下面這樣的:

import A from './lib/componentA';
import B from './lib/componentB';
import C from './lib/componentC';

export {
    A,
    B,
    C
}

我的程式碼庫的name為:kb-bi-vue-component。在專案中引用這個元件庫的時候,程式碼如下:

import { A, B } from 'kb-bi-vue-component';
....

這個時候,問題出現了,我在頁面中,僅僅使用了AB兩個元件,但是頁面打包後,整個元件庫的程式碼都會被打進來,增加了產出的體積,包括了不少的冗餘程式碼。很容易想到的一個解決方案是按照以下的方式引用元件。

import A from 'kb-bi-vue-component/lib/componentA';
import B from 'kb-bi-vue-component/lib/componentB';

這種方法雖然解決了問題,我想引用哪個元件,就引用哪個元件,不會有多餘的程式碼。但是我總覺得這種寫法看起來不太舒服。有沒有還能像第一種寫法一樣引用元件庫,並且只引用需要的元件呢?寫一個babel-plugin好了,自動將第一種寫法轉換成第二種寫法。

Babel的原理

Babel是Javascript編譯器,更確切地說是原始碼到原始碼的編譯器,通常也叫做『轉換編譯器』。也就是說,你給Babel提供一些Javascript程式碼,Babel更改這下程式碼,然後返回給你新生成的程式碼。

AST

在這整個過程中,都是圍繞著抽象語法樹(AST)來進行的。在Javascritp中,AST,簡單來說,就是一個記錄著程式碼語法結構的Object。比如下面的程式碼:

function square(n) {
  return n * n;
}

轉換成AST後如下,

{
  type: "FunctionDeclaration",
  id: {
    type: "Identifier",
    name: "square"
  },
  params: [{
    type: "Identifier",
    name: "n"
  }],
  body: {
    type: "BlockStatement",
    body: [{
      type: "ReturnStatement",
      argument: {
        type: "BinaryExpression",
        operator: "*",
        left: {
          type: "Identifier",
          name: "n"
        },
        right: {
          type: "Identifier",
          name: "n"
        }
      }
    }]
  }
}

AST是分層的,由一個一個的 節點(Node) 組成。如:

{
  ...
  type: "FunctionDeclaration",
  id: {
    type: "Identifier",
    name: "square"
  },
  ...
}
{
  type: "Identifier",
  name: ...
}

每一個節點都有一個必需的 type 欄位表示節點的型別。如上面的FunctionDeclaration

Identifier等等。每種型別的節點都會有自己的屬性。

Babel的工作過程

Babel的處理過程主要為3個:解析(parse)轉換(transform)生成(generate)

  • 解析

    解析主要包含兩個過程:詞法分析和語法分析,輸入是程式碼字串,輸出是AST。

  • 轉換

    處理AST。處理工具、外掛等就是在這個過程中介入,將程式碼按照需求進行轉換。

  • 生成

    遍歷AST,輸出程式碼字串。

解析和生成過程,都有Babel都為我們處理得很好了,我們要做的就是在 轉換 過程中搞事情,進行個性化的定製開發。

開發一個babel-plugin

開發方式概述

首先,需要大致瞭解一下babel-plugin的開發方法。

babel使用一種 訪問者模式 來遍歷整棵語法樹,即遍歷進入到每一個Node節點時,可以說我們在「訪問」這個節點。訪問者就是一個物件,定義了在一個樹狀結構中獲取具體節點的方法。簡單來說,我們可以在訪問者中,使用Node的type來定義一個hook函式,每一次遍歷到對應type的Node時,hook函式就會被觸發,我們可以在這個hook函式中,修改、檢視、替換、刪除這個節點。說起來很抽象,直接看下面的內容吧。

開始開發吧

  • 下面,根據我們的需求,來開發一個plugin。怎麼配置使用自己的babel-plugin呢?我的專案中,是使用.babelrc來配置babel的,如下:
{
    "presets": [
        ["es2015"],
        ["stage-0"]
    ]
}

上面的配置中,只有兩個預設,並沒有使用外掛。首先加上外掛的配置。由於是在本地開發,外掛直接寫的本地的相對地址。

{
    "presets": [
        ["es2015"],
        ["stage-0"]
    ],
    "plugins":["./my-import-babel-plugin"]
}

僅僅像上面這樣是有問題的,因為需求是需要針對具體的library,所以肯定是需要傳入引數的。改成下面這樣:

{
    "presets": [
        ["es2015"],
        ["stage-0"]
    ],
    "plugins":[
        ["./my-import-babel-plugin", { "libraryName": "kb-bi-vue-component", "alias": "kb-bi-vue-component/lib/components"}]
    ]
}

我們給plugin傳了一個引數,libraryName表示需要處理的library,alias表示元件在元件庫內部的路徑。

  • 下面是外掛的程式碼./my-import-babel-plugin.js
module.exports = function ({ types: t }) {
    return {
        visitor: {
            ImportDeclaration(path, source){
                const { opts: { libraryName, alias } } = source;
                if (!t.isStringLiteral(path.node.source, { value: libraryName })) {
                    return;
                }
                console.log(path.node);
                // todo
            }
        }    
    }
}

函式的引數為babel物件,物件中的types是一個用於 AST 節點的 Lodash 式工具庫,它包含了構造、驗證以及變換 AST 節點的方法。 該工具庫包含考慮周到的工具方法,對編寫處理AST邏輯非常有用。我們單獨把這個types拿出來。返回的visitor就是我們上文提到的訪問者物件。這次的需求是對 import 語句的修改,所以我們在visitor中定義了import的type:ImportDeclaration。這樣,當babel處理到程式碼裡的import語句時,就會走到這個ImportDeclaration函式裡面來。

ImportDeclaration接受兩個引數,

  1. path表示當前訪問的路徑,path.node就能取到當前訪問的Node.
  2. source表示PluginPass,即傳遞給當前plugin的其他資訊,包括當前編譯的檔案、程式碼字串以及我們在.babelrc中傳入的引數等。

在外掛的程式碼中,我們首先取到了傳入外掛的引數。接著,判斷如果不是我們需要處理的library,就直接返回了

  • 假設我們的業務程式碼中的程式碼如下:
...
import { A, B } from 'kb-bi-vue-component'
...

我們執行一下打包工具,輸出一下path.node,可以看到,當前訪問的Node如下:

Node {
    type: 'ImportDeclaration',
    start: 9,
    end: 51,
    loc: SourceLocation {
        start: Position {
            line: 10,
            column: 0
        },
        end: Position {
            line: 10,
            column: 42
        }
    },
    specifiers: [Node {
            type: 'ImportSpecifier',
            start: 18,
            end: 19,
            loc: [Object],
            imported: [Object],
            local: [Object]
        },
        Node {
            type: 'ImportSpecifier',
            start: 21,
            end: 22,
            loc: [Object],
            imported: [Object],
            local: [Object]
        }
    ],
    source: Node {
        type: 'StringLiteral',
        start: 30,
        end: 51,
        loc: SourceLocation {
            start: [Object],
            end: [Object]
        },
        extra: {
            rawValue: 'kb-bi-vue-component',
            raw: '\'kb-bi-vue-component\''
        },
        value: 'kb-bi-vue-component'
    }
}

稍微解釋一下這個Node. specifiers是一個數組,包含兩個Node,對應的是程式碼import後面的兩個引數AB。這兩個Node的local值都是Identifier型別的Node。source表示的是程式碼from後面的library。

  • 接下來,按照需求把這個ImportDeclaration型別的Node替換掉,換成我們想要的。使用path.replaceWithMultiple這個方法來替換一個Node。此方法接受一個Node陣列。所以我們首先需要構造出Node,裝進一個數組裡,然後扔給這個path.replaceWithMultiple方法。

    查閱文件,

    t.importDeclaration(specifiers, source)
    
    specifiers: Array<ImportSpecifier | ImportDefaultSpecifier | ImportNamespaceSpecifier> (required)
    source: StringLiteral (required)

    可以通過t.importDeclaration來構造importNode,引數如上所示。構造importNode,需要先構造其引數需要的Node。最終,修改外掛的程式碼如下:

    module.exports = function ({ types: t }) {
        return {
            visitor: {
                ImportDeclaration(path, source) {
                    const { opts: { libraryName, alias } } = source;
                    if (!t.isStringLiteral(path.node.source, { value: libraryName })) {
                        return;
                    }
                    const newImports = path.node.specifiers.map( item => {
                        return t.importDeclaration([t.importDefaultSpecifier(item.local)], t.stringLiteral(`${alias}/${item.local.name}`))
                    });
                    path.replaceWithMultiple(newImports);
                }
            }
        }
    }

    開發基本結束

    好了,一個babel-plugin開發完成了。我們成功的實現了以下的編譯:

    import { A, B } from 'kb-bi-vue-component';
    
    ↓ ↓ ↓ ↓ ↓ ↓
    
    import A from 'kb-bi-vue-component/lib/components/A';
    import B from 'kb-bi-vue-component/lib/components/B';

    babel在工作時,會優先執行.babelrc中的plugins,接著才會執行presets。我們優先將原始碼進行了轉換,再使用babel去轉換為es5的程式碼,整個過程是沒有問題的。

    當然,這是最簡單的babel-plugin,還有很多其他情況沒有處理,比如下面這種,轉換後就不符合預期。

    import { A as aaa, B } from 'kb-bi-vue-component';
    
    ↓ ↓ ↓ ↓ ↓ ↓
    
    import aaa from 'kb-bi-vue-component/lib/components/aaa';
    import B from 'kb-bi-vue-component/lib/components/B';

    要完成一個高質量的babel-plugin,還有很多的工作要做。

    參考連結: