Webpack 熱更新HMR 原理全解析
這是 Webpack 原理分析系列第十篇文章,前文可到公眾號【Tecvan】查閱。
一、什么是 HMR
HMR 全稱 Hot Module Replacement,中文語境通常翻譯為模塊熱更新,它能夠在保持頁面狀態(tài)的情況下動態(tài)替換資源模塊,提供絲滑順暢的 Web 頁面開發(fā)體驗。

HMR 最初由 Webpack 設(shè)計實現(xiàn),至今已幾乎成為現(xiàn)代工程化工具必備特性之一。
1.1?HMR 之前
在 HMR 之前,應(yīng)用的加載、更新是一種頁面級別的原子操作,即使只是單個代碼文件發(fā)生變更都需要刷新整個頁面才能最新代碼映射到瀏覽器上,這會丟失之前在頁面執(zhí)行過的所有交互與狀態(tài),例如:
對于復(fù)雜表單場景,這意味著你可能需要重新填充非常多字段信息 彈框消失,你必須重新執(zhí)行交互動作才會重新彈出
再小的改動,例如更新字體大小,改變備注信息都會需要整個頁面重新加載執(zhí)行,影響開發(fā)體驗。引入 HMR 后,雖然無法覆蓋所有場景,但大多數(shù)小改動都可以實時熱更新到頁面上,從而確保連續(xù)、順暢的開發(fā)調(diào)試體驗,對開發(fā)效率有較大增益效果。
1.2 使用 HMR
Webpack 生態(tài)下,只需要經(jīng)過簡單的配置即可啟動 HMR 功能,大致上分兩步:
配置? devServer.hot?屬性為 true,如:
//?webpack.config.js
module.exports?=?{
??//?...
??devServer:?{
????//?必須設(shè)置?devServer.hot?=?true,啟動?HMR?功能
????hot:?true
??}
};
之后,還需要調(diào)用? module.hot.accept?接口,聲明如何將模塊安全地替換為最新代碼,如:
import?component?from?"./component";
let?demoComponent?=?component();
document.body.appendChild(demoComponent);
//?HMR?interface
if?(module.hot)?{
??//?Capture?hot?update
??module.hot.accept("./component",?()?=>?{
????const?nextComponent?=?component();
????//?Replace?old?content?with?the?hot?loaded?one
????document.body.replaceChild(nextComponent,?demoComponent);
????demoComponent?=?nextComponent;
??});
}
模塊代碼的替換邏輯可能非常復(fù)雜,幸運的是我們通常不太需要對此過多關(guān)注,因為業(yè)界許多 Webpack Loader 已經(jīng)提供了針對不同資源的 HMR 功能,例如:
style-loader?內(nèi)置 Css 模塊熱更vue-loader?內(nèi)置 Vue 模塊熱更react-hot-reload?內(nèi)置 React 模塊熱更接口
因此,站在使用的角度,只需要針對不同資源配置對應(yīng)支持 HMR 的 Loader 即可,很容易上手。
二、實現(xiàn)原理
Webpack HMR 特性的原理并不復(fù)雜,核心流程:
使用? webpack-dev-server?(后面簡稱 WDS)托管靜態(tài)資源,同時以 Runtime 方式注入 HMR 客戶端代碼瀏覽器加載頁面后,與 WDS 建立 WebSocket 連接 Webpack 監(jiān)聽到文件變化后,增量構(gòu)建發(fā)生變更的模塊,并通過 WebSocket 發(fā)送? hash?事件瀏覽器接收到? hash?事件后,請求?manifest?資源文件,確認(rèn)增量變更范圍瀏覽器加載發(fā)生變更的增量模塊 Webpack 運行時觸發(fā)變更模塊的? module.hot.accept?回調(diào),執(zhí)行代碼變更邏輯done

接下來我會展開 HMR 的核心源碼,詳細(xì)講解 Webpack 5 中 Hot Module Replacement 原理的關(guān)鍵部分,內(nèi)容略微晦澀,不感興趣的同學(xué)可以直接跳到下一章。
2.1?注入 HMR 客戶端運行時
執(zhí)行?npx webpack serve?命令后,WDS 調(diào)用?HotModuleReplacementPlugin?插件向應(yīng)用的主 Chunk 注入一系列 HMR Runtime,包括:
用于建立 WebSocket 連接,處理? hash?等消息的運行時代碼用于加載熱更新資源的? RuntimeGlobals.hmrDownloadManifest?與?RuntimeGlobals.hmrDownloadUpdateHandlers?接口用于處理模塊更新策略的? module.hot.accept?接口等等
關(guān)于 Webpack Runtime,可參考?Webpack 原理系列六:徹底理解 Webpack 運行時。
經(jīng)過?HotModuleReplacementPlugin?處理后,構(gòu)建產(chǎn)物中即包含了所有運行 HMR 所需的客戶端運行時與接口。這些 HMR 運行時會在瀏覽器執(zhí)行一套基于 WebSocket 消息的時序框架,如圖:

2.2 增量構(gòu)建
除注入客戶端代碼外,HotModuleReplacementPlugin?插件還會借助 Webpack 的?watch?能力,在代碼文件發(fā)生變化后執(zhí)行增量構(gòu)建,生成:
manifest?文件:JSON 格式文件,包含所有發(fā)生變更的模塊列表,命名為?[hash].hot-update.json模塊變更文件:js 格式,包含編譯后的模塊代碼,命名為? [hash].hot-update.js
增量構(gòu)建完畢后,Webpack 將觸發(fā)?compilation.hooks.done?鉤子,并傳遞本次構(gòu)建的統(tǒng)計信息對象?stats。WDS 則監(jiān)聽?done?鉤子,在回調(diào)中通過 WebSocket 發(fā)送模塊更新消息:
{"type":"hash","data":"${stats.hash}"}
實際效果:

2.3 加載更新
客戶端接受到?hash?消息后,首先發(fā)出?manifest?請求獲取本輪熱更新涉及的 chunk,如:

注意,在 Webpack 4 及之前,熱更新文件以模塊為單位,即所有發(fā)生變化的模塊都會生成對應(yīng)的熱更新文件;?Webpack 5 之后熱更新文件以 chunk 為單位,如上例中,main?chunk 下任意文件的變化都只會生成?main.[hash].hot-update.js?更新文件。
manifest?請求完成后,客戶端 HMR 運行時開始下載發(fā)生變化的 chunk 文件,將最新模塊代碼加載到本地。
2.4module.hot.accept回調(diào)
經(jīng)過上述步驟,瀏覽器加載完最新模塊代碼后,HMR 運行時會繼續(xù)觸發(fā)?module.hot.accept?回調(diào),將最新代碼替換到運行環(huán)境中。
module.hot.accept?是 HMR 運行時暴露給用戶代碼的重要接口之一,它在 Webpack HMR 體系中開了一個口子,讓用戶能夠自定義模塊熱替換的邏輯。module.hot.accept?接口簽名如下:
module.hot.accept(path?:?string,?callback?:?function);
它接受兩個參數(shù):
path:指定需要攔截變更行為的模塊路徑callback:模塊更新后,將最新模塊代碼應(yīng)用到運行環(huán)境的函數(shù)
例如,對于如下代碼:
//?src/bar.js
export?const?bar?=?'bar'
//?src/index.js
import?{?bar?}?from?'./bar';
const?node?=?document.createElement('div')
node.innerText?=?bar;
document.body.appendChild(node)
module.hot.accept('./bar.js',?function?()?{
????node.innerText?=?bar;
})
示例中,module.hot.accept?函數(shù)監(jiān)聽?./bar.js?模塊的變更事件,一旦代碼發(fā)生變動就觸發(fā)回調(diào),將?./bar.js?導(dǎo)出的值應(yīng)用到頁面上,從而實現(xiàn)熱更新效果。
module.hot.accept?的作用并不復(fù)雜,但使用過程中還是有一些值得注意的點,下面細(xì)講。
2.4.1 失敗兜底
module.hot.accept?函數(shù)只接受具體路徑的?path?參數(shù),也就是說我們無法通過?glob?或類似風(fēng)格的方式批量注冊熱更新回調(diào)。
一旦某個模塊沒有注冊對應(yīng)的?module.hot.accept?函數(shù)后,HMR 運行時會執(zhí)行兜底策略,通常是刷新頁面,確保頁面上運行的始終是最新的代碼。
2.4.2 更新事件冒泡
在 Webpack HMR 框架中,module.hot.accept?函數(shù)只能捕獲當(dāng)前模塊對應(yīng)子孫模塊的更新事件,例如對于下面的模塊依賴樹:

示例中,更新事件會沿著模塊依賴樹自底向上逐級傳遞,從?foo?到?index?,從?bar-1?到?bar?再到?index,但不支持反向或跨子樹傳遞,也就是說:
在? foo.js?中無法捕獲?bar.js?及其子模塊的變更事件在? bar-1.js?中無法捕獲?bar.js?的變更事件
這一特性與 DOM 事件規(guī)范中的冒泡過程極為相似,使用時如果摸不準(zhǔn)模塊的依賴關(guān)系,建議直接在應(yīng)用的入口文件中編寫熱更新函數(shù)。
2.4.3 無參數(shù)調(diào)用
除上述調(diào)用方式外,module.hot.accept?函數(shù)還支持無參數(shù)調(diào)用風(fēng)格,作用是捕獲當(dāng)前文件的變更事件,并從模塊第一行開始重新運行該模塊的代碼,例如:
//?src/bar.js
console.log('bar');
module.hot.accept();
示例模塊發(fā)生變動之后,會從頭開始重復(fù)執(zhí)行?console.log?語句。
2.5 小結(jié)
回顧整個 HMR 過程,所有的狀態(tài)流轉(zhuǎn)均由 WebSocket 消息驅(qū)動,這部分邏輯由 HMR 運行時控制,開發(fā)者幾乎無感。
唯一需要開發(fā)者關(guān)心的是為每一個需要處理熱更新的文件注冊?module.hot.accept?回調(diào),所幸這部分需求已經(jīng)被許多成熟的 Loader 處理,作為示例,下一節(jié)我們挖掘 vue-loader 源碼,學(xué)習(xí)如何靈活使用?module.hot.accept?函數(shù)處理文件更新。
三、?vue-loader?如何實現(xiàn) HMR
vue-loader?是一個用于處理 Vue Single File Component 的 Webpack 加載器,它能夠?qū)⑷缦赂袷降膬?nèi)容轉(zhuǎn)譯為可在瀏覽器運行的等價代碼:

除常規(guī)的代碼轉(zhuǎn)譯外,在 HMR 模式下,vue-loader?還會為每一個 Vue 文件注入一段處理模塊替換的邏輯,如:
"./src/a.vue":
/*!*******************!*\
????!***?./src/a.vue?***!
????\*******************/
/***/
((module,?__webpack_exports__,?__webpack_require__)?=>?{
????//?模塊代碼
????//?...
????/*?hot?reload?*/
????if?(true)?{
????var?api?=?__webpack_require__(?/*!?../node_modules/vue-hot-reload-api/dist/index.js?*/?"../node_modules/vue-hot-reload-api/dist/index.js")
????api.install(__webpack_require__(?/*!?vue?*/?"../node_modules/vue/dist/vue.runtime.esm.js"))
????if?(api.compatible)?{
????????module.hot.accept()
????????if?(!api.isRecorded('45c6ab58'))?{
????????api.createRecord('45c6ab58',?component.options)
????????}?else?{
????????api.reload('45c6ab58',?component.options)
????????}
????????module.hot.accept(?/*!?./a.vue?vue&type=template&id=45c6ab58&?*/?"./src/a.vue?vue&type=template&id=45c6ab58&",?__WEBPACK_OUTDATED_DEPENDENCIES__?=>?{
????????/*?harmony?import?*/
????????_a_vue_vue_type_template_id_45c6ab58___WEBPACK_IMPORTED_MODULE_0__?=?__webpack_require__(?/*!?./a.vue?vue&type=template&id=45c6ab58&?*/?"./src/a.vue?vue&type=template&id=45c6ab58&");
????????(function?()?{
????????????api.rerender('45c6ab58',?{
????????????render:?_a_vue_vue_type_template_id_45c6ab58___WEBPACK_IMPORTED_MODULE_0__.render,
????????????staticRenderFns:?_a_vue_vue_type_template_id_45c6ab58___WEBPACK_IMPORTED_MODULE_0__.staticRenderFns
????????????})
????????})(__WEBPACK_OUTDATED_DEPENDENCIES__);
????????})
????}
????}
????//?...
????/***/
}),
這段被注入用于處理模塊熱替換的代碼,主要步驟有:
首次執(zhí)行時,調(diào)用? api.createRecord?記錄組件配置,api?為?vue-hot-reload-api?庫暴露的接口執(zhí)行? module.hot.accept()?語句,監(jiān)聽當(dāng)前模塊變更事件,當(dāng)模塊發(fā)生變化時調(diào)用?api.reload執(zhí)行? module.hot.accept("xxx.vue?vue&type=template&xxxx", fn)?,監(jiān)聽 Vue 文件 template 代碼的變更事件,當(dāng) template 模塊發(fā)生變更時調(diào)用?api.rerender
為什么需要調(diào)用兩次?module.hot.accept?這是因為?
vue-loader?在做轉(zhuǎn)譯時,會將 SFC 不同板塊拆解成多個 module,例如:?template?對應(yīng)生成?xxx.vue?vue&type=template?;script?對應(yīng)生成?xxx.vue?vue&type=script。因此,vue-loader?必須為這些不同的 module 分別調(diào)用?accept?接口,才能處理好不同代碼塊的變更事件。
可以看到,vue-loader?對 HMR 的支持,基本上圍繞?vue-hot-reload-api?展開,當(dāng)代碼文件發(fā)生變化觸發(fā)?module.hot.accept?回調(diào)時,會根據(jù)情況執(zhí)行?vue-hot-reload-api?暴露的?reload?與?rerender?函數(shù),兩者最終都會觸發(fā)組件實例的?$forceUpdate?函數(shù)強制執(zhí)行重新渲染。
四、總結(jié)
最后再回顧一下,Webpack 的 HMR 特性有兩個重點,一是監(jiān)聽文件變化并通過 WebSocket 發(fā)送變更消息;二是需要客戶端提供配合,通過?module.hot.accept?接口明確告知 Webpack 如何執(zhí)行代碼替換。整體盤下來,并沒有想象中那么困難。
最近很忙,雙月OKR、年中績效,還有一些突發(fā)事件,這篇文章實際上在上周就完成了80%,但一直沒時間收尾,后面我盡量保持 1-2 周一更吧。BTW,字節(jié)游戲中臺-前端團(tuán)隊持續(xù)熱招,歡迎直接聯(lián)系我內(nèi)推,我會跟進(jìn)內(nèi)推整個過程,「知無不言言無不盡!」
