前言
既然標題說了是深入Babel,那我們就不說Babel各種用法了,什麼babel-core,babel-runtime,babel-loader……如果你想了解這一部分內容,這類文章很多,推薦最近看到的一篇:一口(很長的)氣了解 babel,可以說是相當詳實完備了。
言歸正傳,這篇文章主要是去了解一下Babel是怎麼工作的,Babel外掛是怎麼工作的,以及怎麼去寫Babel外掛,相信你看完之後一定會有一些收穫。
那我們開始吧!
抽象語法樹(AST)
要了解Babel的工作原理,那首先需要了解抽象語法樹,因為Babel外掛就是作用於抽象語法樹。首先我們編寫的程式碼在編譯階段解析成抽象語法樹(AST),然後經過一系列的遍歷和轉換,然後再將轉換後的抽象語法樹生成為常規的js程式碼。下面這幅圖(來源)可以表示Babel的工作流程:
我們先說AST,程式碼解析成AST的目的就是方便計算機更好地理解我們的程式碼。這裡我們先寫一段程式碼:function add(x, y) {
return x + y;
}
add(1, 2);
複製程式碼
然後將程式碼解析成抽象語法樹(線上工具),表示成JSON形式如下:
{
"type": "Program",
"start": 0,
"end": 52,
"body": [
{
"type": "FunctionDeclaration",
"start": 0,
"end": 40,
"id": {
"type": "Identifier",
"start": 9,
"end": 12,
"name": "add"
},
"expression": false,
"generator": false,
"params": [
{
"type": "Identifier",
"start": 13,
"end": 14,
"name": "x"
},
{
"type": "Identifier",
"start": 16,
"end": 17,
"name": "y"
}
],
"body": {
"type": "BlockStatement",
"start": 19,
"end": 40,
"body": [
{
"type": "ReturnStatement",
"start": 25,
"end": 38,
"argument": {
"type": "BinaryExpression",
"start": 32,
"end": 37,
"left": {
"type": "Identifier",
"start": 32,
"end": 33,
"name": "x"
},
"operator": "+",
"right": {
"type": "Identifier",
"start": 36,
"end": 37,
"name": "y"
}
}
}
]
}
},
{
"type": "ExpressionStatement",
"start": 42,
"end": 52,
"expression": {
"type": "CallExpression",
"start": 42,
"end": 51,
"callee": {
"type": "Identifier",
"start": 42,
"end": 45,
"name": "add"
},
"arguments": [
{
"type": "Literal",
"start": 46,
"end": 47,
"value": 1,
"raw": "1"
},
{
"type": "Literal",
"start": 49,
"end": 50,
"value": 2,
"raw": "2"
}
]
}
}
],
"sourceType": "module"
}
複製程式碼
這裡你會發現抽象語法樹中不同層級有著相似的結構,比如:
{
"type": "Program",
"start": 0,
"end": 52,
"body": [...]
}
複製程式碼
{
"type": "FunctionDeclaration",
"start": 0,
"end": 40,
"id": {...},
"body": {...}
}
複製程式碼
{
"type": "BlockStatement",
"start": 19,
"end": 40,
"body": [...]
}
複製程式碼
像這樣的結構叫做節點(Node)。一個AST是由多個或單個這樣的節點組成,節點內部可以有多個這樣的子節點,構成一顆語法樹,這樣就可以描述用於靜態分析的程式語法。
節點中的type欄位表示節點的型別,比如上述AST中的"Program"、"FunctionDeclaration"、"ExpressionStatement"等等,當然每種節點型別會有一些附加的屬性用於進一步描述該節點型別。
Babel的工作流程
上面那幅圖已經描述了Babel的工作流程,下面我們再詳細描述一下。Babel 的三個主要處理步驟分別是: 解析(parse),轉換(transform),生成(generate)。
-
解析
將程式碼解析成抽象語法樹(AST),每個js引擎(比如Chrome瀏覽器中的V8引擎)都有自己的AST解析器,而Babel是通過Babylon實現的。在解析過程中有兩個階段:詞法分析和語法分析,詞法分析階段把字串形式的程式碼轉換為令牌(tokens)流,令牌類似於AST中節點;而語法分析階段則會把一個令牌流轉換成 AST的形式,同時這個階段會把令牌中的資訊轉換成AST的表述結構。
-
轉換
在這個階段,Babel接受得到AST並通過babel-traverse對其進行深度優先遍歷,在此過程中對節點進行新增、更新及移除操作。這部分也是Babel外掛介入工作的部分。
-
生成
將經過轉換的AST通過babel-generator再轉換成js程式碼,過程就是深度優先遍歷整個AST,然後構建可以表示轉換後程式碼的字串。
這部分更詳細的可以檢視Babel手冊。而值得注意的是,babel的外掛有兩種,一種是語法外掛,這類外掛是在解析階段輔助解析器(Babylon)工作;另一類外掛是轉譯外掛,這類外掛是在轉換階段參與進行程式碼的轉譯工作,這也是我們使用babel最常見也最本質的需求。這篇文章主要關注的也是babel的轉譯外掛。
為了瞭解Babel在遍歷時處理AST的具體過程,我們還需要了解下面幾個重要知識點。
Visitor
當Babel處理一個節點時,是以訪問者的形式獲取節點資訊,並進行相關操作,這種方式是通過一個visitor物件來完成的,在visitor物件中定義了對於各種節點的訪問函式,這樣就可以針對不同的節點做出不同的處理。我們編寫的Babel外掛其實也是通過定義一個例項化visitor物件處理一系列的AST節點來完成我們對程式碼的修改操作。舉個例子:
我們想要處理程式碼中用來載入模組的import命令語句
import { Ajax } from '../lib/utils';
複製程式碼
那麼我們的Babel外掛就需要定義這樣的一個visitor物件:
visitor: {
Program: {
enter(path, state) {
console.log('start processing this module...');
},
exit(path, state) {
console.log('end processing this module!');
}
},
ImportDeclaration (path, state) {
console.log('processing ImportDeclaration...');
// do something
}
}
複製程式碼
當把這個外掛用於遍歷中時,每當處理到一個import語句,即ImportDeclaration節點時,都會自動呼叫ImportDeclaration()方法,這個方法中定義了處理import語句的具體操作。ImportDeclaration()都是在進入ImportDeclaration節點時呼叫的,我們也可以讓外掛在退出節點時呼叫方法進行處理。
visitor: {
ImportDeclaration: {
enter(path, state) {
console.log('start processing ImportDeclaration...');
// do something
},
exit(path, state) {
console.log('end processing ImportDeclaration!');
// do something
}
},
}
複製程式碼
當進入ImportDeclaration節點時呼叫enter()方法,退出ImportDeclaration節點時呼叫exit()方法。上面的Program節點(Program節點可以通俗地解釋為一個模組節點)也是一樣的道理。值得注意的是,AST的遍歷採用深度優先遍歷,所以上述import程式碼塊的AST遍歷的過程如下:
─ Program.enter()
─ ImportDeclaration.enter()
─ ImportDeclaration.exit()
─ Program.exit()
複製程式碼
所以當建立訪問者時你實際上有兩次機會來訪問一個節點。
ps: 有關AST中各種節點型別的定義可以檢視Babylon手冊:github.com/babel/babyl…
Path
從上面的visitor物件中,可以看到每次訪問節點方法時,都會傳入一個path引數,這個path引數中包含了節點的資訊以及節點和所在的位置,以供對特定節點進行操作。具體來說Path 是表示兩個節點之間連線的物件。這個物件不僅包含了當前節點的資訊,也有當前節點的父節點的資訊,同時也包含了新增、更新、移動和刪除節點有關的其他很多方法。具體地,Path物件包含的屬性和方法主要如下:
── 屬性
- node 當前節點
- parent 父節點
- parentPath 父path
- scope 作用域
- context 上下文
- ...
── 方法
- get 當前節點
- findParent 向父節點搜尋節點
- getSibling 獲取兄弟節點
- replaceWith 用AST節點替換該節點
- replaceWithMultiple 用多個AST節點替換該節點
- insertBefore 在節點前插入節點
- insertAfter 在節點後插入節點
- remove 刪除節點
- ...
複製程式碼
具體的可以檢視babel-traverse。
這裡我們繼續上面的例子,看看path引數的node屬性包含哪些資訊:
visitor: {
ImportDeclaration (path, state) {
console.log(path.node);
// do something
}
}
複製程式碼
列印結果如下:
Node {
type: 'ImportDeclaration',
start: 5,
end: 41,
loc:
SourceLocation {
start: Position { line: 2, column: 4 },
end: Position { line: 2, column: 40 } },
specifiers:
[ Node {
type: 'ImportSpecifier',
start: 14,
end: 18,
loc: [SourceLocation],
imported: [Node],
local: [Node] } ],
source:
Node {
type: 'StringLiteral',
start: 26,
end: 40,
loc: SourceLocation { start: [Position], end: [Position] },
extra: { rawValue: '../lib/utils', raw: '\'../lib/utils\'' },
value: '../lib/utils'
}
}
複製程式碼
可以發現除了type、start、end、loc這些常規欄位,ImportDeclaration節點還有specifiers和source這兩個特殊欄位,specifiers表示import匯入的變數組成的節點陣列,source表示匯出模組的來源節點。這裡再說一下specifier中的imported和local欄位,imported表示從匯出模組匯出的變數,local表示匯入後當前模組的變數,還是有點費解,我們把import命令語句修改一下:
import { Ajax as ajax } from '../lib/utils';
複製程式碼
然後繼續列印specifiers第一個元素的local和imported欄位:
Node {
type: 'Identifier',
start: 22,
end: 26,
loc:
SourceLocation {
start: Position { line: 2, column: 21 },
end: Position { line: 2, column: 25 },
identifierName: 'ajax' },
name: 'ajax' }
Node {
type: 'Identifier',
start: 14,
end: 18,
loc:
SourceLocation {
start: Position { line: 2, column: 13 },
end: Position { line: 2, column: 17 },
identifierName: 'Ajax' },
name: 'Ajax' }
複製程式碼
這樣就很明顯了。如果不使用as關鍵字,那麼imported和local就是表示同一個變數的節點了。
State
State是visitor物件中每次訪問節點方法時傳入的第二個引數。如果看Babel手冊裡的解釋,可能還是有點困惑,簡單來說,state就是一系列狀態的集合,包含諸如當前plugin的資訊、plugin傳入的配置引數資訊,甚至當前節點的path資訊也能獲取到,當然也可以把babel外掛處理過程中的自定義狀態儲存到state物件中。
Scopes(作用域)
這裡的作用域其實跟js說的作用域是一個道理,也就是說babel在處理AST時也需要考慮作用域的問題,比如函式內外的同名變數需要區分開來,這裡直接拿Babel手冊裡的一個例子解釋一下。考慮下列程式碼:
function square(n) {
return n * n;
}
複製程式碼
我們來寫一個把 n 重新命名為 x 的visitor。
visitor: {
FunctionDeclaration(path) {
const param = path.node.params[0];
paramName = param.name;
param.name = "x";
},
Identifier(path) {
if (path.node.name === paramName) {
path.node.name = "x";
}
}
}
複製程式碼
對上面的例子程式碼這段訪問者程式碼也許能工作,但它很容易被打破:
function square(n) {
return n * n;
}
var n = 1;
複製程式碼
上面的visitor會把函式square外的n變數替換成x,這顯然不是我們期望的。更好的處理方式是使用遞迴,把一個訪問者放進另外一個訪問者裡面。
visitor: {
FunctionDeclaration(path) {
const updateParamNameVisitor = {
Identifier(path) {
if (path.node.name === this.paramName) {
path.node.name = "x";
}
}
};
const param = path.node.params[0];
paramName = param.name;
param.name = "x";
path.traverse(updateParamNameVisitor, { paramName });
},
}
複製程式碼
到這裡我們已經對Babel工作流程大概有了一些瞭解,下面我們再說一下Babel的工具集。
Babel的工具集
Babel 實際上是一組模組的集合,在上面介紹Babel工作流程中也都提到過。
Babylon
“Babylon 是 Babel的解析器。最初是從Acorn專案fork出來的。Acorn非常快,易於使用,並且針對非標準特性(以及那些未來的標準特性) 設計了一個基於外掛的架構。”。這裡直接引用了手冊裡的說明,可以說Babylon定義了把程式碼解析成AST的一套規範。引用一個例子:
import * as babylon from "babylon";
const code = `function square(n) {
return n * n;
}`;
babylon.parse(code);
// Node {
// type: "File",
// start: 0,
// end: 38,
// loc: SourceLocation {...},
// program: Node {...},
// comments: [],
// tokens: [...]
// }
複製程式碼
babel-traverse
babel-traverse用於維護操作AST的狀態,定義了更新、新增和移除節點的操作方法。之前也說到,path引數裡面的屬性和方法都是在babel-traverse裡面定義的。這裡還是引用一個例子,將babel-traverse和Babylon一起使用來遍歷和更新節點:
import * as babylon from "babylon";
import traverse from "babel-traverse";
const code = `function square(n) {
return n * n;
}`;
const ast = babylon.parse(code);
traverse(ast, {
enter(path) {
if (
path.node.type === "Identifier" &&
path.node.name === "n"
) {
path.node.name = "x";
}
}
});
複製程式碼
babel-types
babel-types是一個強大的用於處理AST節點的工具庫,“它包含了構造、驗證以及變換AST節點的方法。該工具庫包含考慮周到的工具方法,對編寫處理AST邏輯非常有用。”這個工具庫的具體的API可以參考Babel官網:babeljs.io/docs/en/bab…
這裡我們還是用import命令來演示一個例子,比如我們要判斷import匯入是什麼型別的匯入,這裡先寫出三種形式的匯入:
import { Ajax } from '../lib/utils';
import utils from '../lib/utils';
import * as utils from '../lib/utils';
複製程式碼
在AST中用於表示上面匯入的三個變數的節點是不同的,分別叫做ImportSpecifier、ImportDefaultSpecifier和ImportNamespaceSpecifier。具體可以參考這裡。 如果我們只對匯入指定變數的import命令語句做處理,那麼我們的babel外掛就可以這樣寫:
function plugin () {
return ({ types }) => ({
visitor: {
ImportDeclaration (path, state) {
const specifiers = path.node.specifiers;
specifiers.forEach((specifier) => {
if (!types.isImportDefaultSpecifier(specifier) && !types.isImportNamespaceSpecifier(specifier)) {
// do something
}
})
}
}
}
複製程式碼
到這裡,關於Babel的原理差不多都講完了,下面我們嘗試寫一個具體功能的Babel外掛。
Babel外掛實踐
這裡我們嘗試實現這樣一個功能:當使用UI元件庫時,我們常常只會用到元件庫中的部分元件,就像這樣:
import { Select, Pagination } from 'xxx-ui';
複製程式碼
但是這樣卻引入了整個元件庫,那麼打包的時候也會把整個元件庫的程式碼打包進去,這顯然是不太合理的,所以我們希望能夠在打包的時候只打包我們需要的元件。
Let's do it!
首先我們需要告訴Babel怎麼找到對應元件的路徑,也就是說我們需要自定義一個規則告訴Babel根據指定名稱載入對應元件,這裡我們定義一個方法:
"customSourceFunc": componentName =>(`./xxx-ui/src/components/ui-base/${componentName}/${componentName}`)}
複製程式碼
這個方法作為這個外掛的配置引數,可以配置到.babelrc(準確來說是.babelrc.js)或者babel-loader裡面。 接下來我們需要定義visitor物件,有了之前的鋪墊,這裡直接上程式碼:
visitor: {
ImportDeclaration (path, { opts }) {
const specifiers = path.node.specifiers;
const source = path.node.source;
// 判斷傳入的配置引數是否是陣列形式
if (Array.isArray(opts)) {
opts.forEach(opt => {
assert(opt.libraryName, 'libraryName should be provided');
});
if (!opts.find(opt => opt.libraryName === source.value)) return;
} else {
assert(opts.libraryName, 'libraryName should be provided');
if (opts.libraryName !== source.value) return;
}
const opt = Array.isArray(opts) ? opts.find(opt => opt.libraryName === source.value) : opts;
opt.camel2UnderlineComponentName = typeof opt.camel2UnderlineComponentName === 'undefined'
? false
: opt.camel2UnderlineComponentName;
opt.camel2DashComponentName = typeof opt.camel2DashComponentName === 'undefined'
? false
: opt.camel2DashComponentName;
if (!types.isImportDefaultSpecifier(specifiers[0]) && !types.isImportNamespaceSpecifier(specifiers[0])) {
// 遍歷specifiers生成轉換後的ImportDeclaration節點陣列
const declarations = specifiers.map((specifier) => {
// 轉換元件名稱
const transformedSourceName = opt.camel2UnderlineComponentName
? camel2Underline(specifier.imported.name)
: opt.camel2DashComponentName
? camel2Dash(specifier.imported.name)
: specifier.imported.name;
// 利用自定義的customSourceFunc生成絕對路徑,然後建立新的ImportDeclaration節點
return types.ImportDeclaration([types.ImportDefaultSpecifier(specifier.local)],
types.StringLiteral(opt.customSourceFunc(transformedSourceName)));
});
// 將當前節點替換成新建的ImportDeclaration節點組
path.replaceWithMultiple(declarations);
}
}
}
複製程式碼
其中opts表示的就是之前在.babelrc.js或babel-loader中傳入的配置引數,程式碼中的camel2UnderlineComponentName和camel2DashComponentName可以先不考慮,不過從字面上也能猜到是什麼功能。這個visitor主要就是遍歷模組內所有的ImportDeclaration節點,找出specifier為ImportSpecifier型別的節點,利用傳入customSourceFunc得到其絕對路徑的匯入方式,然後替換原來的ImportDeclaration節點,這樣就可以實現元件的按需載入了。
我們來測試一下效果,
const babel = require('babel-core');
const types = require('babel-types');
const plugin = require('./../lib/index.js');
const visitor = plugin({types});
const code = `
import { Select as MySelect, Pagination } from 'xxx-ui';
import * as UI from 'xxx-ui';
`;
const result = babel.transform(code, {
plugins: [
[
visitor,
{
"libraryName": "xxx-ui",
"camel2DashComponentName": true,
"customSourceFunc": componentName =>(`./xxx-ui/src/components/ui-base/${componentName}/${componentName}`)}
}
]
]
});
console.log(result.code);
// import MySelect from './xxx-ui/src/components/ui-base/select/select';
// import Pagination from './xxx-ui/src/components/ui-base/pagination/pagination';
// import * as UI from 'xxx-ui';
複製程式碼
這個Babel外掛已釋出到npm,外掛地址:www.npmjs.com/package/bab…
有興趣的也可以檢視外掛原始碼:github.com/hudingyu/ba… 原始碼裡面有測試例子,可以自己clone下來跑跑看,記得先build一下。
其實這個外掛算是乞丐版的按需載入外掛,ant-design的按需載入外掛babel-plugin-import實現了更完備的方案,也對React做了特殊優化,後續有時間我會對這個外掛做一次原始碼的分析。
差不多就是這樣了。
以上。