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

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

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

解釋一下:
將 process.args + webpack.config.js合并成用戶配置調用 validateSchema校驗配置調用 getNormalizedWebpackOptions + applyWebpackOptionsBaseDefaults合并出最終配置創(chuàng)建 compiler對象遍歷用戶定義的 plugins集合,執(zhí)行插件的apply方法調用 new WebpackOptionsApply().process方法,加載各種內置插件
主要邏輯集中在 WebpackOptionsApply 類,webpack 內置了數百個插件,這些插件并不需要我們手動配置,WebpackOptionsApply 會在初始化階段根據配置內容動態(tài)注入對應的插件,包括:
注入 EntryOptionPlugin插件,處理entry配置根據 devtool值判斷后續(xù)用那個插件處理sourcemap,可選值:EvalSourceMapDevToolPlugin、SourceMapDevToolPlugin、EvalDevToolModulePlugin注入 RuntimePlugin,用于根據代碼內容動態(tài)注入 webpack 運行時
到這里,compiler 實例就被創(chuàng)建出來了,相應的環(huán)境參數也預設好了,緊接著開始調用 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 架構很靈活,但代價是犧牲了源碼的直觀性,比如說上面說的初始化流程,從創(chuàng)建 compiler 實例到調用 make 鉤子,邏輯鏈路很長:
啟動 webpack ,觸發(fā) lib/webpack.js文件中createCompiler方法createCompiler方法內部調用WebpackOptionsApply插件WebpackOptionsApply定義在lib/WebpackOptionsApply.js文件,內部根據entry配置決定注入entry相關的插件,包括:DllEntryPlugin、DynamicEntryPlugin、EntryPlugin、PrefetchPlugin、ProgressPlugin、ContainerPluginEntry相關插件,如lib/EntryPlugin.js的EntryPlugin監(jiān)聽compiler.make鉤子lib/compiler.js的compile函數內調用this.hooks.make.callAsync觸發(fā) EntryPlugin的make回調,在回調中執(zhí)行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,觸發(fā)各種鉤子 在 HarmonyExportDependencyParserPlugin插件監(jiān)聽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 依賴關系,后續(xù)就可以根據這些內容打包輸出。
示例:層級遞進
假如有如下圖所示的文件依賴樹:

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

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

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

到這里解析完所有模塊后,發(fā)現沒有更多新的依賴,就可以繼續(xù)推進,進入下一步。
總結
回顧章節(jié)開始時提到的問題:
Webpack 編譯過程會將源碼解析為 AST 嗎?webpack 與 babel 分別實現了什么? 構建階段會讀取源碼,解析為 AST 集合。 Webpack 讀出 AST 之后僅遍歷 AST 集合;babel 則對源碼做等價轉換 Webpack 編譯過程中,如何識別資源對其他資源的依賴? Webpack 遍歷 AST 集合過程中,識別 require/ import之類的導入語句,確定模塊對其他資源的依賴關系相對于 grant、gulp 等流式構建工具,為什么 webpack 會被認為是新一代的構建工具? Grant、Gulp 僅執(zhí)行開發(fā)者預定義的任務流;而 webpack 則深入處理資源的內容,功能上更強大
生成階段
基本流程
構建階段圍繞 module 展開,生成階段則圍繞 chunks 展開。經過構建階段之后,webpack 得到足夠的模塊內容與模塊關系信息,接下來開始生成最終資源了。代碼層面,就是開始執(zhí)行 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/動態(tài)引入的規(guī)則分配給不同的Chunk對象;compilation.modules集合遍歷完畢后,得到完整的chunks集合對象,調用createXxxAssets方法createXxxAssets遍歷module/chunk,調用compilation.emitAssets方法將資assets信息記錄到compilation.assets對象中觸發(fā) seal回調,控制流回到compiler對象
這一步的關鍵邏輯是將 module 按規(guī)則組織成 chunks ,webpack 內置的 chunk 封裝規(guī)則比較簡單:
entry及 entry 觸達到的模塊,組合成一個chunk使用動態(tài)引入語句引入的模塊,各自組合成一個 chunk
chunk 是輸出的基本單位,默認情況下這些 chunks 與最終輸出的資源一一對應,那按上面的規(guī)則大致上可以推導出一個 entry 會對應打包出一個資源,而通過動態(tài)引入語句引入的模塊,也對應會打包出相應的資源,我們來看個示例。
示例:多入口打包
假如有這樣的配置:
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,且動態(tài)引入了 e;index-b 依賴于 c/d 。根據上面說的規(guī)則:
entry及entry觸達到的模塊,組合成一個 chunk使用動態(tài)引入語句引入的模塊,各自組合成一個 chunk
生成的 chunks 結構為:

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

上面 1-3 都是預處理 + chunks 默認規(guī)則的實現,不在我們討論范圍,這里重點關注第4個步驟觸發(fā)的 optimizeChunks 鉤子,這個時候已經跑完主流程的邏輯,得到 chunks 集合,SplitChunksPlugin 正是使用這個鉤子,分析 chunks 集合的內容,按配置規(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 插件架構的高擴展性,使得整個編譯的主流程是可以固化下來的,分支邏輯和細節(jié)需求“外包”出去由第三方實現,這套規(guī)則架設起了龐大的 webpack 生態(tài),關于插件架構的更多細節(jié),下面 plugin 部分有詳細介紹,這里先跳過。
寫入文件系統(tǒng)
經過構建階段后,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 集合寫入文件系統(tǒng),實現邏輯比較曲折,但是與主流程沒有太多關系,所以這里就不展開講了。
資源形態(tài)流轉
OK,上面已經把邏輯層面的構造主流程梳理完了,這里結合資源形態(tài)流轉的角度重新考察整個過程,加深理解:

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

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

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

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

要一個一個捋清楚這些類的作用實在太累了,我們需要抓住本質:module 的作用是什么?
module 是 webpack 資源處理的基本單位,可以認為 webpack 對資源的路徑解析、讀入、轉譯、分析、打包輸出,所有操作都是圍繞著 module 展開的。有很多文章會說 module = 文件, 其實這種說法并不準確,比如子類 AsyncModuleRuntimeModule 就只是一段內置的代碼,是一種資源而不能簡單等價于實際文件。
Webpack 擴展性很強,包括模塊的處理邏輯上,比如說入口文件是一個普通的 js,此時首先創(chuàng)建 NormalModule 對象,在解析 AST 時發(fā)現這個文件里還包含了異步加載語句,例如 requere.ensure ,那么相應地會創(chuàng)建 AsyncModuleRuntimeModule 模塊,注入異步加載的模板代碼。上面類圖的 54 個 module 子類都是為適配各種場景設計的。
點贊和在看就是最大的支持??
