庖丁解牛:最全babel-plugin-import原始碼詳解
庖丁解牛:最全 babel-plugin-import 原始碼詳解
序言:在用 babel-plugin 實現按需載入一文中筆者用作用域鏈思路實現了按需載入元件。此思路是統一式處理,進入
ImportDeclaration
後,收集依賴,生成新節點,最後利用
作用域( scope )鏈
直接替換了被修改的
specifiers[]
繫結的所有引用名。同樣是利用作用域鏈,可以知曉某一節點是否在上下文中被引用,如果沒有引用就刪除無效節點。乃至最後的替換原節點完成按需載入。這次本文將帶領大家解析
babel-plugin-import
實現按需載入的完整流程,解開業界所認可 babel 外掛的面紗。
首先供上
babel-plugin-import
外掛地址:ant-design/babel-plugin-import
由於在筆者的上一篇文章中已經對 babel 與 babel-plugin 有過介紹了,因此本文不再贅述,直接進入正題。想了解的同學詳情可點選此連結。 眾所周知,庖丁解牛分為三個階段:
第一階段,庖丁剛開始宰牛的時候,對於牛體的結構還不瞭解,看見的只是整頭的牛。
第二階段,三年之後,他見到的是牛的內部肌理筋骨,再也看不見整頭的牛了。
第三階段,現在宰牛的時候,只是用精神去接觸牛的身體就可以了,而不必用眼睛去看。
現在就以這三個階段去逐步遞進
babel-plugin-import
外掛原始碼
Step1 : 始臣之解牛之時,所見無非牛者
首先
babel-plugin-import
是為了解決在打包過程中把專案中引用到的外部元件或功能庫全量打包,從而導致編譯結束後包容量過大的問題,如下圖所示:
babel-plugin-import
外掛原始碼由兩個檔案構成
Index 檔案即是外掛入口初始化的檔案,也是筆者在 Step1 中著重說明的檔案
Plugin 檔案包含了處理各種 AST 節點的方法集,以 Class 形式匯出
先來到外掛的入口檔案 Index :
import
Plugin
from
‘。/Plugin’
;
export
default
function
({
types
})
{
let
plugins
=
null
;
/**
* Program 入口初始化外掛 options 的資料結構
*/
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 ,並且有一個預設匯出函式,函式的引數是被解構出的名叫
types
的引數,它是從 babel 物件中被解構出來的,types 的全稱是
@babel/types
,用於處理 AST 節點的方法集。以這種方式引入後,我們不需要手動引入
@babel/types
。 進入函式後可以看見
觀察者( visitor )
中初始化了一個 AST 節點
Program
,這裡對
Program
節點的處理使用完整外掛結構,有進入( enter )與離開( exit )事件,且需注意:
一般我們縮寫的 Identifier() { 。。。 } 是 Identifier: { enter() { 。。。 } } 的簡寫形式。
這裡可能有同學會問
Program
節點是什麼?見下方 const a = 1 對應的 AST 樹 ( 簡略部分引數 )
{
“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 相當於一個
根節點
,一個完整的原始碼樹。一般在進入該節點的時候進行初始化資料之類的操作,也可理解為該節點先於其他節點執行,同時也是最晚執行 exit 的節點,在 exit 時也可以做一些”善後“的工作。 既然
babel-plugin-import
的
Program
節點處寫了完整的結構,必然在 exit 時也有非常必要的事情需要處理,關於 exit 具體是做什麼的我們稍後進行討論。 我們先看 enter ,這裡首先用 enter 形參 state 結構出使用者制定的外掛引數,驗證必填的
libraryName
[庫名稱] 是否存在。Index 檔案引入的 Plugin 是一個 class 結構,因此需要對 Plugin 進行例項化,並把外掛的所有引數與
@babel/types
全部傳進去,關於 Plugin 類會在下文中進行闡述。 接著呼叫了
applyInstance
函式:
export
default
function
({
types
})
{
let
plugins
=
null
;
/**
* 從類中繼承方法並利用 apply 改變 this 指向,並傳遞 path , state 引數
*/
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
);
},
。。。
}
}
此函式的主要目的是繼承 Plugin 類中的方法,且需要三個引數
method(String):你需要從 Plugin 類中繼承出來的方法名稱
args:(Arrray):[ Path, State ]
PluginPass( Object):內容和 State 一致,確保傳遞內容為最新的 State
主要的目的是讓
Program
的 enter 繼承 Plugin 類的
ProgramEnter
方法,並且傳遞 path 與 state 形參至
ProgramEnter
。
Program
的 exit 同理,繼承的是
ProgramExit
方法。
現在進入 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
;
// 元件名是否轉換以“-”連結的形式
this
。
transformToDefaultImport
=
transformToDefaultImport
||
true
;
// 處理預設匯入
this
。
customName
=
normalizeCustomName
(
customName
);
// 處理轉換結果的函式或路徑
this
。
customStyleName
=
normalizeCustomName
(
customStyleName
);
// 處理轉換結果的函式或路徑
this
。
camel2UnderlineComponentName
=
camel2UnderlineComponentName
;
// 處理成類似 time_picker 的形式
this
。
fileName
=
fileName
||
‘’
;
// 連結到具體的檔案,例如 antd/lib/button/[abc。js]
this
。
types
=
types
;
// babel-types
this
。
pluginStateKey
=
`importPluginState
${
index
}
`
;
}
。。。
}
在入口檔案例項化 Plugin 已經把外掛的引數透過
constructor
後被初始化完畢啦,除了
libraryName
以外其他所有的值均有相應預設值,值得注意的是引數列表中的 customeName 與 customStyleName 可以接收一個函式或者一個引入的路徑,因此需要透過
normalizeCustomName
函式進行統一化處理。
function
normalizeCustomName
(
originCustomName
)
{
if
(
typeof
originCustomName
===
‘string’
)
{
const
customeNameExports
=
require
(
originCustomName
);
return
typeof
customeNameExports
===
‘function’
?
customeNameExports
:
customeNameExports
。
default
;
// 如果customeNameExports不是函式就匯入{default:func()}
}
return
originCustomName
;
}
此函式就是用來處理當引數是路徑時,進行轉換並取出相應的函式。如果處理後
customeNameExports
仍然不是函式就匯入
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 匯入的內容)
pluginState
。
selectedMethods
=
Object
。
create
(
null
);
// 存放經過 importMethod 之後的節點
pluginState
。
pathsToRemove
=
[];
// 儲存需要刪除的節點
/**
* 初始化之後的 state
* state:{
* importPluginState「Number」: {
* specified:{},
* libraryObjs:{},
* select:{},
* pathToRemovw:[]
* },
* opts:{
* 。。。
* },
* 。。。
* }
*/
}
。。。
}
ProgramEnter
中透過
getPluginState
**初始化 state 結構中的
importPluginState
物件,
getPluginState
函式在後續操作中出現非常頻繁,讀者在此需要留意此函式的作用,後文不再對此進行贅述。 但是為什麼需要初始化這麼一個結構呢?這就牽扯到外掛的思路。正像開篇流程圖所述的那樣 ,
babel-plugin-import
具體實現按需載入思路如下:經過 import 節點後收集節點資料,然後從所有可能引用到 import 繫結的節點處執行按需載入轉換方法。state 是一個引用型別,對其進行操作會影響到後續節點的 state 初始值,因此用 Program 節點,在 enter 的時候就初始化這個收集依賴的物件,方便後續操作。負責初始化 state 節點結構與取資料的方法正是
getPluginState
。 這個思路很重要,並且貫穿後面所有的程式碼與目的,請讀者務必理解再往下閱讀。
Step2: 三年之後,未嘗見全牛也
藉由 Step1,現在已經瞭解到外掛以
Program
為出發點繼承了
ProgramEnter
並且初始化了 Plugin 依賴,如果讀者還有尚未梳理清楚的部分,請回到 Step1 仔細消化下內容再繼續閱讀。 首先,我們再回到外圍的 Index 檔案,之前只在觀察者模式中註冊了
Program
的節點,沒有其他 AST 節點入口,因此至少還需注入 import 語句的 AST 節點型別
ImportDeclaration
export
default
function
({
types
})
{
let
plugins
=
null
;
function
applyInstance
(
method
,
args
,
context
)
{
。。。
}
const
Program
=
{
。。。
}
const
methods
=
[
// 註冊 AST type 的陣列
‘ImportDeclaration’
]
const
ret
=
{
visitor
:
{
Program
},
};
// 遍歷陣列,利用 applyInstance 繼承相應方法
for
(
const
method
of
methods
)
{
ret
。
visitor
[
method
]
=
function
()
{
applyInstance
(
method
,
arguments
,
ret
。
visitor
);
};
}
}
建立一個數組並將
ImportDeclaration
置入,經過遍歷呼叫
applyInstance
_ _和 Step1 介紹同理,執行完畢後 visitor 會變成如下結構
visitor:
{
Program:
{
enter:
[Function:
enter],
exit:
[Function:
exit]
}
,
ImportDeclaration:
[
Function
]
,
}
現在迴歸 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 處初始化的結構
if
(
value
===
libraryName
)
{
// AST 庫名與外掛引數名是否一致,一致就進行依賴收集
node
。
specifiers
。
forEach
(
spec
=>
{
if
(
types
。
isImportSpecifier
(
spec
))
{
// 不滿足條件說明 import 是名稱空間引入或預設引入
pluginState
。
specified
[
spec
。
local
。
name
]
=
spec
。
imported
。
name
;
// 儲存為:{ 別名 : 元件名 } 結構
}
else
{
pluginState
。
libraryObjs
[
spec
。
local
。
name
]
=
true
;
// 名稱空間引入或預設引入的值設定為 true
}
});
pluginState
。
pathsToRemove
。
push
(
path
);
// 取值完畢的節點新增進預刪除陣列
}
}
。。。
}
ImportDeclaration
會對 import 中的依賴欄位進行收集,如果是名稱空間引入或者是預設引入就設定為 { 別名 :true },解構匯入就設定為 { 別名 :元件名 } 。
getPluginState
方法在 Step1 中已經進行過說明。關於 import 的 AST 節點結構 用 babel-plugin 實現按需載入 中有詳細說明,本文不再贅述。執行完畢後 pluginState 結構如下
//
例:
import
{
Input,
Button
as
Btn
}
from
‘antd’
{
。。。
importPluginState0:
{
specified:
{
Btn
:
‘Button’,
Input
:
‘Input’
}
,
pathToRemove:
{
[NodePath]
}
。。。
}
。。。
}
這下
state。importPluginState
結構已經收集到了後續幫助節點進行轉換的所有依賴資訊。 目前已經萬事俱備,只欠東風。東風是啥?是能讓轉換 import 工作開始的 action。在 用 babel-plugin 實現按需載入 中收集到依賴的同時也進行了節點轉換與刪除舊節點。一切工作都在
ImportDeclaration
節點中發生。而
babel-plugin-import
的思路是
尋找一切可能引用到 Import 的 AST 節點,對他們全部進行處理。
有部分讀者也許會直接想到去轉換引用了 import
繫結
的 JSX 節點,但是轉換 JSX 節點的意義不大,因為可能引用到 import 繫結的 AST 節點型別 ( type ) 已經夠多了,所有應儘可能的縮小需要轉換的 AST 節點類型範圍。而且 babel 的其他外掛會將我們的 JSX 節點進行轉換成其他 AST type,因此能不考慮 JSX 型別的 AST 樹,可以等其他 babel 外掛轉換後再進行替換工作。其實下一步可以開始的入口有很多,但還是從咱最熟悉的 React。createElement 開始。
class
Hello
extends
React
。
Component
{
render
()
{
return
<
div
>
Hello
<
/div>
}
}
// 轉換後
class
Hello
extends
React
。
Component
{
render
(){
return
React
。
createElement
(
“div”
,
null
,
“Hello”
)
}
}
JSX 轉換後 AST 型別為
CallExpression
(函式執行表示式),結構如下所示,熟悉結構後能方便各位同學對之後步驟有更深入的理解。
{
“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 節點處,繼續轉換流程。
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
);
// 處理一般的呼叫表示式
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外掛返回節點型別與名稱
}
return
arg
;
});
}
。。。
}
可以看見原始碼呼叫了
importMethod
兩次,此函式的作用是觸發 import 轉換成按需載入模式的 action,並返回一個全新的 AST 節點。因為 import 被轉換後,之前我們人工引入的元件名稱會和轉換後的名稱不一樣,因此
importMethod
需要把轉換後的新名字(一個 AST 結構)返回到我們對應 AST 節點的對應位置上,替換掉老元件名。函式原始碼稍後會進行詳細分析。 回到一開始的問題,為什麼
CallExpression
需要呼叫
importMethod
函式?因為這兩處表示的意義是不同的,
CallExpression
節點的情況有兩種:
剛才已經分析過了,這第一種情況是 JSX 程式碼經過轉換後的 React。createElement
我們使用函式呼叫一類的操作程式碼的 AST 也同樣是
CallExpression
型別,例如:
import
lodash
from
‘lodash’
lodash
(
some
values
)
因此在
CallExpression
中首先會判斷 node。callee 值是否是
Identifier
,如果正確則是所述的第二種情況,直接進行轉換。若否,則是 React。createElement 形式,遍歷 React。createElement 的三個引數取出 name,再判斷 name 是否是先前 state。pluginState 收集的 import 的 name,最後檢查 name 的作用域情況,以及追溯 name 的
繫結
是否是一個 import 語句。這些判斷條件都是為了避免錯誤的修改函式原本的語義,防止錯誤修改因
閉包等特性的塊級作用域
中有相同名稱的變數。如果上述條件均滿足那它肯定是需要處理的 import
引用
了。讓其繼續進入
importMethod
轉換函式,
importMethod
需要傳遞三個引數:元件名,File(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
// 根據引數轉換元件名稱
?
transCamel
(
methodName
,
‘_’
)
:
this
。
camel2DashComponentName
?
transCamel
(
methodName
,
‘-’
)
:
methodName
;
/**
* 轉換路徑,優先按照使用者定義的customName進行轉換,如果沒有提供就按照常規拼接路徑
*/
const
path
=
winPath
(
this
。
customName
?
this
。
customName
(
transformedMethodName
,
file
)
:
join
(
this
。
libraryName
,
libraryDirectory
,
transformedMethodName
,
this
。
fileName
),
// eslint-disable-line
);
/**
* 根據是否是預設引入對最終路徑做處理,並沒有對namespace做處理
*/
pluginState
。
selectedMethods
[
methodName
]
=
this
。
transformToDefaultImport
// eslint-disable-line
?
addDefault
(
file
。
path
,
path
,
{
nameHint
:
methodName
})
:
addNamed
(
file
。
path
,
methodName
,
path
);
if
(
this
。
customStyleName
)
{
// 根據使用者指定的路徑引入樣式檔案
const
stylePath
=
winPath
(
this
。
customStyleName
(
transformedMethodName
));
addSideEffect
(
file
。
path
,
`
${
stylePath
}
`
);
}
else
if
(
this
。
styleLibraryDirectory
)
{
// 根據使用者指定的樣式目錄引入樣式檔案
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’
)
{
// 若是函式,根據返回值生成引入
const
stylePath
=
style
(
path
,
file
);
if
(
stylePath
)
{
addSideEffect
(
file
。
path
,
stylePath
);
}
}
}
return
{
。。。
pluginState
。
selectedMethods
[
methodName
]
};
}
。。。
}
進入函式後,先彆著急看程式碼,注意這裡引入了兩個包:path。join 和 @babel/helper-module-imports ,引入 join 是為了處理按需載入路徑快捷拼接的需求,至於 import 語句轉換,肯定需要產生全新的 import AST 節點實現按需載入,最後再把老的 import 語句刪除。而新的 import 節點使用 babel 官方維護的
@babel/helper-module-imports
生成。現在繼續流程,首先無視一開始的 if 條件語句,稍後會做說明。再捋一捋 import 處理函式中需要處理的幾個環節:
對引入的元件名稱進行修改,預設轉換以“-”拼接單詞的形式,例如:DatePicker 轉換為 date-picker,處理轉換的函式是 transCamel。
function
transCamel
(
_str
,
symbol
)
{
const
str
=
_str
[
0
]。
toLowerCase
()
+
_str
。
substr
(
1
);
// 先轉換成小駝峰,以便正則獲取完整單詞
return
str
。
replace
(
/([A-Z])/g
,
$1
=>
`
${
symbol
}${
$1
。
toLowerCase
()
}
`
);
// 例 datePicker,正則抓取到P後,在它前面加上指定的symbol符號
}
轉換到元件所在的具體路徑,如果外掛使用者給定了自定義路徑就使用 customName 進行處理,
babel-plugin-import
為什麼不提供物件的形式作為引數?因為 customName 修改是以 transformedMethodName 值作為基礎並將其傳遞給外掛使用者,如此設計就可以更精確的匹配到需要按需載入的路徑。處理這些動作的函式是 withPath,withPath 主要相容 Linux 作業系統,將 Windows 檔案系統支援的 ‘\’ 統一轉換為 ‘/’。
function
winPath
(
path
)
{
return
path
。
replace
(
/\\/g
,
‘/’
);
// 相容路徑: windows預設使用‘\’,也支援‘/’,但linux不支援‘\’,遂統一轉換成‘/’
}
對 transformToDefaultImport 進行判斷,此選項預設為 true,轉換後的 AST 節點是預設匯出的形式,如果不想要預設匯出可以將 transformToDefaultImport 設定為 false,之後便利用
@babel/helper-module-imports
生成新的 import 節點,最後**函式的返回值就是新 import 節點的 default Identifier,替換掉呼叫 importMethod 函式的節點,從而把所有引用舊 import 繫結的節點替換成最新生成的 import AST 的節點。
最後,根據使用者是否開啟 style 按需引入與 customStyleName 是否有 style 路徑額外處理,以及 styleLibraryDirectory(style 包路徑)等引數處理或生成對應的 css 按需載入節點。
到目前為止一條最基本的轉換線路已經轉換完畢了,相信大家也已經瞭解了按需載入的基本轉換流程,回到 importMethod 函式一開始的
if 判斷語句
,這與我們將在 step3 中的任務息息相關。現在就讓我們一起進入 step3。
Step3: 方今之時,臣以神遇而不以目視,官知止而神欲行
在 step3 中會進行按需載入轉換最後的兩個步驟:
引入 import 繫結的引用肯定不止 JSX 語法,還有其他諸如,三元表示式,類的繼承,運算,判斷語句,返回語法等等型別,我們都得對他們進行處理,確保所有的引用都繫結到最新的 import,這也會導致
importMethod 函式
被重新呼叫,但我們肯定不希望 import 函式被引用了 n 次,生成 n 個新的 import 語句,因此才會有先前的判斷語句。
一開始進入
ImportDeclaration
收集資訊的時候我們只是對其進行了依賴收集工作,並沒有刪除節點。並且我們尚未補充 Program 節點 exit 所做的 action
接下來將以此列舉需要處理的所有 AST 節點,並且會給每一個節點對應的介面(Interface)與例子(不關注語義):
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
*/
如果外掛的選項中沒有關閉 transformToDefaultImport ,這裡會呼叫 importMethod 方法並返回
@babel/helper-module-imports
給予的新節點值。否則會判斷當前值是否是收集到 import 資訊中的一部分以及是否是檔案作用域下的全域性變數,透過獲取作用域檢視其父節點的型別是否是 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
*/
本例中出現 buildDeclaratorHandler 方法,主要確保傳遞的屬性是基礎的 Identifier 型別且是 import 繫結的引用後便進入 importMethod 進行轉換後返回新節點覆蓋原屬性。
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(陣列表示式),介面如下所示
interface ArrayExpression {
type: ‘ArrayExpression’;
elements: ArrayExpressionElement[];
}
/**
* 處理類似:
* [Button, Select, Input]
*/
本例的處理和剛才的其他節點不太一樣,因為陣列的 Element 本身就是一個數組形式,並且我們需要轉換的引用都是陣列元素,因此這裡傳遞的 props 就是類似 [0, 1, 2, 3] 的純陣列,方便後續從 elements 中進行取資料。這裡進行具體轉換的方法是 buildExpressionHandler,
在後續的 AST 節點處理中將會頻繁出現
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 進行轉換,和之前的 buildDeclaratorHandler 方法差不多,只是 props 是陣列形式
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 方法進行轉換
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 方法進行轉換。
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){ }
*/
這個節點相對比較特殊,但筆者不明白為什麼要呼叫兩次 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 節點後,刪除掉原本的 import 匯入,由於我們已經把舊 import 的 path 儲存在 pluginState。pathsToRemove 中,最佳的刪除的時機便是
ProgramExit
,使用 path。remove() 刪除。
ProgramExit
(
path
,
state
)
{
this
。
getPluginState
(
state
)。
pathsToRemove
。
forEach
(
p
=>
!
p
。
removed
&&
p
。
remove
());
}
恭喜各位堅持看到現在的讀者,已經到最後一步啦,把我們所處理的所有 AST 節點型別註冊到觀察者中
export
default
function
({
types
})
{
let
plugins
=
null
;
function
applyInstance
(
method
,
args
,
context
)
{
。。。
}
const
Program
=
{
。。。
}
// 補充註冊 AST type 的陣列
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
)
{
。。。
}
}
到此已經完整分析完
babel-plugin-import
的整個流程,讀者可以重新捋一捋處理按需載入的整個處理思路,其實拋去細節,主體邏輯還是比較簡單明瞭的。
思考
筆者在進行原始碼與單元測試的閱讀後,發現外掛並沒有對 Switch 節點進行轉換,遂向官方倉庫提了 PR,目前已經被合入 master 分支,讀者有任何想法,歡迎在評論區暢所欲言。 筆者主要補了
SwitchStatement
,
SwitchCase
與兩個 AST 節點處理。
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
);
}
總結
這是筆者第一次寫原始碼解析的文章,也因筆者能力有限,如果有些邏輯闡述的不夠清晰,或者在解讀過程中有錯誤的,歡迎讀者在評論區給出建議或進行糾錯。現在 babel 其實也出了一些 API 可以更加簡化
babel-plugin-import
的程式碼或者邏輯,例如:path。replaceWithMultiple ,但原始碼中一些看似多餘的邏輯一定是有對應的場景,所以才會被加以保留。此外掛經受住了時間的考驗,同時對有需要開發 babel-plugin 的讀者來說,也是一個非常好的事例。不僅如此,對於功能的邊緣化處理以及作業系統的相容等細節都有做完善的處理。如果僅僅需要使用
babel-plugin-import
,此文展示了一些在
babel-plugin-import
文件中未暴露的 API,也可以幫助外掛使用者實現更多擴充套件功能,因此筆者推出了此文,希望能幫助到各位同學。
作者資訊
上一篇:靜電地板概述
下一篇:請問跳繩以後怎麼拉伸才不會腿粗?