徹底理解 Webpack 運行時
背景
=====
-
Webpack 的構建產物包含那些內容?產物如何支持諸如模塊化、異步加載、HMR 特性?
-
何謂運行時?Webpack 構建過程中如何收集運行時依賴?如何將運行時與業務代碼合併輸出到
bundle
?
實際上,本文及前面幾篇原理性質的文章,可能並不能馬上解決你在業務中可能正在面臨的現實問題,但放到更長的時間維度,這些文章所呈現的知識、思維、思辨過程可能能夠長遠地給到你:
-
分析、理解複雜開源代碼的能力
-
理解 Webpack 架構及實現細節,下次遇到問題的時候能根據表象迅速定位到根源
-
理解 Webpack 爲 hooks、loader 提供的上下文,能夠更通暢地理解其它開源組件,甚至能夠自如地實現自己的組件
所以,希望感興趣的同學能夠堅持,我後續還會輸出很多關於 Webpack 實現原理的文章!如果你恰好也想提升自己在 Webpack 方面的知識儲備,關注我,我們一起學習!
編譯產物分析
爲了正常、正確運行業務項目,Webpack 需要將開發者編寫的業務代碼以及支撐、調配這些業務代碼的**「運行時」**一併打包到產物 (bundle) 中,以建築作類比的話,業務代碼相當於磚瓦水泥,是看得見摸得着能直接感知的邏輯;運行時相當於掩埋在磚瓦之下的鋼筋地基,通常不會關注但決定了整座建築的功能、質量。
-
異步按需加載
-
HMR
-
WASM
-
Module Federation
下面先從最簡單的示例開始,逐步展開了解各個特性下的 Webpack 運行時代碼。
基本結構
先從一個最簡單的示例開始,對於下面的代碼結構:
// a.js
export default 'a module';
// index.js
import name from './a'
console.log(name)
使用如下配置:
module.exports = {
entry: "./src/index",
mode: "development",
devtool: false,
output: {
filename: "[name].js",
path: path.join(__dirname, "./dist"),
},
};
配置的內容比較簡單,就不展開講了,直接看編譯生成的結果:
雖然看起來很非主流,但細心分析還是能拆解出代碼脈絡的,bundle 整體由一個 IIFE 包裹,裏面的內容從上到下依次爲:
-
__webpack_modules__
對象,包含了除入口外的所有模塊,示例中即a.js
模塊 -
__webpack_module_cache__
對象,用於存儲被引用過的模塊 -
__webpack_require__
函數,實現模塊引用 (require) 邏輯 -
__webpack_require__.d
,工具函數,實現將模塊導出的內容附加的模塊對象上 -
__webpack_require__.o
,工具函數,判斷對象屬性用 -
__webpack_require__.r
,工具函數,在 ESM 模式下聲明 ESM 模塊標識 -
最後的 IIFE,對應 entry 模塊即上述示例的
index.js
,用於啓動整個應用
這幾個 __webpack_
開頭奇奇怪怪的函數可以統稱爲 Webpack 運行時代碼,作用如前面所說的是搭起整個業務項目的骨架,就上述簡單示例所羅列出來的幾個函數、對象而言,它們協作構建起一個簡單的模塊化體系從而實現 ES Module 規範所聲明的模塊化特性。
上述示例中最終的函數是 __webpack_require__
,它實現了模塊間引用功能,核心代碼:
function __webpack_require__(moduleId) {
/******/ // 如果模塊被引用過
/******/ var cachedModule = __webpack_module_cache__[moduleId];
/******/ if (cachedModule !== undefined) {
/******/ return cachedModule.exports;
/******/
}
/******/ // Create a new module (and put it into the cache)
/******/ var module = (__webpack_module_cache__[moduleId] = {
/******/ // no module.id needed
/******/ // no module.loaded needed
/******/ exports: {},
/******/
});
/******/
/******/ // Execute the module function
/******/ __webpack_modules__[moduleId](
module,
module.exports,
__webpack_require__
);
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/
}
從代碼可以推測出,它的功能:
-
根據
moduleId
參數找到對應的模塊代碼,執行並返回結果 -
如果
moduleId
對應的模塊被引用過,則直接返回存儲在__webpack_module_cache__
緩存對象中的導出內容,避免重複執行
其中,業務模塊代碼被存儲在 bundle 最開始的 __webpack_modules__
變量中,內容如:
var __webpack_modules__ = {
"./src/a.js": (
__unused_webpack_module,
__webpack_exports__,
__webpack_require__
) => {
// ...
},
};
結合 __webpack_require__
函數與 __webpack_modules__
變量就可以正確地引用到代碼模塊,例如上例生成代碼最後面的 IIFE:
(() => {
/*!**********************!*\
!*** ./src/index.js ***!
\**********************/
/* harmony import */ var _a__WEBPACK_IMPORTED_MODULE_0__ =
__webpack_require__(/*! ./a */ "./src/a.js");
console.log(_a__WEBPACK_IMPORTED_MODULE_0__.name);
})();
這幾個函數、對象構成了 Webpack 運行時最基本的能力 —— 模塊化,它們的生成規則與原理我們放到文章第二節《實現原理》再講,下面我們繼續看看異步模塊加載、模塊熱更新場景下對應的運行時內容。
異步模塊加載
我們來看個簡單的異步模塊加載示例:
// ./src/a.js
export default "module-a"
// ./src/index.js
import('./a').then(console.log)
Webpack 配置跟上例相似:
module.exports = {
entry: "./src/index",
mode: "development",
devtool: false,
output: {
filename: "[name].js",
path: path.join(__dirname, "./dist"),
},
};
生成的代碼太長,就不貼了,相比於最開始的基本結構示例所示的模塊化功能,使用異步模塊加載特性時,會額外增加如下運行時:
-
__webpack_require__.e
:邏輯上包裹了一層中間件模式與promise.all
,用於異步加載多個模塊 -
__webpack_require__.f
:供__webpack_require__.e
使用的中間件對象,例如使用 Module Federation 特性時就需要在這裏註冊中間件以修改 e 函數的執行邏輯 -
__webpack_require__.u
:用於拼接異步模塊名稱的函數 -
__webpack_require__.l
:基於 JSONP 實現的異步模塊加載函數 -
__webpack_require__.p
:當前文件的完整 URL,可用於計算異步模塊的實際 URL
建議讀者運行示例對比實際生成代碼,感受它們的具體功能。這幾個運行時模塊構建起 Webpack 異步加載能力,其中最核心的是 __webpack_require__.e
函數,它的代碼很簡單:
__webpack_require__.f = {};
/******/ // This file contains only the entry chunk.
/******/ // The chunk loading function for additional chunks
/******/ __webpack_require__.e = (chunkId) => {
/******/ return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => {
/******/ __webpack_require__.f[key](chunkId, promises);
/******/ return promises;
/******/ }, []));
/******/ };
從代碼看,只是實現了一套基於 __webpack_require__.f
的中間件模式,以及用 Promise.all
實現並行處理,實際加載工作由 __webpack_require__.f.j
與 __webpack_require__.l
實現,分開來看兩個函數:
/******/ __webpack_require__.f.j = (chunkId, promises) => {
/******/ // JSONP chunk loading for javascript
/******/ var installedChunkData = __webpack_require__.o(installedChunks, chunkId) ? installedChunks[chunkId] : undefined;
/******/ if(installedChunkData !== 0) { // 0 means "already installed".
/******/
/******/ // a Promise means "currently loading".
/******/ if(installedChunkData) {
/******/ promises.push(installedChunkData[2]);
/******/ } else {
/******/ if(true) { // all chunks have JS
/******/ // ...
/******/ // start chunk loading
/******/ var url = __webpack_require__.p + __webpack_require__.u(chunkId);
/******/ // create error before stack unwound to get useful stacktrace later
/******/ var error = new Error();
/******/ var loadingEnded = ...;
/******/ __webpack_require__.l(url, loadingEnded, "chunk-" + chunkId, chunkId);
/******/ } else installedChunks[chunkId] = 0;
/******/ }
/******/ }
/******/ };
__webpack_require__.f.j
實現了異步 chunk
路徑的拼接、緩存、異常處理三個方面的邏輯,而 __webpack_require__.l
函數:
/******/ var inProgress = {};
/******/ // data-webpack is not used as build has no uniqueName
/******/ // loadScript function to load a script via script tag
/******/ __webpack_require__.l = (url, done, key, chunkId) => {
/******/ if(inProgress[url]) { inProgress[url].push(done); return; }
/******/ var script, needAttach;
/******/ if(key !== undefined) {
/******/ var scripts = document.getElementsByTagName("script");
/******/ // ...
/******/ }
/******/ // ...
/******/ inProgress[url] = [done];
/******/ var onScriptComplete = (prev, event) => {
/******/ // ...
/******/ }
/******/ ;
/******/ var timeout = setTimeout(onScriptComplete.bind(null, undefined, { type: 'timeout', target: script }), 120000);
/******/ script.onerror = onScriptComplete.bind(null, script.onerror);
/******/ script.onload = onScriptComplete.bind(null, script.onload);
/******/ needAttach && document.head.appendChild(script);
/******/ };
__webpack_require__.l
中通過 script 實現異步 chunk 內容的加載與執行。
e + l + f.j
三個運行時函數支撐起 Webpack 異步模塊運行的能力,落到實際用法上只需要調用 e 函數即可完成異步模塊加載、運行,例如上例對應生成的 entry
內容:
/*!**********************!*\
!*** ./src/index.js ***!
\**********************/
__webpack_require__.e(/*! import() */ "src_a_js").then(__webpack_require__.bind(__webpack_require__, /*! ./a */ "./src/a.js"))
模塊熱更新
模塊熱更新 —— HMR 是一個能顯著提高開發效率的能力,它能夠在模塊代碼出現變化的時候,單獨編譯該模塊並將最新的編譯結果傳送到瀏覽器,瀏覽器再用新的模塊代碼替換掉舊的代碼,從而實現模塊級別的代碼熱替換能力。落到最終體驗上,開發者啓動 Webpack 後,編寫、修改代碼的過程中不需要手動刷新瀏覽器頁面,所有變更能夠實時同步呈現到頁面中。
實現上,HMR 的實現鏈路很長也比較有意思,我們後續會單開一篇文章討論,本文主要關注 HMR 特性所帶入運行時代碼。啓動 HMR 能力需要用到一些特殊的配置項:
module.exports = {
entry: "./src/index",
mode: "development",
devtool: false,
output: {
filename: "[name].js",
path: path.join(__dirname, "./dist"),
},
// 簡單起見,這裏使用 HtmlWebpackPlugin 插件自動生成作爲 host 的 html 文件
plugins: [
new HtmlWebpackPlugin({
title: "Hot Module Replacement",
}),
],
// 配置 devServer 屬性,啓動 HMR
devServer: {
contentBase: "./dist",
hot: true,
writeToDisk: true,
},
按照上述配置,使用命令 webpack serve --hot-only
啓動 Webpack,就可以在 dist 文件夾找到產物:
相比於前面兩個示例,HMR 所產生運行時代碼達到 1.5w+ 行,簡直可以用炸裂來形容。主要的運行時內容有:
-
支持 HMR 所需要用到的
webpack-dev-server
、webpack/hot/xxx
、querystring
等框架,這一部分佔了大部分代碼 -
__webpack_require__.l
:與異步模塊加載一樣,基於 JSONP 實現的異步模塊加載函數 -
__webpack_require__.e
:與異步模塊加載一樣 -
__webpack_require__.f
:與異步模塊加載一樣 -
__webpack_require__.hmrF
:用於拼接熱更新模塊 url 的函數 -
webpack/runtime/hot
:這不是單個對象或函數,而是包含了一堆實現模塊替換的方法
可以看到, HMR 運行時是上面異步模塊加載運行時的超集,而異步模塊加載的運行時又是第一個基本示例運行時的超集,層層疊加。在 HMR 中包含了:
-
模塊化能力
-
異步模塊加載能力 —— 實現變更模塊的異步加載
-
熱替換能力 —— 用拉取到的新模塊替換掉舊的模塊,並觸發熱更新事件
內容過多,我們放到下次專門開一篇文章聊聊 HMR。
實現原理
仔細閱讀上述三個示例,相信讀者應該已經模模糊糊捕捉到一些重要規則:
-
除了業務代碼外,bundle 中還必須包含**「運行時」**代碼才能正常運行
-
「運行時的具體內容由業務代碼,確切地說由業務代碼所使用到的特性決定」,例如使用到異步加載時需要打包
__webpack_require__.e
函數,那麼這裏面必然有一個運行時依賴收集的過程 -
開發者編寫的業務代碼會被包裹進恰當的運行時函數中,實現整體協調
落到 Webpack 源碼實現上,運行時的生成邏輯可以劃分爲兩個步驟:
-
「依賴收集」:遍歷業務代碼模塊收集模塊的特性依賴,從而確定整個項目對 Webpack runtime 的依賴列表
-
「生成」:合併 runtime 的依賴列表,打包到最終輸出的 bundle
兩個步驟都發生在打包階段,即 Webpack(v5) 源碼的 compilation.seal
函數中:
上圖是我總結的 Webpack 知識圖譜的一部分,可關注公衆號【Tecvan】 回覆【1】獲取線上地址
注意上圖,進入 runtime 處理環節時 Webpack 已經解析得出 ModuleDependencyGraph
及 ChunkGraph
關係,也就意味着此時已經可以計算出:
-
需要輸出那些
chunk
-
每個
chunk
包含那些module
,以及每個module
的內容 -
chunk
與chunk
之間的父子依賴關係
對 bundle、module、chunk 關係這幾個概念還不太清晰的同學,建議擴展閱讀:
基於這些信息,接下來首先需要收集運行時依賴。
依賴收集
Webpack runtime 的依賴概念上很像 Vue 的依賴,都是用來表達模塊對其它模塊存在依附關係,只是實現方法上 Vue 基於動態、在運行過程中收集,而 Webpack 則基於靜態代碼分析的方式收集依賴。實現邏輯大致爲:
運行時依賴的計算邏輯集中在 compilation.processRuntimeRequirements
函數,代碼上包含三次循環:
-
第一次循環遍歷所有
module
,收集所有module
的 runtime 依賴 -
第二次循環遍歷所有
chunk
,將chunk
下所有module
的 runtime 統一收錄到chunk
中 -
第三次循環遍歷所有 runtime chunk,收集其對應的子
chunk
下所有 runtime 依賴,之後遍歷所有依賴併發布runtimeRequirementInTree
鉤子,(主要是)RuntimePlugin
插件訂閱該鉤子並根據依賴類型創建對應的RuntimeModule
子類實例
下面我們展開聊聊細節。
第一次循環:收集模塊依賴
在打包 (seal) 階段,完成 ChunkGraph
的構建之後,Webpack 會緊接着調用 codeGeneration
函數遍歷 module
數組,調用它們的 module.codeGeneration
函數執行模塊轉譯,模塊轉譯結果如:
其中,sources 屬性爲模塊經過轉譯後的結果;而 runtimeRequirements
則是基於 AST 計算出來的,爲運行該模塊時所需要用到的運行時,計算過程與本文主題無關,挖個坑下一回我們再繼續講。
所有模塊轉譯完畢後,開始調用 compilation.processRuntimeRequirements
進入第一重循環,將上述轉譯結果的 runtimeRequirements
記錄到 ChunkGraph
對象中。
第二次循環:整合 chunk 依賴
第一次循環針對 module
收集依賴,第二次循環則遍歷 chunk
數組,收集將其對應所有 module
的 runtime 依賴,例如:
示例圖中,module a
包含兩個運行時依賴;module b
包含一個運行時依賴,則經過第二次循環整合後,對應的 chunk
會包含兩個模塊對應的三個運行時依賴。
第三次循環:依賴標識轉 RuntimeModule 對象
源碼中,第三次循環的代碼最少但邏輯最複雜,大致上執行三個操作:
-
遍歷所有 runtime chunk,收集其所有子
chunk
的 runtime 依賴 -
爲該 runtime chunk 下的所有依賴發佈
runtimeRequirementInTree
鉤子 -
RuntimePlugin
監聽鉤子,並根據 runtime 依賴的標識信息創建對應的RuntimeModule
子類對象,並將對象加入到ModuleDepedencyGraph
和ChunkGraph
體系中管理
至此,runtime 依賴完成了從 module
內容解析,到收集,到創建依賴對應的 Module
子類,再將 Module
加入到 ModuleDepedencyGraph
/ChunkGraph
體系的全流程,業務代碼及運行時代碼對應的模塊依賴關係圖完全 ready,可以準備進入下一階段 —— 生成最終產物。
但在繼續講解產物邏輯之前,我們有必要先解決兩個問題:
-
何謂 runtime chunk?與普通
chunk
是什麼關係 -
何謂
RuntimeModule
?與普通Module
有什麼區別
總結:Chunk 與 Runtime Chunk
在上一篇文章 有點難的 webpack 知識點:Chunk 分包規則詳解 我嘗試完整地講解 Webpack 默認分包規則,回顧一下在三種特定的情況下,Webpack 會創建新的 chunk
:
-
每個 entry 項都會對應生成一個
chunk
對象,稱之爲initial chunk
-
每個異步模塊都會對應生成一個
chunk
對象,稱之爲async chunk
-
Webpack 5 之後,如果 entry 配置中包含 runtime 值,則在 entry 之外再增加一個專門容納 runtime 的 chunk 對象,此時可以稱之爲 runtime chunk
默認情況下 initial chunk
通常包含運行該 entry 所需要的所有 runtime 代碼,但 webpack 5 之後出現的第三條規則打破了這一限制,允許開發者將 runtime 從 initial chunk
中剝離出來獨立爲一個多 entry 間可共享的 runtime chunk
。
類似的,異步模塊對應 runtime 代碼大部分都被包含在對應的引用者身上,比如說:
// a.js
export default 'a-module'
// index.js
// 異步引入 a 模塊
import('./a').then(console.log)
在這個示例中,index 異步引入 a 模塊,那麼按默認分配規則會產生兩個 chunk
:入口文件 index 對應的 initial chunk
、異步模塊 a 對應的 async chunk
。此時從 ChunkGraph
的角度看 chunk[index]
爲 chunk[a]
的父級,運行時代碼會被打入 chunk[index]
,站在瀏覽器的角度,運行 chunk[a]
之前必須先運行 chunk[index]
,兩者形成明顯的父子關係。
總結:RuntimeModule 體系
在最開始閱讀 Webpack 源碼的時候,我就覺得很奇怪,Module
是 Webpack 資源管理的基本單位,但 Module
底下總共衍生出了 54 個子類,且大部分爲 Module => RuntimeModule => xxxRuntimeModule
的繼承關係:
在 有點難的 webpack 知識點:Dependency Graph 深度解析 一文中我們聊到模塊依賴關係圖的生成過程及作用,但文章的內容主要圍繞業務代碼展開,用到的大多是 NormalModule
。到 seal
函數收集運行時的過程中,RuntimePlugin
還會爲運行時依賴一一創建對應的 RuntimeModule
子類,例如:
-
模塊化實現中依賴
__webpack_require__.r
,則對應創建MakeNamespaceObjectRuntimeModule
對象 -
ESM 依賴
__webpack_require__.o
,則對應創建HasOwnPropertyRuntimeModule
對象 -
異步模塊加載依賴
__webpack_require__.e
,則對應創建EnsureChunkRuntimeModule
對象 -
等等
所以可以推導出所有 RuntimeModule
結尾的類型與特定的運行時功能一一對應,收集依賴的結果就是在業務代碼之外創建出一堆支撐性質的 RuntimeModule
子類,這些子類對象隨後被加入 ModuleDependencyGraph
,併入整個模塊依賴體系中。
資源合併生成
經過上面的運行時依賴收集過程後,bundle 所需要的所有內容都就緒了,接着就可以準備寫出到文件中,即下圖核心流程中的生成 (emit) 階段:
我的另一篇 [萬字總結] 一文喫透 Webpack 核心原理 對這一塊有比較細緻的講解,這裏從運行時的視角再簡單聊一下代碼流程:
-
調用
compilation.createChunkAssets
,遍歷chunks
將 chunk 對應的所有module
,包括業務模塊、運行時模塊全部合併成一個資源 (Source
子類) 對象 -
調用
compilation.emitAsset
將資源對象掛載到compilation.assets
屬性中 -
調用
compiler.emitAssets
將 assets 全部寫到 FileSystem -
發佈
compiler.hooks.done
鉤子 -
運行結束
挖坑
Webpack 真的很複雜,每次信心滿滿寫出一個主題的內容之後都會發現更多新的坑點,比如本文可以衍生出來的關注點:
-
除了 NormalModule 與 RuntimeModule 體系外,其他的 Module 子類分別起什麼作用?
-
單個 Module 的內容轉譯過程是怎麼樣的?在這個過程中具體是怎麼計算出 runtime 依賴的?
-
除了記錄 module、chunk 的 runtimeRequirements 之外,ChunkGraph 還起什麼作用?
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/nkBvbwpzeb0fzG02HXta8A