[萬字總結(jié)] 一文吃透 Webpack 核心原理
背景
Webpack 特別難學(xué)!!!
時至 5.0 版本之后,Webpack 功能集變得非常龐大,包括:模塊打包、代碼分割、按需加載、HMR、Tree-shaking、文件監(jiān)聽、sourcemap、Module Federation、devServer、DLL、多進(jìn)程等等,為了實(shí)現(xiàn)這些功能,webpack 的代碼量已經(jīng)到了驚人的程度:
498 份JS文件 18862 行注釋 73548 行代碼 54 個 module 類型 69 個 dependency 類型 162 個內(nèi)置插件 237 個hook
在這個數(shù)量級下,源碼的閱讀、分析、學(xué)習(xí)成本非常高,加上 webpack 官網(wǎng)語焉不詳?shù)奈臋n,導(dǎo)致 webpack 的學(xué)習(xí)、上手成本極其高。為此,社區(qū)圍繞著 Webpack 衍生出了各種手腳架,比如 vue-cli、create-react-app,解決“用”的問題。
但這又導(dǎo)致一個新的問題,大部分人在工程化方面逐漸變成一個配置工程師,停留在“會用會配”但是不知道黑盒里面到底是怎么轉(zhuǎn)的階段,遇到具體問題就瞎了:
想給基礎(chǔ)庫做個升級,出現(xiàn)兼容性問題跑不動了,直接放棄 想優(yōu)化一下編譯性能,但是不清楚內(nèi)部原理,無從下手
究其原因還是對 webpack 內(nèi)部運(yùn)行機(jī)制沒有形成必要的整體認(rèn)知,無法迅速定位問題 —— 對,連問題的本質(zhì)都常常看不出,所謂的不能透過現(xiàn)象看本質(zhì),那本質(zhì)是啥?我個人將 webpack 整個龐大的體系抽象為三方面的知識:
構(gòu)建的核心流程 loader 的作用 plugin 架構(gòu)與常用套路
三者協(xié)作構(gòu)成 webpack 的主體框架:

理解了這三塊內(nèi)容就算是入了個門,對 Webpack 有了一個最最基礎(chǔ)的認(rèn)知了,工作中再遇到問題也就能按圖索驥了。補(bǔ)充一句,作為一份入門教程,本文不會展開太多 webpack 代碼層面的細(xì)節(jié) —— 我的精力也不允許,所以讀者也不需要看到一堆文字就產(chǎn)生特別大的心理負(fù)擔(dān)。
核心流程解析
首先,我們要理解一個點(diǎn),Webpack 最核心的功能:
At its core, webpack is a static module bundler for modern JavaScript applications.
也就是將各種類型的資源,包括圖片、css、js等,轉(zhuǎn)譯、組合、拼接、生成 JS 格式的 bundler 文件。官網(wǎng)首頁的動畫很形象地表達(dá)了這一點(diǎn):

這個過程核心完成了 內(nèi)容轉(zhuǎn)換 + 資源合并 兩種功能,實(shí)現(xiàn)上包含三個階段:
初始化階段: 初始化參數(shù):從配置文件、 配置對象、Shell 參數(shù)中讀取,與默認(rèn)配置結(jié)合得出最終的參數(shù) 創(chuàng)建編譯器對象:用上一步得到的參數(shù)創(chuàng)建 Compiler對象初始化編譯環(huán)境:包括注入內(nèi)置插件、注冊各種模塊工廠、初始化 RuleSet 集合、加載配置的插件等 開始編譯:執(zhí)行 compiler對象的run方法確定入口:根據(jù)配置中的 entry找出所有的入口文件,調(diào)用compilition.addEntry將入口文件轉(zhuǎn)換為dependence對象構(gòu)建階段: 編譯模塊(make):根據(jù) entry對應(yīng)的dependence創(chuàng)建module對象,調(diào)用loader將模塊轉(zhuǎn)譯為標(biāo)準(zhǔn) JS 內(nèi)容,調(diào)用 JS 解釋器將內(nèi)容轉(zhuǎn)換為 AST 對象,從中找出該模塊依賴的模塊,再 遞歸 本步驟直到所有入口依賴的文件都經(jīng)過了本步驟的處理完成模塊編譯:上一步遞歸處理所有能觸達(dá)到的模塊后,得到了每個模塊被翻譯后的內(nèi)容以及它們之間的 依賴關(guān)系圖 生成階段: 輸出資源(seal):根據(jù)入口和模塊之間的依賴關(guān)系,組裝成一個個包含多個模塊的 Chunk,再把每個Chunk轉(zhuǎn)換成一個單獨(dú)的文件加入到輸出列表,這步是可以修改輸出內(nèi)容的最后機(jī)會寫入文件系統(tǒng)(emitAssets):在確定好輸出內(nèi)容后,根據(jù)配置確定輸出的路徑和文件名,把文件內(nèi)容寫入到文件系統(tǒng)
單次構(gòu)建過程自上而下按順序執(zhí)行,下面會展開聊聊細(xì)節(jié),在此之前,對上述提及的各類技術(shù)名詞不太熟悉的同學(xué),可以先看看簡介:
Entry:編譯入口,webpack 編譯的起點(diǎn)Compiler:編譯管理器,webpack 啟動后會創(chuàng)建compiler對象,該對象一直存活知道結(jié)束退出Compilation:單次編輯過程的管理器,比如watch = true時,運(yùn)行過程中只有一個compiler但每次文件變更觸發(fā)重新編譯時,都會創(chuàng)建一個新的compilation對象Dependence:依賴對象,webpack 基于該類型記錄模塊間依賴關(guān)系Module:webpack 內(nèi)部所有資源都會以“module”對象形式存在,所有關(guān)于資源的操作、轉(zhuǎn)譯、合并都是以 “module” 為基本單位進(jìn)行的Chunk:編譯完成準(zhǔn)備輸出時,webpack 會將module按特定的規(guī)則組織成一個一個的chunk,這些chunk某種程度上跟最終輸出一一對應(yīng)Loader:資源內(nèi)容轉(zhuǎn)換器,其實(shí)就是實(shí)現(xiàn)從內(nèi)容 A 轉(zhuǎn)換 B 的轉(zhuǎn)換器Plugin:webpack構(gòu)建過程中,會在特定的時機(jī)廣播對應(yīng)的事件,插件監(jiān)聽這些事件,在特定時間點(diǎn)介入編譯過程
webpack 編譯過程都是圍繞著這些關(guān)鍵對象展開的,更詳細(xì)完整的信息,可以參考 Webpack 知識圖譜 。
初始化階段
基本流程
學(xué)習(xí)一個項目的源碼通常都是從入口開始看起,按圖索驥慢慢摸索出套路的,所以先來看看 webpack 的初始化過程:

解釋一下:
將 process.args + webpack.config.js合并成用戶配置調(diào)用 validateSchema校驗(yàn)配置調(diào)用 getNormalizedWebpackOptions + applyWebpackOptionsBaseDefaults合并出最終配置創(chuàng)建 compiler對象遍歷用戶定義的 plugins集合,執(zhí)行插件的apply方法調(diào)用 new WebpackOptionsApply().process方法,加載各種內(nèi)置插件
主要邏輯集中在 WebpackOptionsApply 類,webpack 內(nèi)置了數(shù)百個插件,這些插件并不需要我們手動配置,WebpackOptionsApply 會在初始化階段根據(jù)配置內(nèi)容動態(tài)注入對應(yīng)的插件,包括:
注入 EntryOptionPlugin插件,處理entry配置根據(jù) devtool值判斷后續(xù)用那個插件處理sourcemap,可選值:EvalSourceMapDevToolPlugin、SourceMapDevToolPlugin、EvalDevToolModulePlugin注入 RuntimePlugin,用于根據(jù)代碼內(nèi)容動態(tài)注入 webpack 運(yùn)行時
到這里,compiler 實(shí)例就被創(chuàng)建出來了,相應(yīng)的環(huán)境參數(shù)也預(yù)設(shè)好了,緊接著開始調(diào)用 compiler.compile 函數(shù):
// 取自 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 架構(gòu)很靈活,但代價是犧牲了源碼的直觀性,比如說上面說的初始化流程,從創(chuàng)建 compiler 實(shí)例到調(diào)用 make 鉤子,邏輯鏈路很長:
啟動 webpack ,觸發(fā) lib/webpack.js文件中createCompiler方法createCompiler方法內(nèi)部調(diào)用WebpackOptionsApply插件WebpackOptionsApply定義在lib/WebpackOptionsApply.js文件,內(nèi)部根據(jù)entry配置決定注入entry相關(guān)的插件,包括:DllEntryPlugin、DynamicEntryPlugin、EntryPlugin、PrefetchPlugin、ProgressPlugin、ContainerPluginEntry相關(guān)插件,如lib/EntryPlugin.js的EntryPlugin監(jiān)聽compiler.make鉤子lib/compiler.js的compile函數(shù)內(nèi)調(diào)用this.hooks.make.callAsync觸發(fā) EntryPlugin的make回調(diào),在回調(diào)中執(zhí)行compilation.addEntry函數(shù)compilation.addEntry函數(shù)內(nèi)部經(jīng)過一坨與主流程無關(guān)的hook之后,再調(diào)用handleModuleCreate函數(shù),正式開始構(gòu)建內(nèi)容
這個過程需要在 webpack 初始化的時候預(yù)埋下各種插件,經(jīng)歷 4 個文件,7次跳轉(zhuǎn)才開始進(jìn)入主題,前戲太足了,如果讀者對 webpack 的概念、架構(gòu)、組件沒有足夠了解時,源碼閱讀過程會很痛苦。
關(guān)于這個問題,我在文章最后總結(jié)了一些技巧和建議,有興趣的可以滑到附錄閱讀模塊。
構(gòu)建階段
基本流程
你有沒有思考過這樣的問題:
Webpack 編譯過程會將源碼解析為 AST 嗎?webpack 與 babel 分別實(shí)現(xiàn)了什么? Webpack 編譯過程中,如何識別資源對其他資源的依賴? 相對于 grunt、gulp 等流式構(gòu)建工具,為什么 webpack 會被認(rèn)為是新一代的構(gòu)建工具?
這些問題,基本上在構(gòu)建階段都能看出一些端倪。構(gòu)建階段從 entry 開始遞歸解析資源與資源的依賴,在 compilation 對象內(nèi)逐步構(gòu)建出 module 集合以及 module 之間的依賴關(guān)系,核心流程:

解釋一下,構(gòu)建階段從入口文件開始:
調(diào)用 handleModuleCreate,根據(jù)文件類型構(gòu)建module子類調(diào)用 loader-runner 倉庫的 runLoaders轉(zhuǎn)譯module內(nèi)容,通常是從各類資源類型轉(zhuǎn)譯為 JavaScript 文本調(diào)用 acorn 將 JS 文本解析為AST 遍歷 AST,觸發(fā)各種鉤子 在 HarmonyExportDependencyParserPlugin插件監(jiān)聽exportImportSpecifier鉤子,解讀 JS 文本對應(yīng)的資源依賴調(diào)用 module對象的addDependency將依賴對象加入到module依賴列表中AST 遍歷完畢后,調(diào)用 module.handleParseResult處理模塊依賴對于 module新增的依賴,調(diào)用handleModuleCreate,控制流回到第一步所有依賴都解析完畢后,構(gòu)建階段結(jié)束
這個過程中數(shù)據(jù)流 module => ast => dependences => module ,先轉(zhuǎn) AST 再從 AST 找依賴。這就要求 loaders 處理完的最后結(jié)果必須是可以被 acorn 處理的標(biāo)準(zhǔn) JavaScript 語法,比如說對于圖片,需要從圖像二進(jìn)制轉(zhuǎn)換成類似于 export default "data:image/png;base64,xxx" 這類 base64 格式或者 export default "http://xxx" 這類 url 格式。
compilation 按這個流程遞歸處理,逐步解析出每個模塊的內(nèi)容以及 module 依賴關(guān)系,后續(xù)就可以根據(jù)這些內(nèi)容打包輸出。
示例:層級遞進(jìn)
假如有如下圖所示的文件依賴樹:

其中 index.js 為 entry 文件,依賴于 a/b 文件;a 依賴于 c/d 文件。初始化編譯環(huán)境之后,EntryPlugin 根據(jù) entry 配置找到 index.js 文件,調(diào)用 compilation.addEntry 函數(shù)觸發(fā)構(gòu)建流程,構(gòu)建完畢后內(nèi)部會生成這樣的數(shù)據(jù)結(jié)構(gòu):

此時得到 module[index.js] 的內(nèi)容以及對應(yīng)的依賴對象 dependence[a.js] 、dependence[b.js] 。OK,這就得到下一步的線索:a.js、b.js,根據(jù)上面流程圖的邏輯繼續(xù)調(diào)用 module[index.js] 的 handleParseResult 函數(shù),繼續(xù)處理 a.js、b.js 文件,遞歸上述流程,進(jìn)一步得到 a、b 模塊:

從 a.js 模塊中又解析到 c.js/d.js 依賴,于是再再繼續(xù)調(diào)用 module[a.js] 的 handleParseResult ,再再遞歸上述流程:

到這里解析完所有模塊后,發(fā)現(xiàn)沒有更多新的依賴,就可以繼續(xù)推進(jìn),進(jìn)入下一步。
總結(jié)
回顧章節(jié)開始時提到的問題:
Webpack 編譯過程會將源碼解析為 AST 嗎?webpack 與 babel 分別實(shí)現(xiàn)了什么? 構(gòu)建階段會讀取源碼,解析為 AST 集合。 Webpack 讀出 AST 之后僅遍歷 AST 集合;babel 則對源碼做等價轉(zhuǎn)換 Webpack 編譯過程中,如何識別資源對其他資源的依賴? Webpack 遍歷 AST 集合過程中,識別 require/ import之類的導(dǎo)入語句,確定模塊對其他資源的依賴關(guān)系相對于 grant、gulp 等流式構(gòu)建工具,為什么 webpack 會被認(rèn)為是新一代的構(gòu)建工具? Grant、Gulp 僅執(zhí)行開發(fā)者預(yù)定義的任務(wù)流;而 webpack 則深入處理資源的內(nèi)容,功能上更強(qiáng)大
生成階段
基本流程
構(gòu)建階段圍繞 module 展開,生成階段則圍繞 chunks 展開。經(jīng)過構(gòu)建階段之后,webpack 得到足夠的模塊內(nèi)容與模塊關(guān)系信息,接下來開始生成最終資源了。代碼層面,就是開始執(zhí)行 compilation.seal 函數(shù):
// 取自 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 語境下接近于 “將模塊裝進(jìn)蜜罐” 。seal 函數(shù)主要完成從 module 到 chunks 的轉(zhuǎn)化,核心流程:

簡單梳理一下:
構(gòu)建本次編譯的 ChunkGraph對象;遍歷 compilation.modules集合,將module按entry/動態(tài)引入的規(guī)則分配給不同的Chunk對象;compilation.modules集合遍歷完畢后,得到完整的chunks集合對象,調(diào)用createXxxAssets方法createXxxAssets遍歷module/chunk,調(diào)用compilation.emitAssets方法將assets信息記錄到compilation.assets對象中觸發(fā) seal回調(diào),控制流回到compiler對象
這一步的關(guān)鍵邏輯是將 module 按規(guī)則組織成 chunks ,webpack 內(nèi)置的 chunk 封裝規(guī)則比較簡單:
entry及 entry 觸達(dá)到的模塊,組合成一個chunk使用動態(tài)引入語句引入的模塊,各自組合成一個 chunk
chunk 是輸出的基本單位,默認(rèn)情況下這些 chunks 與最終輸出的資源一一對應(yīng),那按上面的規(guī)則大致上可以推導(dǎo)出一個 entry 會對應(yīng)打包出一個資源,而通過動態(tài)引入語句引入的模塊,也對應(yīng)會打包出相應(yīng)的資源,我們來看個示例。
示例:多入口打包
假如有這樣的配置:
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: [],
};
實(shí)例配置中有兩個入口,對應(yīng)的文件結(jié)構(gòu):

index-a 依賴于c,且動態(tài)引入了 e;index-b 依賴于 c/d 。根據(jù)上面說的規(guī)則:
entry及entry觸達(dá)到的模塊,組合成一個 chunk使用動態(tài)引入語句引入的模塊,各自組合成一個 chunk
生成的 chunks 結(jié)構(gòu)為:

也就是根據(jù)依賴關(guān)系,chunk[a] 包含了 index-a/c 兩個模塊;chunk[b] 包含了 c/index-b/d 三個模塊;chunk[e-hash] 為動態(tài)引入 e 對應(yīng)的 chunk。
不知道大家注意到?jīng)]有,chunk[a] 與 chunk[b] 同時包含了 c,這個問題放到具體業(yè)務(wù)場景可能就是,一個多頁面應(yīng)用,所有頁面都依賴于相同的基礎(chǔ)庫,那么這些所有頁面對應(yīng)的 entry 都會包含有基礎(chǔ)庫代碼,這豈不浪費(fèi)?為了解決這個問題,webpack 提供了一些插件如 CommonsChunkPlugin 、SplitChunksPlugin,在基本規(guī)則之外進(jìn)一步優(yōu)化 chunks 結(jié)構(gòu)。
SplitChunksPlugin 的作用
SplitChunksPlugin 是 webpack 架構(gòu)高擴(kuò)展的一個絕好的示例,我們上面說了 webpack 主流程里面是按 entry / 動態(tài)引入 兩種情況組織 chunks 的,這必然會引發(fā)一些不必要的重復(fù)打包,webpack 通過插件的形式解決這個問題。
回顧 compilation.seal 函數(shù)的代碼,大致上可以梳理成這么4個步驟:
遍歷 compilation.modules,記錄下模塊與chunk關(guān)系觸發(fā)各種模塊優(yōu)化鉤子,這一步優(yōu)化的主要是模塊依賴關(guān)系 遍歷 module構(gòu)建 chunk 集合觸發(fā)各種優(yōu)化鉤子

上面 1-3 都是預(yù)處理 + chunks 默認(rèn)規(guī)則的實(shí)現(xiàn),不在我們討論范圍,這里重點(diǎn)關(guān)注第4個步驟觸發(fā)的 optimizeChunks 鉤子,這個時候已經(jīng)跑完主流程的邏輯,得到 chunks 集合,SplitChunksPlugin 正是使用這個鉤子,分析 chunks 集合的內(nèi)容,按配置規(guī)則增加一些通用的 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 插件架構(gòu)的高擴(kuò)展性,使得整個編譯的主流程是可以固化下來的,分支邏輯和細(xì)節(jié)需求“外包”出去由第三方實(shí)現(xiàn),這套規(guī)則架設(shè)起了龐大的 webpack 生態(tài),關(guān)于插件架構(gòu)的更多細(xì)節(jié),下面 plugin 部分有詳細(xì)介紹,這里先跳過。
寫入文件系統(tǒng)
經(jīng)過構(gòu)建階段后,compilation 會獲知資源模塊的內(nèi)容與依賴關(guān)系,也就知道“輸入”是什么;而經(jīng)過 seal 階段處理后, compilation 則獲知資源輸出的圖譜,也就是知道怎么“輸出”:哪些模塊跟那些模塊“綁定”在一起輸出到哪里。seal 后大致的數(shù)據(jù)結(jié)構(gòu):
compilation = {
// ...
modules: [
/* ... */
],
chunks: [
{
id: "entry name",
files: ["output file name"],
hash: "xxx",
runtime: "xxx",
entryPoint: {xxx}
// ...
},
// ...
],
};
seal 結(jié)束之后,緊接著調(diào)用 compiler.emitAssets 函數(shù),函數(shù)內(nèi)部調(diào)用 compiler.outputFileSystem.writeFile 方法將 assets 集合寫入文件系統(tǒng),實(shí)現(xiàn)邏輯比較曲折,但是與主流程沒有太多關(guān)系,所以這里就不展開講了。
資源形態(tài)流轉(zhuǎn)
OK,上面已經(jīng)把邏輯層面的構(gòu)造主流程梳理完了,這里結(jié)合資源形態(tài)流轉(zhuǎn)的角度重新考察整個過程,加深理解:

compiler.make階段:entry文件以dependence對象形式加入compilation的依賴列表,dependence對象記錄有entry的類型、路徑等信息根據(jù) dependence調(diào)用對應(yīng)的工廠函數(shù)創(chuàng)建module對象,之后讀入module對應(yīng)的文件內(nèi)容,調(diào)用loader-runner對內(nèi)容做轉(zhuǎn)化,轉(zhuǎn)化結(jié)果若有其它依賴則繼續(xù)讀入依賴資源,重復(fù)此過程直到所有依賴均被轉(zhuǎn)化為modulecompilation.seal階段:遍歷 module集合,根據(jù)entry配置及引入資源的方式,將module分配到不同的chunk遍歷 chunk集合,調(diào)用compilation.emitAsset方法標(biāo)記chunk的輸出規(guī)則,即轉(zhuǎn)化為assets集合compiler.emitAssets階段:將 assets寫入文件系統(tǒng)
Plugin 解析
網(wǎng)上不少資料將 webpack 的插件架構(gòu)歸類為“事件/訂閱”模式,我認(rèn)為這種歸納有失偏頗。訂閱模式是一種松耦合架構(gòu),發(fā)布器只是在特定時機(jī)發(fā)布事件消息,訂閱者并不或者很少與事件直接發(fā)生交互,舉例來說,我們平常在使用 HTML 事件的時候很多時候只是在這個時機(jī)觸發(fā)業(yè)務(wù)邏輯,很少調(diào)用上下文操作。而 webpack 的鉤子體系是一種強(qiáng)耦合架構(gòu),它在特定時機(jī)觸發(fā)鉤子時會附帶上足夠的上下文信息,插件定義的鉤子回調(diào)中,能也只能與這些上下文背后的數(shù)據(jù)結(jié)構(gòu)、接口交互產(chǎn)生 side effect,進(jìn)而影響到編譯狀態(tài)和后續(xù)流程。
學(xué)習(xí)插件架構(gòu),需要理解三個關(guān)鍵問題:
WHAT: 什么是插件 WHEN: 什么時間點(diǎn)會有什么鉤子被觸發(fā) HOW: 在鉤子回調(diào)中,如何影響編譯狀態(tài)
What: 什么是插件
從形態(tài)上看,插件通常是一個帶有 apply 函數(shù)的類:
class SomePlugin {
apply(compiler) {
}
}
apply 函數(shù)運(yùn)行時會得到參數(shù) compiler ,以此為起點(diǎn)可以調(diào)用 hook 對象注冊各種鉤子回調(diào),例如:compiler.hooks.make.tapAsync ,這里面 make 是鉤子名稱,tapAsync 定義了鉤子的調(diào)用方式,webpack 的插件架構(gòu)基于這種模式構(gòu)建而成,插件開發(fā)者可以使用這種模式在鉤子回調(diào)中,插入特定代碼。webpack 各種內(nèi)置對象都帶有 hooks 屬性,比如 compilation 對象:
class SomePlugin {
apply(compiler) {
compiler.hooks.thisCompilation.tap('SomePlugin', (compilation) => {
compilation.hooks.optimizeChunkAssets.tapAsync('SomePlugin', ()=>{});
})
}
}
鉤子的核心邏輯定義在 Tapable 倉庫,內(nèi)部定義了如下類型的鉤子:
const {
SyncHook,
SyncBailHook,
SyncWaterfallHook,
SyncLoopHook,
AsyncParallelHook,
AsyncParallelBailHook,
AsyncSeriesHook,
AsyncSeriesBailHook,
AsyncSeriesWaterfallHook
} = require("tapable");
不同類型的鉤子根據(jù)其并行度、熔斷方式、同步異步,調(diào)用方式會略有不同,插件開發(fā)者需要根據(jù)這些的特性,編寫不同的交互邏輯,這部分內(nèi)容也特別多,回頭展開聊聊。
When: 什么時候會觸發(fā)鉤子
了解 webpack 插件的基本形態(tài)之后,接下來需要弄清楚一個問題:webpack 會在什么時間節(jié)點(diǎn)觸發(fā)什么鉤子?這一塊我認(rèn)為是知識量最大的一部分,畢竟源碼里面有237個鉤子,但官網(wǎng)只介紹了不到100個,且官網(wǎng)對每個鉤子的說明都太簡短,就我個人而言看完并沒有太大收獲,所以有必要展開聊一下這個話題。先看幾個例子:
compiler.hooks.compilation:時機(jī):啟動編譯創(chuàng)建出 compilation 對象后觸發(fā) 參數(shù):當(dāng)前編譯的 compilation 對象 示例:很多插件基于此事件獲取 compilation 實(shí)例 compiler.hooks.make:時機(jī):正式開始編譯時觸發(fā) 參數(shù):同樣是當(dāng)前編譯的 compilation對象示例:webpack 內(nèi)置的 EntryPlugin基于此鉤子實(shí)現(xiàn)entry模塊的初始化compilation.hooks.optimizeChunks:時機(jī): seal函數(shù)中,chunk集合構(gòu)建完畢后觸發(fā)參數(shù): chunks集合與chunkGroups集合示例: SplitChunksPlugin插件基于此鉤子實(shí)現(xiàn)chunk拆分優(yōu)化compiler.hooks.done:時機(jī):編譯完成后觸發(fā) 參數(shù): stats對象,包含編譯過程中的各類統(tǒng)計信息示例: webpack-bundle-analyzer插件基于此鉤子實(shí)現(xiàn)打包分析
這是我總結(jié)的鉤子的三個學(xué)習(xí)要素:觸發(fā)時機(jī)、傳遞參數(shù)、示例代碼。
觸發(fā)時機(jī)
觸發(fā)時機(jī)與 webpack 工作過程緊密相關(guān),大體上從啟動到結(jié)束,compiler 對象逐次觸發(fā)如下鉤子:

而 compilation 對象逐次觸發(fā):

所以,理解清楚前面說的 webpack 工作的主流程,基本上就可以捋清楚“什么時候會觸發(fā)什么鉤子”。
參數(shù)
傳遞參數(shù)與具體的鉤子強(qiáng)相關(guān),官網(wǎng)對這方面沒有做出進(jìn)一步解釋,我的做法是直接在源碼里面搜索調(diào)用語句,例如對于 compilation.hooks.optimizeTree ,可以在 webpack 源碼中搜索 hooks.optimizeTree.call 關(guān)鍵字,就可以找到調(diào)用代碼:
// lib/compilation.js#2297
this.hooks.optimizeTree.callAsync(this.chunks, this.modules, err => {
});
結(jié)合代碼所在的上下文,可以判斷出此時傳遞的是經(jīng)過優(yōu)化的 chunks 及 modules 集合。
找到示例
Webpack 的鉤子復(fù)雜程度不一,我認(rèn)為最好的學(xué)習(xí)方法還是帶著目的去查詢其他插件中如何使用這些鉤子。例如,在 compilation.seal 函數(shù)內(nèi)部有 optimizeModules 和 afterOptimizeModules 這一對看起來很對偶的鉤子,optimizeModules 從字面上可以理解為用于優(yōu)化已經(jīng)編譯出的 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 的設(shè)計初衷就是用于通知優(yōu)化行為的結(jié)束。
apply 雖然是一個函數(shù),但是從設(shè)計上就只有輸入,webpack 不 care 輸出,所以在插件中只能通過調(diào)用類型實(shí)體的各種方法來或者更改實(shí)體的配置信息,變更編譯行為。例如:
compilation.addModule :添加模塊,可以在原有的 module 構(gòu)建規(guī)則之外,添加自定義模塊 compilation.emitAsset:直譯是“提交資產(chǎn)”,功能可以理解將內(nèi)容寫入到特定路徑
到這里,插件的工作機(jī)理和寫法已經(jīng)有一個很粗淺的介紹了,回頭單拎出來細(xì)講吧。
How: 如何影響編譯狀態(tài)
解決上述兩個問題之后,我們就能理解“如何將特定邏輯插入 webpack 編譯過程”,接下來才是重點(diǎn) —— 如何影響編譯狀態(tài)?強(qiáng)調(diào)一下,webpack 的插件體系與平常所見的 訂閱/發(fā)布 模式差別很大,是一種非常強(qiáng)耦合的設(shè)計,hooks 回調(diào)由 webpack 決定何時,以何種方式執(zhí)行;而在 hooks 回調(diào)內(nèi)部可以通過修改狀態(tài)、調(diào)用上下文 api 等方式對 webpack 產(chǎn)生 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);
});
});
}
}
上述代碼片段調(diào)用了兩個影響 compilation 對象狀態(tài)的接口:
compilation.dependencyFactories.setcompilation.addEntry
操作的具體含義可以先忽略,這里要理解的重點(diǎn)是,webpack 會將上下文信息以參數(shù)或 this (compiler 對象) 形式傳遞給鉤子回調(diào),在回調(diào)中可以調(diào)用上下文對象的方法或者直接修改上下文對象屬性的方式,對原定的流程產(chǎn)生 side effect。所以想純熟地編寫插件,除了要理解調(diào)用時機(jī),還需要了解我們可以用哪一些api,例如:
compilation.addModule:添加模塊,可以在原有的module構(gòu)建規(guī)則之外,添加自定義模塊compilation.emitAsset:直譯是“提交資產(chǎn)”,功能可以理解將內(nèi)容寫入到特定路徑compilation.addEntry:添加入口,功能上與直接定義entry配置相同module.addError:添加編譯錯誤信息...
Loader 介紹
Loader 的作用和實(shí)現(xiàn)比較簡單,容易理解,所以簡單介紹一下就行了。回顧 loader 在編譯流程中的生效的位置:

流程圖中, runLoaders 會調(diào)用用戶所配置的 loader 集合讀取、轉(zhuǎn)譯資源,此前的內(nèi)容可以千奇百怪,但轉(zhuǎn)譯之后理論上應(yīng)該輸出標(biāo)準(zhǔn) JavaScript 文本或者 AST 對象,webpack 才能繼續(xù)處理模塊依賴。
理解了這個基本邏輯之后,loader 的職責(zé)就比較清晰了,不外乎是將內(nèi)容 A 轉(zhuǎn)化為內(nèi)容 B,但是在具體用法層面還挺多講究的,有 pitch、pre、post、inline 等概念用于應(yīng)對各種場景。
為了幫助理解,這里補(bǔ)充一個示例:Webpack 案例 -- vue-loader 原理分析。
附錄
源碼閱讀技巧
**避重就輕:**挑軟柿子捏,比如初始化過程雖然繞,但是相對來說是概念最少、邏輯最清晰的,那從這里入手摸清整個工作過程,可以習(xí)得 webpack 的一些通用套路,例如鉤子的設(shè)計與作用、編碼規(guī)則、命名習(xí)慣、內(nèi)置插件的加載邏輯等,相當(dāng)于先入了個門 **學(xué)會調(diào)試:**多用 ndb單點(diǎn)調(diào)試功能追蹤程序的運(yùn)行,雖然 node 的調(diào)試有很多種方法,但是我個人更推薦ndb,靈活、簡單,配合debugger語句是大殺器**理解架構(gòu):**某種程度上可以將 webpack 架構(gòu)簡化為 compiler + compilation + plugins,webpack 運(yùn)行過程中只會有一個compiler;而每次編譯 —— 包括調(diào)用compiler.run函數(shù)或者watch = true時文件發(fā)生變更,都會創(chuàng)建一個compilation對象。理解這三個核心對象的設(shè)計、職責(zé)、協(xié)作,差不多就能理解 webpack 的核心邏輯了抓大放小: plugin 的關(guān)鍵是“鉤子”,我建議戰(zhàn)略上重視,戰(zhàn)術(shù)上忽視!鉤子畢竟是 webpack 的關(guān)鍵概念,是整個插件機(jī)制的根基,學(xué)習(xí) webpack 根本不可能繞過鉤子,但是相應(yīng)的邏輯跳轉(zhuǎn)實(shí)在太繞太不直觀了,看代碼的時候一直揪著這個點(diǎn)的話,復(fù)雜性會劇增,我的經(jīng)驗(yàn)是: 認(rèn)真看一下 tapable 倉庫的文檔,或者粗略看一下 tapable的源碼,理解同步鉤子、異步鉤子、promise 鉤子、串行鉤子、并行鉤子等概念,對tapable提供的事件模型有一個較為精細(xì)的認(rèn)知,這叫戰(zhàn)略上重視遇到不懂的鉤子別慌,我的經(jīng)驗(yàn)我連這個類都不清楚干啥的,要去理解這些鉤子實(shí)在太難了,不如先略過鉤子本身的含義,去看那些插件用到了它,然后到插件哪里去加 debugger語句單點(diǎn)調(diào)試,等你縷清后續(xù)邏輯的時候,大概率你也知道鉤子的含義了,這叫戰(zhàn)術(shù)上忽視**保持好奇心:**學(xué)習(xí)過程保持旺盛的好奇心和韌性,善于 & 敢于提出問題,然后基于源碼和社區(qū)資料去總結(jié)出自己的答案,問題可能會很多,比如: loader 為什么要設(shè)計 pre、pitch、post、inline? compilation.seal函數(shù)內(nèi)部設(shè)計了很多優(yōu)化型的鉤子,為什么需要區(qū)分的這么細(xì)?webpack 設(shè)計者對不同鉤子有什么預(yù)期?為什么需要那么多 module子類?這些子類分別在什么時候被使用?
Module 與 Module 子類
從上文可以看出,webpack 構(gòu)建階段的核心流程基本上都圍繞著 module 展開,相信接觸過、用過 Webpack 的讀者對 module 應(yīng)該已經(jīng)有一個感性認(rèn)知,但是實(shí)現(xiàn)上 module 的邏輯是非常復(fù)雜繁重的。
以 [email protected] 為例,直接或間接繼承自 Module (webpack/lib/Module.js 文件) 的子類有54個:
無法復(fù)制加載中的內(nèi)容
要一個一個捋清楚這些類的作用實(shí)在太累了,我們需要抓住本質(zhì):module 的作用是什么?
module 是 webpack 資源處理的基本單位,可以認(rèn)為 webpack 對資源的路徑解析、讀入、轉(zhuǎn)譯、分析、打包輸出,所有操作都是圍繞著 module 展開的。有很多文章會說 module = 文件, 其實(shí)這種說法并不準(zhǔn)確,比如子類 AsyncModuleRuntimeModule 就只是一段內(nèi)置的代碼,是一種資源而不能簡單等價于實(shí)際文件。
Webpack 擴(kuò)展性很強(qiáng),包括模塊的處理邏輯上,比如說入口文件是一個普通的 js,此時首先創(chuàng)建 NormalModule 對象,在解析 AST 時發(fā)現(xiàn)這個文件里還包含了異步加載語句,例如 requere.ensure ,那么相應(yīng)地會創(chuàng)建 AsyncModuleRuntimeModule 模塊,注入異步加載的模板代碼。上面類圖的 54 個 module 子類都是為適配各種場景設(shè)計的。
