庖丁解牛:最全 babel-plugin-import 源碼詳解
序言:在用 babel-plugin 實現(xiàn)按需加載一文中筆者用作用域鏈思路實現(xiàn)了按需加載組件烤镐。此思路是統(tǒng)一式處理幽勒,進入
ImportDeclaration
后,收集依賴镰吆,生成新節(jié)點,最后利用作用域( scope )鏈直接替換了被修改的specifiers[]
綁定的所有引用名。同樣是利用作用域鏈向族,可以知曉某一節(jié)點是否在上下文中被引用搀继,如果沒有引用就刪除無效節(jié)點窘面。乃至最后的替換原節(jié)點完成按需加載。這次本文將帶領大家解析babel-plugin-import
實現(xiàn)按需加載的完整流程叽躯,解開業(yè)界所認可 babel 插件的面紗财边。
首先供上 babel-plugin-import
插件地址:ant-design/babel-plugin-import
由于在筆者的上一篇文章中已經(jīng)對 babel 與 babel-plugin 有過介紹了,因此本文不再贅述点骑,直接進入正題酣难。
眾所周知,庖丁解牛分為三個階段:
- 第一階段黑滴,庖丁剛開始宰牛的時候憨募,對于牛體的結(jié)構(gòu)還不了解,看見的只是整頭的牛袁辈。
- 第二階段菜谣,三年之后,他見到的是牛的內(nèi)部肌理筋骨晚缩,再也看不見整頭的牛了尾膊。
- 第三階段,現(xiàn)在宰牛的時候橡羞,只是用精神去接觸牛的身體就可以了眯停,而不必用眼睛去看。
現(xiàn)在就以這三個階段去逐步遞進 babel-plugin-import
插件源碼
Step1 : 始臣之解牛之時,所見無非牛者
首先 babel-plugin-import
是為了解決在打包過程中把項目中引用到的外部組件或功能庫全量打包卿泽,從而導致編譯結(jié)束后包容量過大的問題莺债,如下圖所示:
babel-plugin-import
插件源碼由兩個文件構(gòu)成
- Index 文件即是插件入口初始化的文件,也是筆者在 Step1 中著重說明的文件
- Plugin 文件包含了處理各種 AST 節(jié)點的方法集签夭,以 Class 形式導出
先來到插件的入口文件 Index :
import Plugin from './Plugin';
export default function({ types }) {
let plugins = null;
/**
* Program 入口初始化插件 options 的數(shù)據(jù)結(jié)構(gòu)
*/
const Program = {
enter(path, { opts = {} }) {
assert(opts.libraryName, 'libraryName should be provided');
plugins = [
new Plugin(
opts.libraryName,
opts.libraryDirectory,
opts.style,
opts.styleLibraryDirectory,
opts.customStyleName,
opts.camel2DashComponentName,
opts.camel2UnderlineComponentName,
opts.fileName,
opts.customName,
opts.transformToDefaultImport,
types,
),
];
applyInstance('ProgramEnter', arguments, this);
},
exit() {
applyInstance('ProgramExit', arguments, this);
},
};
const ret = {
visitor: { Program }, // 對整棵AST樹的入口進行初始化操作
};
return ret;
}
首先 Index 文件導入了 Plugin 齐邦,并且有一個默認導出函數(shù),函數(shù)的參數(shù)是被解構(gòu)出的名叫 types 的參數(shù)第租,它是從 babel 對象中被解構(gòu)出來的措拇,types 的全稱是 @babel/types
,用于處理 AST 節(jié)點的方法集慎宾。以這種方式引入后丐吓,我們不需要手動引入 @babel/types
。
進入函數(shù)后可以看見觀察者( visitor )
中初始化了一個 AST 節(jié)點 Program
趟据,這里對 Program
節(jié)點的處理使用完整插件結(jié)構(gòu)券犁,有進入( enter )與離開( exit )事件,且需注意:
一般我們縮寫的 Identifier() { ... } 是 Identifier: { enter() { ... } } 的簡寫形式汹碱。
這里可能有同學會問 Program
節(jié)點是什么粘衬?見下方 const a = 1 對應的 AST 樹 ( 簡略部分參數(shù) )
{
"type": "File",
"loc": {
"start":... ,
"end": ...
},
"program": {
"type": "Program", // Program 所在位置
"sourceType": "module",
"body": [
{
"type": "VariableDeclaration",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "a"
},
"init": {
"type": "NumericLiteral",
"value": 1
}
}
],
"kind": "const"
}
],
"directives": []
},
"comments": [],
"tokens": [
...
]
}
Program 相當于一個根節(jié)點,一個完整的源代碼樹。一般在進入該節(jié)點的時候進行初始化數(shù)據(jù)之類的操作稚新,也可理解為該節(jié)點先于其他節(jié)點執(zhí)行勘伺,同時也是最晚執(zhí)行 exit 的節(jié)點,在 exit 時也可以做一些”善后“的工作褂删。 既然 babel-plugin-import
的 Program
節(jié)點處寫了完整的結(jié)構(gòu)飞醉,必然在 exit 時也有非常必要的事情需要處理,關(guān)于 exit 具體是做什么的我們稍后進行討論笤妙。
我們先看 enter 冒掌,這里首先用 enter 形參 state 結(jié)構(gòu)出用戶制定的插件參數(shù),驗證必填的 libraryName
[庫名稱] 是否存在蹲盘。Index 文件引入的 Plugin 是一個 class 結(jié)構(gòu),因此需要對 Plugin 進行實例化膳音,并把插件的所有參數(shù)與 @babel/types
全部傳進去召衔,關(guān)于 Plugin 類會在下文中進行闡述。
接著調(diào)用了 applyInstance
函數(shù):
export default function({ types }) {
let plugins = null;
/**
* 從類中繼承方法并利用 apply 改變 this 指向祭陷,并傳遞 path , state 參數(shù)
*/
function applyInstance(method, args, context) {
for (const plugin of plugins) {
if (plugin[method]) {
plugin[method].apply(plugin, [...args, context]);
}
}
}
const Program = {
enter(path, { opts = {} }) {
...
applyInstance('ProgramEnter', arguments, this);
},
...
}
}
此函數(shù)的主要目的是繼承 Plugin 類中的方法苍凛,且需要三個參數(shù)
- method(String):你需要從 Plugin 類中繼承出來的方法名稱
- args:(Arrray<T>):[ Path, State ]
- PluginPass( Object):內(nèi)容和 State 一致,確保傳遞內(nèi)容為最新的 State
主要的目的是讓 Program
的 enter 繼承 Plugin 類的 ProgramEnter
方法兵志,并且傳遞 path 與 state 形參至 ProgramEnter
醇蝴。Program
的 exit 同理,繼承的是 ProgramExit
方法想罕。
現(xiàn)在進入 Plugin 類:
export default class Plugin {
constructor(
libraryName,
libraryDirectory,
style,
styleLibraryDirectory,
customStyleName,
camel2DashComponentName,
camel2UnderlineComponentName,
fileName,
customName,
transformToDefaultImport,
types, // babel-types
index = 0, // 標記符
) {
this.libraryName = libraryName; // 庫名
this.libraryDirectory = typeof libraryDirectory === 'undefined' ? 'lib' : libraryDirectory; // 包路徑
this.style = style || false; // 是否加載 style
this.styleLibraryDirectory = styleLibraryDirectory; // style 包路徑
this.camel2DashComponentName = camel2DashComponentName || true; // 組件名是否轉(zhuǎn)換以“-”鏈接的形式
this.transformToDefaultImport = transformToDefaultImport || true; // 處理默認導入
this.customName = normalizeCustomName(customName); // 處理轉(zhuǎn)換結(jié)果的函數(shù)或路徑
this.customStyleName = normalizeCustomName(customStyleName); // 處理轉(zhuǎn)換結(jié)果的函數(shù)或路徑
this.camel2UnderlineComponentName = camel2UnderlineComponentName; // 處理成類似 time_picker 的形式
this.fileName = fileName || ''; // 鏈接到具體的文件悠栓,例如 antd/lib/button/[abc.js]
this.types = types; // babel-types
this.pluginStateKey = `importPluginState${index}`;
}
...
}
在入口文件實例化 Plugin 已經(jīng)把插件的參數(shù)通過 constructor
后被初始化完畢啦,除了 libraryName
以外其他所有的值均有相應默認值按价,值得注意的是參數(shù)列表中的 customeName 與 customStyleName 可以接收一個函數(shù)或者一個引入的路徑惭适,因此需要通過 normalizeCustomName
函數(shù)進行統(tǒng)一化處理。
function normalizeCustomName(originCustomName) {
if (typeof originCustomName === 'string') {
const customeNameExports = require(originCustomName);
return typeof customeNameExports === 'function'
? customeNameExports
: customeNameExports.default;// 如果customeNameExports不是函數(shù)就導入{default:func()}
}
return originCustomName;
}
此函數(shù)就是用來處理當參數(shù)是路徑時楼镐,進行轉(zhuǎn)換并取出相應的函數(shù)癞志。如果處理后 customeNameExports
仍然不是函數(shù)就導入 customeNameExports.default
,這里牽扯到 export default 是語法糖的一個小知識點框产。
export default something() {}
// 等效于
function something() {}
export ( something as default )
回歸代碼凄杯,Step1 中入口文件 Program
的 Enter 繼承了 Plugin 的 ProgramEnter
方法
export default class Plugin {
constructor(...) {...}
getPluginState(state) {
if (!state[this.pluginStateKey]) {
// eslint-disable-next-line no-param-reassign
state[this.pluginStateKey] = {}; // 初始化標示
}
return state[this.pluginStateKey]; // 返回標示
}
ProgramEnter(_, state) {
const pluginState = this.getPluginState(state);
pluginState.specified = Object.create(null); // 導入對象集合
pluginState.libraryObjs = Object.create(null); // 庫對象集合 (非 module 導入的內(nèi)容)
pluginState.selectedMethods = Object.create(null); // 存放經(jīng)過 importMethod 之后的節(jié)點
pluginState.pathsToRemove = []; // 存儲需要刪除的節(jié)點
/**
* 初始化之后的 state
* state:{
* importPluginState「Number」: {
* specified:{},
* libraryObjs:{},
* select:{},
* pathToRemovw:[]
* },
* opts:{
* ...
* },
* ...
* }
*/
}
...
}
ProgramEnter
中通過 getPluginState
**初始化 state 結(jié)構(gòu)中的 importPluginState
對象,getPluginState
函數(shù)在后續(xù)操作中出現(xiàn)非常頻繁秉宿,讀者在此需要留意此函數(shù)的作用戒突,后文不再對此進行贅述。
但是為什么需要初始化這么一個結(jié)構(gòu)呢蘸鲸?這就牽扯到插件的思路妖谴。正像開篇流程圖所述的那樣 ,babel-plugin-import
具體實現(xiàn)按需加載思路如下:經(jīng)過 import 節(jié)點后收集節(jié)點數(shù)據(jù),然后從所有可能引用到 import 綁定的節(jié)點處執(zhí)行按需加載轉(zhuǎn)換方法膝舅。state 是一個引用類型嗡载,對其進行操作會影響到后續(xù)節(jié)點的 state 初始值,因此用 Program 節(jié)點仍稀,在 enter 的時候就初始化這個收集依賴的對象洼滚,方便后續(xù)操作。負責初始化 state 節(jié)點結(jié)構(gòu)與取數(shù)據(jù)的方法正是 getPluginState
技潘。
這個思路很重要遥巴,并且貫穿后面所有的代碼與目的,請讀者務必理解再往下閱讀享幽。
Step2: 三年之后,未嘗見全牛也
借由 Step1铲掐,現(xiàn)在已經(jīng)了解到插件以 Program
為出發(fā)點繼承了 ProgramEnter
并且初始化了 Plugin 依賴,如果讀者還有尚未梳理清楚的部分值桩,請回到 Step1 仔細消化下內(nèi)容再繼續(xù)閱讀摆霉。
首先,我們再回到外圍的 Index 文件奔坟,之前只在觀察者模式中注冊了 Program
的節(jié)點携栋,沒有其他 AST 節(jié)點入口,因此至少還需注入 import 語句的 AST 節(jié)點類型 ImportDeclaration
export default function({ types }) {
let plugins = null;
function applyInstance(method, args, context) {
...
}
const Program = {
...
}
const methods = [ // 注冊 AST type 的數(shù)組
'ImportDeclaration'
]
const ret = {
visitor: { Program },
};
// 遍歷數(shù)組咳秉,利用 applyInstance 繼承相應方法
for (const method of methods) {
ret.visitor[method] = function() {
applyInstance(method, arguments, ret.visitor);
};
}
}
創(chuàng)建一個數(shù)組并將 ImportDeclaration
置入婉支,經(jīng)過遍歷調(diào)用 applyInstance
_ _和 Step1 介紹同理,執(zhí)行完畢后 visitor 會變成如下結(jié)構(gòu)
visitor: {
Program: { enter: [Function: enter], exit: [Function: exit] },
ImportDeclaration: [Function],
}
現(xiàn)在回歸 Plugin澜建,進入 ImportDeclaration
export default class Plugin {
constructor(...) {...}
ProgramEnter(_, state) { ... }
/**
* 主目標向挖,收集依賴
*/
ImportDeclaration(path, state) {
const { node } = path;
// path 有可能被前一個實例刪除
if (!node) return;
const {
source: { value }, // 獲取 AST 中引入的庫名
} = node;
const { libraryName, types } = this;
const pluginState = this.getPluginState(state); // 獲取在 Program 處初始化的結(jié)構(gòu)
if (value === libraryName) { // AST 庫名與插件參數(shù)名是否一致,一致就進行依賴收集
node.specifiers.forEach(spec => {
if (types.isImportSpecifier(spec)) { // 不滿足條件說明 import 是名稱空間引入或默認引入
pluginState.specified[spec.local.name] = spec.imported.name;
// 保存為:{ 別名 : 組件名 } 結(jié)構(gòu)
} else {
pluginState.libraryObjs[spec.local.name] = true;// 名稱空間引入或默認引入的值設置為 true
}
});
pluginState.pathsToRemove.push(path); // 取值完畢的節(jié)點添加進預刪除數(shù)組
}
}
...
}
ImportDeclaration
會對 import 中的依賴字段進行收集霎奢,如果是名稱空間引入或者是默認引入就設置為 { 別名 :true }户誓,解構(gòu)導入就設置為 { 別名 :組件名 } 。getPluginState
方法在 Step1 中已經(jīng)進行過說明幕侠。關(guān)于 import 的 AST 節(jié)點結(jié)構(gòu) 用 babel-plugin 實現(xiàn)按需加載 中有詳細說明帝美,本文不再贅述。執(zhí)行完畢后 pluginState 結(jié)構(gòu)如下
// 例: import { Input, Button as Btn } from 'antd'
{
...
importPluginState0: {
specified: {
Btn : 'Button',
Input : 'Input'
},
pathToRemove: {
[NodePath]
}
...
}
...
}
這下 state.importPluginState
結(jié)構(gòu)已經(jīng)收集到了后續(xù)幫助節(jié)點進行轉(zhuǎn)換的所有依賴信息晤硕。
目前已經(jīng)萬事俱備悼潭,只欠東風。東風是啥舞箍?是能讓轉(zhuǎn)換 import 工作開始的 action舰褪。在 用 babel-plugin 實現(xiàn)按需加載 中收集到依賴的同時也進行了節(jié)點轉(zhuǎn)換與刪除舊節(jié)點。一切工作都在 ImportDeclaration
節(jié)點中發(fā)生疏橄。而 babel-plugin-import
的思路是尋找一切可能引用到 Import 的 AST 節(jié)點占拍,對他們?nèi)窟M行處理略就。有部分讀者也許會直接想到去轉(zhuǎn)換引用了 import 綁定的 JSX 節(jié)點,但是轉(zhuǎn)換 JSX 節(jié)點的意義不大晃酒,因為可能引用到 import 綁定的 AST 節(jié)點類型 ( type ) 已經(jīng)夠多了表牢,所有應盡可能的縮小需要轉(zhuǎn)換的 AST 節(jié)點類型范圍。而且 babel 的其他插件會將我們的 JSX 節(jié)點進行轉(zhuǎn)換成其他 AST type贝次,因此能不考慮 JSX 類型的 AST 樹崔兴,可以等其他 babel 插件轉(zhuǎn)換后再進行替換工作。其實下一步可以開始的入口有很多蛔翅,但還是從咱最熟悉的 React.createElement 開始敲茄。
class Hello extends React.Component {
render() {
return <div>Hello</div>
}
}
// 轉(zhuǎn)換后
class Hello extends React.Component {
render(){
return React.createElement("div",null,"Hello")
}
}
JSX 轉(zhuǎn)換后 AST 類型為 CallExpression
(函數(shù)執(zhí)行表達式),結(jié)構(gòu)如下所示山析,熟悉結(jié)構(gòu)后能方便各位同學對之后步驟有更深入的理解堰燎。
{
"type": "File",
"program": {
"type": "Program",
"body": [
{
"type": "ClassDeclaration",
"body": {
"type": "ClassBody",
"body": [
{
"type": "ClassMethod",
"body": {
"type": "BlockStatement",
"body": [
{
"type": "ReturnStatement",
"argument": {
"type": "CallExpression", // 這里是處理的起點
"callee": {
"type": "MemberExpression",
"object": {
"type": "Identifier",
"identifierName": "React"
},
"name": "React"
},
"property": {
"type": "Identifier",
"loc": {
"identifierName": "createElement"
},
"name": "createElement"
}
},
"arguments": [
{
"type": "StringLiteral",
"extra": {
"rawValue": "div",
"raw": "\"div\""
},
"value": "div"
},
{
"type": "NullLiteral"
},
{
"type": "StringLiteral",
"extra": {
"rawValue": "Hello",
"raw": "\"Hello\""
},
"value": "Hello"
}
]
}
],
"directives": []
}
}
]
}
}
]
}
}
因此我們進入 CallExpression 節(jié)點處,繼續(xù)轉(zhuǎn)換流程笋轨。
export default class Plugin {
constructor(...) {...}
ProgramEnter(_, state) { ... }
ImportDeclaration(path, state) { ... }
CallExpression(path, state) {
const { node } = path;
const file = path?.hub?.file || state?.file;
const { name } = node.callee;
const { types } = this;
const pluginState = this.getPluginState(state);
// 處理一般的調(diào)用表達式
if (types.isIdentifier(node.callee)) {
if (pluginState.specified[name]) {
node.callee = this.importMethod(pluginState.specified[name], file, pluginState);
}
}
// 處理React.createElement
node.arguments = node.arguments.map(arg => {
const { name: argName } = arg;
// 判斷作用域的綁定是否為import
if (
pluginState.specified[argName] &&
path.scope.hasBinding(argName) &&
types.isImportSpecifier(path.scope.getBinding(argName).path)
) {
return this.importMethod(pluginState.specified[argName], file, pluginState); // 替換了引用爽待,help/import插件返回節(jié)點類型與名稱
}
return arg;
});
}
...
}
可以看見源碼調(diào)用了importMethod
兩次,此函數(shù)的作用是觸發(fā) import 轉(zhuǎn)換成按需加載模式的 action翩腐,并返回一個全新的 AST 節(jié)點。因為 import 被轉(zhuǎn)換后膏燃,之前我們?nèi)斯ひ氲慕M件名稱會和轉(zhuǎn)換后的名稱不一樣茂卦,因此 importMethod
需要把轉(zhuǎn)換后的新名字(一個 AST 結(jié)構(gòu))返回到我們對應 AST 節(jié)點的對應位置上,替換掉老組件名组哩。函數(shù)源碼稍后會進行詳細分析等龙。
回到一開始的問題,為什么 CallExpression
需要調(diào)用 importMethod
函數(shù)伶贰?因為這兩處表示的意義是不同的蛛砰,CallExpression
節(jié)點的情況有兩種:
- 剛才已經(jīng)分析過了,這第一種情況是 JSX 代碼經(jīng)過轉(zhuǎn)換后的 React.createElement
- 我們使用函數(shù)調(diào)用一類的操作代碼的 AST 也同樣是
CallExpression
類型黍衙,例如:
import lodash from 'lodash'
lodash(some values)
因此在 CallExpression
中首先會判斷 node.callee 值是否是 Identifier
泥畅,如果正確則是所述的第二種情況,直接進行轉(zhuǎn)換琅翻。若否位仁,則是 React.createElement 形式,遍歷 React.createElement 的三個參數(shù)取出 name方椎,再判斷 name 是否是先前 state.pluginState 收集的 import 的 name聂抢,最后檢查 name 的作用域情況,以及追溯 name 的綁定是否是一個 import 語句棠众。這些判斷條件都是為了避免錯誤的修改函數(shù)原本的語義琳疏,防止錯誤修改因閉包等特性的塊級作用域中有相同名稱的變量。如果上述條件均滿足那它肯定是需要處理的 import 引用了。讓其繼續(xù)進入importMethod
轉(zhuǎn)換函數(shù)空盼,importMethod
需要傳遞三個參數(shù):組件名书幕,F(xiàn)ile(path.sub.file),pluginState
import { join } from 'path';
import { addSideEffect, addDefault, addNamed } from '@babel/helper-module-imports';
export default class Plugin {
constructor(...) {...}
ProgramEnter(_, state) { ... }
ImportDeclaration(path, state) { ... }
CallExpression(path, state) { ... }
// 組件原始名稱 , sub.file , 導入依賴項
importMethod(methodName, file, pluginState) {
if (!pluginState.selectedMethods[methodName]) {
const { style, libraryDirectory } = this;
const transformedMethodName = this.camel2UnderlineComponentName // 根據(jù)參數(shù)轉(zhuǎn)換組件名稱
? transCamel(methodName, '_')
: this.camel2DashComponentName
? transCamel(methodName, '-')
: methodName;
/**
* 轉(zhuǎn)換路徑我注,優(yōu)先按照用戶定義的customName進行轉(zhuǎn)換按咒,如果沒有提供就按照常規(guī)拼接路徑
*/
const path = winPath(
this.customName
? this.customName(transformedMethodName, file)
: join(this.libraryName, libraryDirectory, transformedMethodName, this.fileName), // eslint-disable-line
);
/**
* 根據(jù)是否是默認引入對最終路徑做處理,并沒有對namespace做處理
*/
pluginState.selectedMethods[methodName] = this.transformToDefaultImport // eslint-disable-line
? addDefault(file.path, path, { nameHint: methodName })
: addNamed(file.path, methodName, path);
if (this.customStyleName) { // 根據(jù)用戶指定的路徑引入樣式文件
const stylePath = winPath(this.customStyleName(transformedMethodName));
addSideEffect(file.path, `${stylePath}`);
} else if (this.styleLibraryDirectory) { // 根據(jù)用戶指定的樣式目錄引入樣式文件
const stylePath = winPath(
join(this.libraryName, this.styleLibraryDirectory, transformedMethodName, this.fileName),
);
addSideEffect(file.path, `${stylePath}`);
} else if (style === true) { // 引入 scss/less
addSideEffect(file.path, `${path}/style`);
} else if (style === 'css') { // 引入 css
addSideEffect(file.path, `${path}/style/css`);
} else if (typeof style === 'function') { // 若是函數(shù),根據(jù)返回值生成引入
const stylePath = style(path, file);
if (stylePath) {
addSideEffect(file.path, stylePath);
}
}
}
return { ...pluginState.selectedMethods[methodName] };
}
...
}
進入函數(shù)后但骨,先別著急看代碼励七,注意這里引入了兩個包:path.join 和 @babel/helper-module-imports ,引入 join 是為了處理按需加載路徑快捷拼接的需求奔缠,至于 import 語句轉(zhuǎn)換掠抬,肯定需要產(chǎn)生全新的 import AST 節(jié)點實現(xiàn)按需加載,最后再把老的 import 語句刪除校哎。而新的 import 節(jié)點使用 babel 官方維護的 @babel/helper-module-imports
生成×讲ǎ現(xiàn)在繼續(xù)流程,首先無視一開始的 if 條件語句闷哆,稍后會做說明腰奋。再捋一捋 import 處理函數(shù)中需要處理的幾個環(huán)節(jié):
- 對引入的組件名稱進行修改,默認轉(zhuǎn)換以“-”拼接單詞的形式抱怔,例如:DatePicker 轉(zhuǎn)換為 date-picker劣坊,處理轉(zhuǎn)換的函數(shù)是 transCamel。
function transCamel(_str, symbol) {
const str = _str[0].toLowerCase() + _str.substr(1); // 先轉(zhuǎn)換成小駝峰,以便正則獲取完整單詞
return str.replace(/([A-Z])/g, $1 => `${symbol}${$1.toLowerCase()}`);
// 例 datePicker屈留,正則抓取到P后局冰,在它前面加上指定的symbol符號
}
- 轉(zhuǎn)換到組件所在的具體路徑,如果插件用戶給定了自定義路徑就使用 customName 進行處理灌危,
babel-plugin-import
為什么不提供對象的形式作為參數(shù)康二?因為 customName 修改是以 transformedMethodName 值作為基礎并將其傳遞給插件使用者,如此設計就可以更精確的匹配到需要按需加載的路徑勇蝙。處理這些動作的函數(shù)是 withPath沫勿,withPath 主要兼容 Linux 操作系統(tǒng),將 Windows 文件系統(tǒng)支持的 '' 統(tǒng)一轉(zhuǎn)換為 '/'浅蚪。
function winPath(path) {
return path.replace(/\\/g, '/');
// 兼容路徑: windows默認使用‘\’,也支持‘/’藕帜,但linux不支持‘\’,遂統(tǒng)一轉(zhuǎn)換成‘/’
}
-
對 transformToDefaultImport 進行判斷惜傲,此選項默認為 true洽故,轉(zhuǎn)換后的 AST 節(jié)點是默認導出的形式,如果不想要默認導出可以將 transformToDefaultImport 設置為 false盗誊,之后便利用
@babel/helper-module-imports
生成新的 import 節(jié)點时甚,最后**函數(shù)的返回值就是新 import 節(jié)點的 default Identifier隘弊,替換掉調(diào)用 importMethod 函數(shù)的節(jié)點,從而把所有引用舊 import 綁定的節(jié)點替換成最新生成的 import AST 的節(jié)點荒适。 最后梨熙,根據(jù)用戶是否開啟 style 按需引入與 customStyleName 是否有 style 路徑額外處理,以及 styleLibraryDirectory(style 包路徑)等參數(shù)處理或生成對應的 css 按需加載節(jié)點刀诬。
到目前為止一條最基本的轉(zhuǎn)換線路已經(jīng)轉(zhuǎn)換完畢了咽扇,相信大家也已經(jīng)了解了按需加載的基本轉(zhuǎn)換流程,回到 importMethod 函數(shù)一開始的if 判斷語句陕壹,這與我們將在 step3 中的任務息息相關(guān)≈视現(xiàn)在就讓我們一起進入 step3。
Step3: 方今之時,臣以神遇而不以目視,官知止而神欲行
在 step3 中會進行按需加載轉(zhuǎn)換最后的兩個步驟:
- 引入 import 綁定的引用肯定不止 JSX 語法糠馆,還有其他諸如嘶伟,三元表達式,類的繼承又碌,運算九昧,判斷語句,返回語法等等類型毕匀,我們都得對他們進行處理铸鹰,確保所有的引用都綁定到最新的 import,這也會導致importMethod 函數(shù)被重新調(diào)用皂岔,但我們肯定不希望 import 函數(shù)被引用了 n 次掉奄,生成 n 個新的 import 語句,因此才會有先前的判斷語句凤薛。
- 一開始進入
ImportDeclaration
收集信息的時候我們只是對其進行了依賴收集工作,并沒有刪除節(jié)點诞仓。并且我們尚未補充 Program 節(jié)點 exit 所做的 action
接下來將以此列舉需要處理的所有 AST 節(jié)點缤苫,并且會給每一個節(jié)點對應的接口(Interface)與例子(不關(guān)注語義):
MemberExpression
MemberExpression(path, state) {
const { node } = path;
const file = (path && path.hub && path.hub.file) || (state && state.file);
const pluginState = this.getPluginState(state);
if (!node.object || !node.object.name) return;
if (pluginState.libraryObjs[node.object.name]) {
// antd.Button -> _Button
path.replaceWith(this.importMethod(node.property.name, file, pluginState));
} else if (pluginState.specified[node.object.name] && path.scope.hasBinding(node.object.name)) {
const { scope } = path.scope.getBinding(node.object.name);
// 全局變量處理
if (scope.path.parent.type === 'File') {
node.object = this.importMethod(pluginState.specified[node.object.name], file, pluginState);
}
}
}
MemberExpression(屬性成員表達式),接口如下
interface MemberExpression {
type: 'MemberExpression';
computed: boolean;
object: Expression;
property: Expression;
}
/**
* 處理類似:
* console.log(lodash.fill())
* antd.Button
*/
如果插件的選項中沒有關(guān)閉 transformToDefaultImport 墅拭,這里會調(diào)用 importMethod 方法并返回@babel/helper-module-imports
給予的新節(jié)點值活玲。否則會判斷當前值是否是收集到 import 信息中的一部分以及是否是文件作用域下的全局變量,通過獲取作用域查看其父節(jié)點的類型是否是 File谍婉,即可避免錯誤的替換其他同名變量舒憾,比如閉包場景。
VariableDeclarator
VariableDeclarator(path, state) {
const { node } = path;
this.buildDeclaratorHandler(node, 'init', path, state);
}
VariableDeclarator(變量聲明)穗熬,非常方便理解處理場景镀迂,主要處理 const/let/var 聲明語句
interface VariableDeclaration : Declaration {
type: "VariableDeclaration";
declarations: [ VariableDeclarator ];
kind: "var" | "let" | "const";
}
/**
* 處理類似:
* const foo = antd
*/
本例中出現(xiàn) buildDeclaratorHandler 方法,主要確保傳遞的屬性是基礎的 Identifier 類型且是 import 綁定的引用后便進入 importMethod 進行轉(zhuǎn)換后返回新節(jié)點覆蓋原屬性唤蔗。
buildDeclaratorHandler(node, prop, path, state) {
const file = (path && path.hub && path.hub.file) || (state && state.file);
const { types } = this;
const pluginState = this.getPluginState(state);
if (!types.isIdentifier(node[prop])) return;
if (
pluginState.specified[node[prop].name] &&
path.scope.hasBinding(node[prop].name) &&
path.scope.getBinding(node[prop].name).path.type === 'ImportSpecifier'
) {
node[prop] = this.importMethod(pluginState.specified[node[prop].name], file, pluginState);
}
}
ArrayExpression
ArrayExpression(path, state) {
const { node } = path;
const props = node.elements.map((_, index) => index);
this.buildExpressionHandler(node.elements, props, path, state);
}
ArrayExpression(數(shù)組表達式),接口如下所示
interface ArrayExpression {
type: 'ArrayExpression';
elements: ArrayExpressionElement[];
}
/**
* 處理類似:
* [Button, Select, Input]
*/
本例的處理和剛才的其他節(jié)點不太一樣涯穷,因為數(shù)組的 Element 本身就是一個數(shù)組形式,并且我們需要轉(zhuǎn)換的引用都是數(shù)組元素藏雏,因此這里傳遞的 props 就是類似 [0, 1, 2, 3] 的純數(shù)組,方便后續(xù)從 elements 中進行取數(shù)據(jù)赚瘦。這里進行具體轉(zhuǎn)換的方法是 buildExpressionHandler,在后續(xù)的 AST 節(jié)點處理中將會頻繁出現(xiàn)
buildExpressionHandler(node, props, path, state) {
const file = (path && path.hub && path.hub.file) || (state && state.file);
const { types } = this;
const pluginState = this.getPluginState(state);
props.forEach(prop => {
if (!types.isIdentifier(node[prop])) return;
if (
pluginState.specified[node[prop].name] &&
types.isImportSpecifier(path.scope.getBinding(node[prop].name).path)
) {
node[prop] = this.importMethod(pluginState.specified[node[prop].name], file, pluginState);
}
});
}
首先對 props 進行遍歷杯巨,同樣確保傳遞的屬性是基礎的 Identifier
類型且是 import 綁定的引用后便進入 importMethod 進行轉(zhuǎn)換蚤告,和之前的 buildDeclaratorHandler 方法差不多杜恰,只是 props 是數(shù)組形式
LogicalExpression
LogicalExpression(path, state) {
const { node } = path;
this.buildExpressionHandler(node, ['left', 'right'], path, state);
}
LogicalExpression(邏輯運算符表達式)
interface LogicalExpression {
type: 'LogicalExpression';
operator: '||' | '&&';
left: Expression;
right: Expression;
}
/**
* 處理類似:
* antd && 1
*/
主要取出邏輯運算符表達式的左右兩邊的變量仍源,并使用 buildExpressionHandler 方法進行轉(zhuǎn)換
ConditionalExpression
ConditionalExpression(path, state) {
const { node } = path;
this.buildExpressionHandler(node, ['test', 'consequent', 'alternate'], path, state);
}
ConditionalExpression(條件運算符)
interface ConditionalExpression {
type: 'ConditionalExpression';
test: Expression;
consequent: Expression;
alternate: Expression;
}
/**
* 處理類似:
* antd ? antd.Button : antd.Select;
*/
主要取出類似三元表達式的元素笼踩,同用 buildExpressionHandler 方法進行轉(zhuǎn)換。
IfStatement
IfStatement(path, state) {
const { node } = path;
this.buildExpressionHandler(node, ['test'], path, state);
this.buildExpressionHandler(node.test, ['left', 'right'], path, state);
}
IfStatement(if 語句)
interface IfStatement {
type: 'IfStatement';
test: Expression;
consequent: Statement;
alternate?: Statement;
}
/**
* 處理類似:
* if(antd){ }
*/
這個節(jié)點相對比較特殊掘而,但筆者不明白為什么要調(diào)用兩次 buildExpressionHandler 袍睡,因為筆者所想到的可能性肋僧,都有其他的 AST 入口可以處理嫌吠。望知曉的讀者可進行科普辫诅。
ExpressionStatement
ExpressionStatement(path, state) {
const { node } = path;
const { types } = this;
if (types.isAssignmentExpression(node.expression)) {
this.buildExpressionHandler(node.expression, ['right'], path, state);
}
}
ExpressionStatement(表達式語句)
interface ExpressionStatement {
type: 'ExpressionStatement';
expression: Expression;
directive?: string;
}
/**
* 處理類似:
* module.export = antd
*/
ReturnStatement
ReturnStatement(path, state) {
const { node } = path;
this.buildExpressionHandler(node, ['argument'], path, state);
}
ReturnStatement(return 語句)
interface ReturnStatement {
type: 'ReturnStatement';
argument: Expression | null;
}
/**
* 處理類似:
* return lodash
*/
ExportDefaultDeclaration
ExportDefaultDeclaration(path, state) {
const { node } = path;
this.buildExpressionHandler(node, ['declaration'], path, state);
}
ExportDefaultDeclaration(導出默認模塊)
interface ExportDefaultDeclaration {
type: 'ExportDefaultDeclaration';
declaration: Identifier | BindingPattern | ClassDeclaration | Expression | FunctionDeclaration;
}
/**
* 處理類似:
* return lodash
*/
BinaryExpression
BinaryExpression(path, state) {
const { node } = path;
this.buildExpressionHandler(node, ['left', 'right'], path, state);
}
BinaryExpression(二元操作符表達式)
interface BinaryExpression {
type: 'BinaryExpression';
operator: BinaryOperator;
left: Expression;
right: Expression;
}
/**
* 處理類似:
* antd > 1
*/
NewExpression
NewExpression(path, state) {
const { node } = path;
this.buildExpressionHandler(node, ['callee', 'arguments'], path, state);
}
NewExpression(new 表達式)
interface NewExpression {
type: 'NewExpression';
callee: Expression;
arguments: ArgumentListElement[];
}
/**
* 處理類似:
* new Antd()
*/
ClassDeclaration
ClassDeclaration(path, state) {
const { node } = path;
this.buildExpressionHandler(node, ['superClass'], path, state);
}
ClassDeclaration(類聲明)
interface ClassDeclaration {
type: 'ClassDeclaration';
id: Identifier | null;
superClass: Identifier | null;
body: ClassBody;
}
/**
* 處理類似:
* class emaple extends Antd {...}
*/
Property
Property(path, state) {
const { node } = path;
this.buildDeclaratorHandler(node, ['value'], path, state);
}
Property(對象的屬性值)
/**
* 處理類似:
* const a={
* button:antd.Button
* }
*/
處理完 AST 節(jié)點后簇宽,刪除掉原本的 import 導入,由于我們已經(jīng)把舊 import 的 path 保存在 pluginState.pathsToRemove 中譬嚣,最佳的刪除的時機便是 ProgramExit
拜银,使用 path.remove() 刪除遭垛。
ProgramExit(path, state) {
this.getPluginState(state).pathsToRemove.forEach(p => !p.removed && p.remove());
}
恭喜各位堅持看到現(xiàn)在的讀者锯仪,已經(jīng)到最后一步啦,把我們所處理的所有 AST 節(jié)點類型注冊到觀察者中
export default function({ types }) {
let plugins = null;
function applyInstance(method, args, context) { ... }
const Program = { ... }
// 補充注冊 AST type 的數(shù)組
const methods = [
'ImportDeclaration'
'CallExpression',
'MemberExpression',
'Property',
'VariableDeclarator',
'ArrayExpression',
'LogicalExpression',
'ConditionalExpression',
'IfStatement',
'ExpressionStatement',
'ReturnStatement',
'ExportDefaultDeclaration',
'BinaryExpression',
'NewExpression',
'ClassDeclaration',
]
const ret = {
visitor: { Program },
};
for (const method of methods) { ... }
}
到此已經(jīng)完整分析完 babel-plugin-import
的整個流程小腊,讀者可以重新捋一捋處理按需加載的整個處理思路秩冈,其實拋去細節(jié)入问,主體邏輯還是比較簡單明了的稀颁。
思考
筆者在進行源碼與單元測試的閱讀后匾灶,發(fā)現(xiàn)插件并沒有對 Switch 節(jié)點進行轉(zhuǎn)換,遂向官方倉庫提了 PR,目前已經(jīng)被合入 master 分支张肾,讀者有任何想法锚扎,歡迎在評論區(qū)暢所欲言驾孔。
筆者主要補了 SwitchStatement
惯疙,SwitchCase
與兩個 AST 節(jié)點處理霉颠。
SwitchStatement
SwitchStatement(path, state) {
const { node } = path;
this.buildExpressionHandler(node, ['discriminant'], path, state);
}
SwitchCase
SwitchCase(path, state) {
const { node } = path;
this.buildExpressionHandler(node, ['test'], path, state);
}
總結(jié)
這是筆者第一次寫源碼解析的文章蒿偎,也因筆者能力有限怀读,如果有些邏輯闡述的不夠清晰菜枷,或者在解讀過程中有錯誤的,歡迎讀者在評論區(qū)給出建議或進行糾錯≡啦t,F(xiàn)在 babel 其實也出了一些 API 可以更加簡化 babel-plugin-import
的代碼或者邏輯寝优,例如:path.replaceWithMultiple 枫耳,但源碼中一些看似多余的邏輯一定是有對應的場景迁杨,所以才會被加以保留。此插件經(jīng)受住了時間的考驗捷沸,同時對有需要開發(fā) babel-plugin 的讀者來說痒给,也是一個非常好的事例骏全。不僅如此姜贡,對于功能的邊緣化處理以及操作系統(tǒng)的兼容等細節(jié)都有做完善的處理楼咳。如果僅僅需要使用 babel-plugin-import
烛恤,此文展示了一些在 babel-plugin-import
文檔中未暴露的 API缚柏,也可以幫助插件使用者實現(xiàn)更多擴展功能宾添,因此筆者推出了此文,希望能幫助到各位同學壮莹。