您當前的位置:首頁 > 舞蹈

庖丁解牛:最全babel-plugin-import原始碼詳解

作者:由 DTUX 袋鼠雲前端 發表于 舞蹈時間:2021-03-18

庖丁解牛:最全 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原始碼詳解

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 的節點。

庖丁解牛:最全babel-plugin-import原始碼詳解

最後,根據使用者是否開啟 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,也可以幫助外掛使用者實現更多擴充套件功能,因此筆者推出了此文,希望能幫助到各位同學。

作者資訊

庖丁解牛:最全babel-plugin-import原始碼詳解

標簽: path  STATE  node  節點  import