一文喫透 Webpack 核心原理
背景
Webpack 特別難學!!!
時至 5.0 版本之後,Webpack 功能集變得非常龐大,包括:模塊打包、代碼分割、按需加載、HMR、Tree-shaking、文件監聽、sourcemap、Module Federation、devServer、DLL、多進程等等,爲了實現這些功能,webpack 的代碼量已經到了驚人的程度:
-
498 份 JS 文件
-
18862 行註釋
-
73548 行代碼
-
54 個 module 類型
-
69 個 dependency 類型
-
162 個內置插件
-
237 個 hook
在這個數量級下,源碼的閱讀、分析、學習成本非常高,加上 webpack 官網語焉不詳的文檔,導致 webpack 的學習、上手成本極其高。爲此,社區圍繞着 Webpack 衍生出了各種手腳架,比如 vue-cli、create-react-app,解決 “用” 的問題。
但這又導致一個新的問題,大部分人在工程化方面逐漸變成一個配置工程師,停留在 “會用會配” 但是不知道黑盒裏面到底是怎麼轉的階段,遇到具體問題就瞎了:
-
想給基礎庫做個升級,出現兼容性問題跑不動了,直接放棄
-
想優化一下編譯性能,但是不清楚內部原理,無從下手
究其原因還是對 webpack 內部運行機制沒有形成必要的整體認知,無法迅速定位問題 —— 對,連問題的本質都常常看不出,所謂的不能透過現象看本質,那本質是啥?我個人將 webpack 整個龐大的體系抽象爲三方面的知識:
-
構建的核心流程
-
loader 的作用
-
plugin 架構與常用套路
三者協作構成 webpack 的主體框架:
理解了這三塊內容就算是入了個門,對 Webpack 有了一個最最基礎的認知了,工作中再遇到問題也就能按圖索驥了。補充一句,作爲一份入門教程,本文不會展開太多 webpack 代碼層面的細節 —— 我的精力也不允許,所以讀者也不需要看到一堆文字就產生特別大的心理負擔。
核心流程解析
首先,我們要理解一個點,Webpack 最核心的功能:
At its core, webpack is a static module bundler for modern JavaScript applications.
也就是將各種類型的資源,包括圖片、css、js 等,轉譯、組合、拼接、生成 JS 格式的 bundler 文件。官網首頁的動畫很形象地表達了這一點:
1d66a833-2841-4a8a-a91a-0da800fab306.png
這個過程核心完成了 內容轉換 + 資源合併 兩種功能,實現上包含三個階段:
-
初始化階段:
-
初始化參數:從配置文件、 配置對象、Shell 參數中讀取,與默認配置結合得出最終的參數
-
創建編譯器對象:用上一步得到的參數創建
Compiler
對象 -
初始化編譯環境:包括注入內置插件、註冊各種模塊工廠、初始化 RuleSet 集合、加載配置的插件等
-
開始編譯:執行
compiler
對象的run
方法 -
確定入口:根據配置中的
entry
找出所有的入口文件,調用compilition.addEntry
將入口文件轉換爲dependence
對象 -
構建階段:
-
編譯模塊 (make):根據
entry
對應的dependence
創建module
對象,調用loader
將模塊轉譯爲標準 JS 內容,調用 JS 解釋器將內容轉換爲 AST 對象,從中找出該模塊依賴的模塊,再 遞歸 本步驟直到所有入口依賴的文件都經過了本步驟的處理 -
完成模塊編譯:上一步遞歸處理所有能觸達到的模塊後,得到了每個模塊被翻譯後的內容以及它們之間的 依賴關係圖
-
生成階段:
-
輸出資源 (seal):根據入口和模塊之間的依賴關係,組裝成一個個包含多個模塊的
Chunk
,再把每個Chunk
轉換成一個單獨的文件加入到輸出列表,這步是可以修改輸出內容的最後機會 -
寫入文件系統 (emitAssets):在確定好輸出內容後,根據配置確定輸出的路徑和文件名,把文件內容寫入到文件系統
單次構建過程自上而下按順序執行,下面會展開聊聊細節,在此之前,對上述提及的各類技術名詞不太熟悉的同學,可以先看看簡介:
-
Entry
:編譯入口,webpack 編譯的起點 -
Compiler
:編譯管理器,webpack 啓動後會創建compiler
對象,該對象一直存活知道結束退出 -
Compilation
:單次編輯過程的管理器,比如watch = true
時,運行過程中只有一個compiler
但每次文件變更觸發重新編譯時,都會創建一個新的compilation
對象 -
Dependence
:依賴對象,webpack 基於該類型記錄模塊間依賴關係 -
Module
:webpack 內部所有資源都會以 “module” 對象形式存在,所有關於資源的操作、轉譯、合併都是以 “module” 爲基本單位進行的 -
Chunk
:編譯完成準備輸出時,webpack 會將module
按特定的規則組織成一個一個的chunk
,這些chunk
某種程度上跟最終輸出一一對應 -
Loader
:資源內容轉換器,其實就是實現從內容 A 轉換 B 的轉換器 -
Plugin
:webpack 構建過程中,會在特定的時機廣播對應的事件,插件監聽這些事件,在特定時間點介入編譯過程
webpack 編譯過程都是圍繞着這些關鍵對象展開的,更詳細完整的信息,可以參考 Webpack 知識圖譜 。
初始化階段
學習一個項目的源碼通常都是從入口開始看起,按圖索驥慢慢摸索出套路的,所以先來看看 webpack 的初始化過程:
解釋一下:
-
將
process.args + webpack.config.js
合併成用戶配置 -
調用
validateSchema
校驗配置 -
調用
getNormalizedWebpackOptions + applyWebpackOptionsBaseDefaults
合併出最終配置 -
創建
compiler
對象 -
遍歷用戶定義的
plugins
集合,執行插件的apply
方法 -
調用
new WebpackOptionsApply().process
方法,加載各種內置插件
主要邏輯集中在 WebpackOptionsApply
類,webpack 內置了數百個插件,這些插件並不需要我們手動配置,WebpackOptionsApply
會在初始化階段根據配置內容動態注入對應的插件,包括:
-
注入
EntryOptionPlugin
插件,處理entry
配置 -
根據
devtool
值判斷後續用那個插件處理sourcemap
,可選值:EvalSourceMapDevToolPlugin
、SourceMapDevToolPlugin
、EvalDevToolModulePlugin
-
注入
RuntimePlugin
,用於根據代碼內容動態注入 webpack 運行時
到這裏,compiler
實例就被創建出來了,相應的環境參數也預設好了,緊接着開始調用 compiler.compile
函數:
// 取自 webpack/lib/compiler.js
compile(callback) {
const params = this.newCompilationParams();
this.hooks.beforeCompile.callAsync(params, err => {
// ...
const compilation = this.newCompilation(params);
this.hooks.make.callAsync(compilation, err => {
// ...
this.hooks.finishMake.callAsync(compilation, err => {
// ...
process.nextTick(() => {
compilation.finish(err => {
compilation.seal(err => {...});
});
});
});
});
});
}
Webpack 架構很靈活,但代價是犧牲了源碼的直觀性,比如說上面說的初始化流程,從創建 compiler
實例到調用 make
鉤子,邏輯鏈路很長:
-
啓動 webpack ,觸發
lib/webpack.js
文件中createCompiler
方法 -
createCompiler
方法內部調用WebpackOptionsApply
插件 -
WebpackOptionsApply
定義在lib/WebpackOptionsApply.js
文件,內部根據entry
配置決定注入entry
相關的插件,包括:DllEntryPlugin
、DynamicEntryPlugin
、EntryPlugin
、PrefetchPlugin
、ProgressPlugin
、ContainerPlugin
-
Entry
相關插件,如lib/EntryPlugin.js
的EntryPlugin
監聽compiler.make
鉤子 -
lib/compiler.js
的compile
函數內調用this.hooks.make.callAsync
-
觸發
EntryPlugin
的make
回調,在回調中執行compilation.addEntry
函數 -
compilation.addEntry
函數內部經過一坨與主流程無關的hook
之後,再調用handleModuleCreate
函數,正式開始構建內容
這個過程需要在 webpack 初始化的時候預埋下各種插件,經歷 4 個文件,7 次跳轉纔開始進入主題,前戲太足了,如果讀者對 webpack 的概念、架構、組件沒有足夠了解時,源碼閱讀過程會很痛苦。
關於這個問題,我在文章最後總結了一些技巧和建議,有興趣的可以滑到附錄閱讀模塊。
構建階段
基本流程
你有沒有思考過這樣的問題:
-
Webpack 編譯過程會將源碼解析爲 AST 嗎?webpack 與 babel 分別實現了什麼?
-
Webpack 編譯過程中,如何識別資源對其他資源的依賴?
-
相對於 grunt、gulp 等流式構建工具,爲什麼 webpack 會被認爲是新一代的構建工具?
這些問題,基本上在構建階段都能看出一些端倪。構建階段從 entry
開始遞歸解析資源與資源的依賴,在 compilation
對象內逐步構建出 module
集合以及 module
之間的依賴關係,核心流程:
解釋一下,構建階段從入口文件開始:
-
調用
handleModuleCreate
,根據文件類型構建module
子類 -
調用 loader-runner 倉庫的
runLoaders
轉譯module
內容,通常是從各類資源類型轉譯爲 JavaScript 文本 -
調用 acorn 將 JS 文本解析爲 AST
-
遍歷 AST,觸發各種鉤子
-
在
HarmonyExportDependencyParserPlugin
插件監聽exportImportSpecifier
鉤子,解讀 JS 文本對應的資源依賴 -
調用
module
對象的addDependency
將依賴對象加入到module
依賴列表中 -
AST 遍歷完畢後,調用
module.handleParseResult
處理模塊依賴 -
對於
module
新增的依賴,調用handleModuleCreate
,控制流回到第一步 -
所有依賴都解析完畢後,構建階段結束
這個過程中數據流 module => ast => dependences => module
,先轉 AST 再從 AST 找依賴。這就要求 loaders
處理完的最後結果必須是可以被 acorn 處理的標準 JavaScript 語法,比如說對於圖片,需要從圖像二進制轉換成類似於 export default "data:image/png;base64,xxx"
這類 base64 格式或者 export default "http://xxx"
這類 url 格式。
compilation
按這個流程遞歸處理,逐步解析出每個模塊的內容以及 module
依賴關係,後續就可以根據這些內容打包輸出。
示例:層級遞進
假如有如下圖所示的文件依賴樹:
其中 index.js
爲 entry
文件,依賴於 a/b 文件;a 依賴於 c/d 文件。初始化編譯環境之後,EntryPlugin
根據 entry
配置找到 index.js
文件,調用 compilation.addEntry
函數觸發構建流程,構建完畢後內部會生成這樣的數據結構:
此時得到 module[index.js]
的內容以及對應的依賴對象 dependence[a.js]
、dependence[b.js]
。OK,這就得到下一步的線索:a.js、b.js,根據上面流程圖的邏輯繼續調用 module[index.js]
的 handleParseResult
函數,繼續處理 a.js、b.js 文件,遞歸上述流程,進一步得到 a、b 模塊:
從 a.js 模塊中又解析到 c.js/d.js 依賴,於是再再繼續調用 module[a.js]
的 handleParseResult
,再再遞歸上述流程:
到這裏解析完所有模塊後,發現沒有更多新的依賴,就可以繼續推進,進入下一步。
總結
回顧章節開始時提到的問題:
-
Webpack 編譯過程會將源碼解析爲 AST 嗎?webpack 與 babel 分別實現了什麼?
-
構建階段會讀取源碼,解析爲 AST 集合。
-
Webpack 讀出 AST 之後僅遍歷 AST 集合;babel 則對源碼做等價轉換
-
Webpack 編譯過程中,如何識別資源對其他資源的依賴?
-
Webpack 遍歷 AST 集合過程中,識別
require/ import
之類的導入語句,確定模塊對其他資源的依賴關係 -
相對於 grant、gulp 等流式構建工具,爲什麼 webpack 會被認爲是新一代的構建工具?
-
Grant、Gulp 僅執行開發者預定義的任務流;而 webpack 則深入處理資源的內容,功能上更強大
生成階段
基本流程
構建階段圍繞 module
展開,生成階段則圍繞 chunks
展開。經過構建階段之後,webpack 得到足夠的模塊內容與模塊關係信息,接下來開始生成最終資源了。代碼層面,就是開始執行 compilation.seal
函數:
// 取自 webpack/lib/compiler.js
compile(callback) {
const params = this.newCompilationParams();
this.hooks.beforeCompile.callAsync(params, err => {
// ...
const compilation = this.newCompilation(params);
this.hooks.make.callAsync(compilation, err => {
// ...
this.hooks.finishMake.callAsync(compilation, err => {
// ...
process.nextTick(() => {
compilation.finish(err => {
**compilation.seal**(err => {...});
});
});
});
});
});
}
seal
原意密封、上鎖,我個人理解在 webpack 語境下接近於 “將模塊裝進蜜罐” 。seal
函數主要完成從 module
到 chunks
的轉化,核心流程:
簡單梳理一下:
-
構建本次編譯的
ChunkGraph
對象; -
遍歷
compilation.modules
集合,將module
按entry/動態引入
的規則分配給不同的Chunk
對象; -
compilation.modules
集合遍歷完畢後,得到完整的chunks
集合對象,調用createXxxAssets
方法 -
createXxxAssets
遍歷module/chunk
,調用compilation.emitAssets
方法將資assets
信息記錄到compilation.assets
對象中 -
觸發
seal
回調,控制流回到compiler
對象
這一步的關鍵邏輯是將 module
按規則組織成 chunks
,webpack 內置的 chunk
封裝規則比較簡單:
-
entry
及 entry 觸達到的模塊,組合成一個chunk
-
使用動態引入語句引入的模塊,各自組合成一個
chunk
chunk
是輸出的基本單位,默認情況下這些 chunks
與最終輸出的資源一一對應,那按上面的規則大致上可以推導出一個 entry
會對應打包出一個資源,而通過動態引入語句引入的模塊,也對應會打包出相應的資源,我們來看個示例。
示例:多入口打包
假如有這樣的配置:
const path = require("path");
module.exports = {
mode: "development",
context: path.join(__dirname),
entry: {
a: "./src/index-a.js",
b: "./src/index-b.js",
},
output: {
filename: "[name].js",
path: path.join(__dirname, "./dist"),
},
devtool: false,
target: "web",
plugins: [],
};
實例配置中有兩個入口,對應的文件結構:
index-a
依賴於 c,且動態引入了 e;index-b
依賴於 c/d 。根據上面說的規則:
-
entry
及 entry 觸達到的模塊,組合成一個 chunk -
使用動態引入語句引入的模塊,各自組合成一個 chunk
生成的 chunks
結構爲:
也就是根據依賴關係,chunk[a]
包含了 index-a/c
兩個模塊;chunk[b]
包含了 c/index-b/d
三個模塊;chunk[e-hash]
爲動態引入 e
對應的 chunk。
不知道大家注意到沒有,chunk[a]
與 chunk[b]
同時包含了 c,這個問題放到具體業務場景可能就是,一個多頁面應用,所有頁面都依賴於相同的基礎庫,那麼這些所有頁面對應的 entry
都會包含有基礎庫代碼,這豈不浪費?爲了解決這個問題,webpack 提供了一些插件如 CommonsChunkPlugin
、SplitChunksPlugin
,在基本規則之外進一步優化 chunks
結構。
SplitChunksPlugin
的作用
SplitChunksPlugin
是 webpack 架構高擴展的一個絕好的示例,我們上面說了 webpack 主流程裏面是按 entry / 動態引入
兩種情況組織 chunks
的,這必然會引發一些不必要的重複打包,webpack 通過插件的形式解決這個問題。
回顧 compilation.seal
函數的代碼,大致上可以梳理成這麼 4 個步驟:
-
遍歷
compilation.modules
,記錄下模塊與chunk
關係 -
觸發各種模塊優化鉤子,這一步優化的主要是模塊依賴關係
-
遍歷
module
構建 chunk 集合 -
觸發各種優化鉤子
image (3).png
上面 1-3 都是預處理 + chunks 默認規則的實現,不在我們討論範圍,這裏重點關注第 4 個步驟觸發的 optimizeChunks
鉤子,這個時候已經跑完主流程的邏輯,得到 chunks
集合,SplitChunksPlugin
正是使用這個鉤子,分析 chunks
集合的內容,按配置規則增加一些通用的 chunk :
module.exports = class SplitChunksPlugin {
constructor(options = {}) {
// ...
}
_getCacheGroup(cacheGroupSource) {
// ...
}
apply(compiler) {
// ...
compiler.hooks.thisCompilation.tap("SplitChunksPlugin", (compilation) => {
// ...
compilation.hooks.optimizeChunks.tap(
{
name: "SplitChunksPlugin",
stage: STAGE_ADVANCED,
},
(chunks) => {
// ...
}
);
});
}
};
理解了嗎?webpack 插件架構的高擴展性,使得整個編譯的主流程是可以固化下來的,分支邏輯和細節需求 “外包” 出去由第三方實現,這套規則架設起了龐大的 webpack 生態,關於插件架構的更多細節,下面 plugin
部分有詳細介紹,這裏先跳過。
寫入文件系統
經過構建階段後,compilation
會獲知資源模塊的內容與依賴關係,也就知道 “輸入” 是什麼;而經過 seal
階段處理後, compilation
則獲知資源輸出的圖譜,也就是知道怎麼 “輸出”:哪些模塊跟那些模塊“綁定” 在一起輸出到哪裏。seal
後大致的數據結構:
compilation = {
// ...
modules: [
/* ... */
],
chunks: [
{
id: "entry name",
files: ["output file name"],
hash: "xxx",
runtime: "xxx",
entryPoint: {xxx}
// ...
},
// ...
],
};
seal
結束之後,緊接着調用 compiler.emitAssets
函數,函數內部調用 compiler.outputFileSystem.writeFile
方法將 assets
集合寫入文件系統,實現邏輯比較曲折,但是與主流程沒有太多關係,所以這裏就不展開講了。
資源形態流轉
OK,上面已經把邏輯層面的構造主流程梳理完了,這裏結合資源形態流轉的角度重新考察整個過程,加深理解:
-
compiler.make
階段: -
entry
文件以dependence
對象形式加入compilation
的依賴列表,dependence
對象記錄有entry
的類型、路徑等信息 -
根據
dependence
調用對應的工廠函數創建module
對象,之後讀入module
對應的文件內容,調用loader-runner
對內容做轉化,轉化結果若有其它依賴則繼續讀入依賴資源,重複此過程直到所有依賴均被轉化爲module
-
compilation.seal
階段: -
遍歷
module
集合,根據entry
配置及引入資源的方式,將module
分配到不同的chunk
-
遍歷
chunk
集合,調用compilation.emitAsset
方法標記chunk
的輸出規則,即轉化爲assets
集合 -
compiler.emitAssets
階段: -
將
assets
寫入文件系統
Plugin 解析
網上不少資料將 webpack 的插件架構歸類爲 “事件 / 訂閱” 模式,我認爲這種歸納有失偏頗。訂閱模式是一種松耦合架構,發佈器只是在特定時機發布事件消息,訂閱者並不或者很少與事件直接發生交互,舉例來說,我們平常在使用 HTML 事件的時候很多時候只是在這個時機觸發業務邏輯,很少調用上下文操作。而 webpack 的鉤子體系是一種強耦合架構,它在特定時機觸發鉤子時會附帶上足夠的上下文信息,插件定義的鉤子回調中,能也只能與這些上下文背後的數據結構、接口交互產生 side effect,進而影響到編譯狀態和後續流程。
學習插件架構,需要理解三個關鍵問題:
-
WHAT: 什麼是插件
-
WHEN: 什麼時間點會有什麼鉤子被觸發
-
HOW: 在鉤子回調中,如何影響編譯狀態
What: 什麼是插件
從形態上看,插件通常是一個帶有 apply
函數的類:
class SomePlugin {
apply(compiler) {
}
}
apply
函數運行時會得到參數 compiler
,以此爲起點可以調用 hook
對象註冊各種鉤子回調,例如:compiler.hooks.make.tapAsync
,這裏面 make
是鉤子名稱,tapAsync
定義了鉤子的調用方式,webpack 的插件架構基於這種模式構建而成,插件開發者可以使用這種模式在鉤子回調中,插入特定代碼。webpack 各種內置對象都帶有 hooks
屬性,比如 compilation
對象:
class SomePlugin {
apply(compiler) {
compiler.hooks.thisCompilation.tap('SomePlugin', (compilation) => {
compilation.hooks.optimizeChunkAssets.tapAsync('SomePlugin', ()=>{});
})
}
}
鉤子的核心邏輯定義在 Tapable 倉庫,內部定義瞭如下類型的鉤子:
const {
SyncHook,
SyncBailHook,
SyncWaterfallHook,
SyncLoopHook,
AsyncParallelHook,
AsyncParallelBailHook,
AsyncSeriesHook,
AsyncSeriesBailHook,
AsyncSeriesWaterfallHook
} = require("tapable");
不同類型的鉤子根據其並行度、熔斷方式、同步異步,調用方式會略有不同,插件開發者需要根據這些的特性,編寫不同的交互邏輯,這部分內容也特別多,回頭展開聊聊。
When: 什麼時候會觸發鉤子
瞭解 webpack 插件的基本形態之後,接下來需要弄清楚一個問題:webpack 會在什麼時間節點觸發什麼鉤子?這一塊我認爲是知識量最大的一部分,畢竟源碼裏面有 237 個鉤子,但官網只介紹了不到 100 個,且官網對每個鉤子的說明都太簡短,就我個人而言看完並沒有太大收穫,所以有必要展開聊一下這個話題。先看幾個例子:
-
compiler.hooks.compilation
: -
時機:啓動編譯創建出 compilation 對象後觸發
-
參數:當前編譯的 compilation 對象
-
示例:很多插件基於此事件獲取 compilation 實例
-
compiler.hooks.make
: -
時機:正式開始編譯時觸發
-
參數:同樣是當前編譯的
compilation
對象 -
示例:webpack 內置的
EntryPlugin
基於此鉤子實現entry
模塊的初始化 -
compilation.hooks.optimizeChunks
: -
時機:
seal
函數中,chunk
集合構建完畢後觸發 -
參數:
chunks
集合與chunkGroups
集合 -
示例:
SplitChunksPlugin
插件基於此鉤子實現chunk
拆分優化 -
compiler.hooks.done
: -
時機:編譯完成後觸發
-
參數:
stats
對象,包含編譯過程中的各類統計信息 -
示例:
webpack-bundle-analyzer
插件基於此鉤子實現打包分析
這是我總結的鉤子的三個學習要素:觸發時機、傳遞參數、示例代碼。
觸發時機
觸發時機與 webpack 工作過程緊密相關,大體上從啓動到結束,compiler
對象逐次觸發如下鉤子:
image.png
而 compilation
對象逐次觸發:
image (1).png
所以,理解清楚前面說的 webpack 工作的主流程,基本上就可以捋清楚 “什麼時候會觸發什麼鉤子”。
參數
傳遞參數與具體的鉤子強相關,官網對這方面沒有做出進一步解釋,我的做法是直接在源碼裏面搜索調用語句,例如對於 compilation.hooks.optimizeTree
,可以在 webpack 源碼中搜索 hooks.optimizeTree.call
關鍵字,就可以找到調用代碼:
// lib/compilation.js#2297
this.hooks.optimizeTree.callAsync(this.chunks, this.modules, err => {
});
結合代碼所在的上下文,可以判斷出此時傳遞的是經過優化的 chunks
及 modules
集合。
找到示例
Webpack 的鉤子複雜程度不一,我認爲最好的學習方法還是帶着目的去查詢其他插件中如何使用這些鉤子。例如,在 compilation.seal
函數內部有 optimizeModules
和 afterOptimizeModules
這一對看起來很對偶的鉤子,optimizeModules
從字面上可以理解爲用於優化已經編譯出的 modules
,那 afterOptimizeModules
呢?
從 webpack 源碼中唯一搜索到的用途是 ProgressPlugin
,大體上邏輯如下:
compilation.hooks.afterOptimizeModules.intercept({
name: "ProgressPlugin",
call() {
handler(percentage, "sealing", title);
},
done() {
progressReporters.set(compiler, undefined);
handler(percentage, "sealing", title);
},
result() {
handler(percentage, "sealing", title);
},
error() {
handler(percentage, "sealing", title);
},
tap(tap) {
// p is percentage from 0 to 1
// args is any number of messages in a hierarchical matter
progressReporters.set(compilation.compiler, (p, ...args) => {
handler(percentage, "sealing", title, tap.name, ...args);
});
handler(percentage, "sealing", title, tap.name);
}
});
基本上可以猜測出,afterOptimizeModules
的設計初衷就是用於通知優化行爲的結束。
apply
雖然是一個函數,但是從設計上就只有輸入,webpack 不 care 輸出,所以在插件中只能通過調用類型實體的各種方法來或者更改實體的配置信息,變更編譯行爲。例如:
-
compilation.addModule :添加模塊,可以在原有的 module 構建規則之外,添加自定義模塊
-
compilation.emitAsset:直譯是 “提交資產”,功能可以理解將內容寫入到特定路徑
到這裏,插件的工作機理和寫法已經有一個很粗淺的介紹了,回頭單拎出來細講吧。
How: 如何影響編譯狀態
解決上述兩個問題之後,我們就能理解 “如何將特定邏輯插入 webpack 編譯過程”,接下來纔是重點 —— 如何影響編譯狀態?強調一下,webpack 的插件體系與平常所見的 訂閱 / 發佈 模式差別很大,是一種非常強耦合的設計,hooks 回調由 webpack 決定何時,以何種方式執行;而在 hooks 回調內部可以通過修改狀態、調用上下文 api 等方式對 webpack 產生 side effect。
比如,EntryPlugin
插件:
class EntryPlugin {
apply(compiler) {
compiler.hooks.compilation.tap(
"EntryPlugin",
(compilation, { normalModuleFactory }) => {
compilation.dependencyFactories.set(
EntryDependency,
normalModuleFactory
);
}
);
compiler.hooks.make.tapAsync("EntryPlugin", (compilation, callback) => {
const { entry, options, context } = this;
const dep = EntryPlugin.createDependency(entry, options);
compilation.addEntry(context, dep, options, (err) => {
callback(err);
});
});
}
}
上述代碼片段調用了兩個影響 compilation
對象狀態的接口:
-
compilation.dependencyFactories.set
-
compilation.addEntry
操作的具體含義可以先忽略,這裏要理解的重點是,webpack 會將上下文信息以參數或 this
(compiler 對象) 形式傳遞給鉤子回調,在回調中可以調用上下文對象的方法或者直接修改上下文對象屬性的方式,對原定的流程產生 side effect。所以想純熟地編寫插件,除了要理解調用時機,還需要了解我們可以用哪一些 api,例如:
-
compilation.addModule
:添加模塊,可以在原有的module
構建規則之外,添加自定義模塊 -
compilation.emitAsset
:直譯是 “提交資產”,功能可以理解將內容寫入到特定路徑 -
compilation.addEntry
:添加入口,功能上與直接定義entry
配置相同 -
module.addError
:添加編譯錯誤信息 -
...
Loader 介紹
Loader 的作用和實現比較簡單,容易理解,所以簡單介紹一下就行了。回顧 loader 在編譯流程中的生效的位置:
流程圖中, runLoaders
會調用用戶所配置的 loader 集合讀取、轉譯資源,此前的內容可以千奇百怪,但轉譯之後理論上應該輸出標準 JavaScript 文本或者 AST 對象,webpack 才能繼續處理模塊依賴。
理解了這個基本邏輯之後,loader 的職責就比較清晰了,不外乎是將內容 A 轉化爲內容 B,但是在具體用法層面還挺多講究的,有 pitch、pre、post、inline 等概念用於應對各種場景。
爲了幫助理解,這裏補充一個示例:Webpack 案例 -- vue-loader 原理分析。
附錄
源碼閱讀技巧
-
避重就輕: 挑軟柿子捏,比如初始化過程雖然繞,但是相對來說是概念最少、邏輯最清晰的,那從這裏入手摸清整個工作過程,可以習得 webpack 的一些通用套路,例如鉤子的設計與作用、編碼規則、命名習慣、內置插件的加載邏輯等,相當於先入了個門
-
學會調試: 多用
ndb
單點調試功能追蹤程序的運行,雖然 node 的調試有很多種方法,但是我個人更推薦ndb
,靈活、簡單,配合debugger
語句是大殺器 -
理解架構: 某種程度上可以將 webpack 架構簡化爲
compiler + compilation + plugins
,webpack 運行過程中只會有一個compiler
;而每次編譯 —— 包括調用compiler.run
函數或者watch = true
時文件發生變更,都會創建一個compilation
對象。理解這三個核心對象的設計、職責、協作,差不多就能理解 webpack 的核心邏輯了 -
抓大放小: plugin 的關鍵是 “鉤子”,我建議戰略上重視,戰術上忽視!鉤子畢竟是 webpack 的關鍵概念,是整個插件機制的根基,學習 webpack 根本不可能繞過鉤子,但是相應的邏輯跳轉實在太繞太不直觀了,看代碼的時候一直揪着這個點的話,複雜性會劇增,我的經驗是:
-
認真看一下 tapable 倉庫的文檔,或者粗略看一下
tapable
的源碼,理解同步鉤子、異步鉤子、promise 鉤子、串行鉤子、並行鉤子等概念,對tapable
提供的事件模型有一個較爲精細的認知,這叫戰略上重視 -
遇到不懂的鉤子別慌,我的經驗我連這個類都不清楚幹啥的,要去理解這些鉤子實在太難了,不如先略過鉤子本身的含義,去看那些插件用到了它,然後到插件哪裏去加
debugger
語句單點調試,等你縷清後續邏輯的時候,大概率你也知道鉤子的含義了,這叫戰術上忽視 -
保持好奇心: 學習過程保持旺盛的好奇心和韌性,善於 & 敢於提出問題,然後基於源碼和社區資料去總結出自己的答案,問題可能會很多,比如:
-
loader 爲什麼要設計 pre、pitch、post、inline?
-
compilation.seal
函數內部設計了很多優化型的鉤子,爲什麼需要區分的這麼細?webpack 設計者對不同鉤子有什麼預期? -
爲什麼需要那麼多
module
子類?這些子類分別在什麼時候被使用?
Module
與 Module
子類
從上文可以看出,webpack 構建階段的核心流程基本上都圍繞着 module
展開,相信接觸過、用過 Webpack 的讀者對 module
應該已經有一個感性認知,但是實現上 module
的邏輯是非常複雜繁重的。
以 webpack@5.26.3 爲例,直接或間接繼承自 Module
(webpack/lib/Module.js
文件) 的子類有 54 個:
module 體系. png
要一個一個捋清楚這些類的作用實在太累了,我們需要抓住本質:module
的作用是什麼?
module
是 webpack 資源處理的基本單位,可以認爲 webpack 對資源的路徑解析、讀入、轉譯、分析、打包輸出,所有操作都是圍繞着 module 展開的。有很多文章會說 module = 文件, 其實這種說法並不準確,比如子類 AsyncModuleRuntimeModule
就只是一段內置的代碼,是一種資源而不能簡單等價於實際文件。
Webpack 擴展性很強,包括模塊的處理邏輯上,比如說入口文件是一個普通的 js,此時首先創建 NormalModule 對象,在解析 AST 時發現這個文件裏還包含了異步加載語句,例如 requere.ensure
,那麼相應地會創建 AsyncModuleRuntimeModule
模塊,注入異步加載的模板代碼。上面類圖的 54 個 module 子類都是爲適配各種場景設計的。
轉自: Tecvan
https://juejin.cn/post/6949040393165996040#heading-23
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/Jw_-cZepryo9nbnk1mwjjw