[全文高能] 一文吃透 Webpack 核心原理
背景
Webpack 特別難學(xué)!!!
時(shí)至 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 個(gè) module 類型 69 個(gè) dependency 類型 162 個(gè)內(nèi)置插件 237 個(gè)hook
在這個(gè)數(shù)量級(jí)下,源碼的閱讀、分析、學(xué)習(xí)成本非常高,加上 webpack 官網(wǎng)語(yǔ)焉不詳?shù)奈臋n,導(dǎo)致 webpack 的學(xué)習(xí)、上手成本極其高。為此,社區(qū)圍繞著 Webpack 衍生出了各種手腳架,比如 vue-cli、create-react-app,解決“用”的問(wèn)題。
但這又導(dǎo)致一個(gè)新的問(wèn)題,大部分人在工程化方面逐漸變成一個(gè)配置工程師,停留在“會(huì)用會(huì)配”但是不知道黑盒里面到底是怎么轉(zhuǎn)的階段,遇到具體問(wèn)題就瞎了:
想給基礎(chǔ)庫(kù)做個(gè)升級(jí),出現(xiàn)兼容性問(wèn)題跑不動(dòng)了,直接放棄 想優(yōu)化一下編譯性能,但是不清楚內(nèi)部原理,無(wú)從下手
究其原因還是對(duì) webpack 內(nèi)部運(yùn)行機(jī)制沒(méi)有形成必要的整體認(rèn)知,無(wú)法迅速定位問(wèn)題 —— 對(duì),連問(wèn)題的本質(zhì)都常常看不出,所謂的不能透過(guò)現(xiàn)象看本質(zhì),那本質(zhì)是啥?我個(gè)人將 webpack 整個(gè)龐大的體系抽象為三方面的知識(shí):
構(gòu)建的核心流程 loader 的作用 plugin 架構(gòu)與常用套路
三者協(xié)作構(gòu)成 webpack 的主體框架:

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

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

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

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

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

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

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

也就是根據(jù)依賴關(guān)系,chunk[a] 包含了 index-a/c 兩個(gè)模塊;chunk[b] 包含了 c/index-b/d 三個(gè)模塊;chunk[e-hash] 為動(dòng)態(tài)引入 e 對(duì)應(yīng)的 chunk。
不知道大家注意到?jīng)]有,chunk[a] 與 chunk[b] 同時(shí)包含了 c,這個(gè)問(wèn)題放到具體業(yè)務(wù)場(chǎng)景可能就是,一個(gè)多頁(yè)面應(yīng)用,所有頁(yè)面都依賴于相同的基礎(chǔ)庫(kù),那么這些所有頁(yè)面對(duì)應(yīng)的 entry 都會(huì)包含有基礎(chǔ)庫(kù)代碼,這豈不浪費(fèi)?為了解決這個(gè)問(wèn)題,webpack 提供了一些插件如 CommonsChunkPlugin 、SplitChunksPlugin,在基本規(guī)則之外進(jìn)一步優(yōu)化 chunks 結(jié)構(gòu)。
SplitChunksPlugin 的作用
SplitChunksPlugin 是 webpack 架構(gòu)高擴(kuò)展的一個(gè)絕好的示例,我們上面說(shuō)了 webpack 主流程里面是按 entry / 動(dòng)態(tài)引入 兩種情況組織 chunks 的,這必然會(huì)引發(fā)一些不必要的重復(fù)打包,webpack 通過(guò)插件的形式解決這個(gè)問(wèn)題。
回顧 compilation.seal 函數(shù)的代碼,大致上可以梳理成這么4個(gè)步驟:
遍歷 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個(gè)步驟觸發(fā)的 optimizeChunks 鉤子,這個(gè)時(shí)候已經(jīng)跑完主流程的邏輯,得到 chunks 集合,SplitChunksPlugin 正是使用這個(gè)鉤子,分析 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ò)展性,使得整個(gè)編譯的主流程是可以固化下來(lái)的,分支邏輯和細(xì)節(jié)需求“外包”出去由第三方實(shí)現(xiàn),這套規(guī)則架設(shè)起了龐大的 webpack 生態(tài),關(guān)于插件架構(gòu)的更多細(xì)節(jié),下面 plugin 部分有詳細(xì)介紹,這里先跳過(guò)。
寫入文件系統(tǒng)
經(jīng)過(guò)構(gòu)建階段后,compilation 會(huì)獲知資源模塊的內(nèi)容與依賴關(guān)系,也就知道“輸入”是什么;而經(jīng)過(guò) 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)邏輯比較曲折,但是與主流程沒(méi)有太多關(guān)系,所以這里就不展開講了。
資源形態(tài)流轉(zhuǎn)
OK,上面已經(jīng)把邏輯層面的構(gòu)造主流程梳理完了,這里結(jié)合資源形態(tài)流轉(zhuǎn)的角度重新考察整個(gè)過(guò)程,加深理解:

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

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

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

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