手把手教你開發一個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';
....
這個時候,問題出現了,我在頁面中,僅僅使用了A
、B
兩個元件,但是頁面打包後,整個元件庫的程式碼都會被打進來,增加了產出的體積,包括了不少的冗餘程式碼。很容易想到的一個解決方案是按照以下的方式引用元件。
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
接受兩個引數,
-
path
表示當前訪問的路徑,path.node
就能取到當前訪問的Node. -
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
後面的兩個引數A
和B
。這兩個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
來構造import
Node,引數如上所示。構造import
Node,需要先構造其引數需要的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,還有很多的工作要做。
參考連結: