Webpack 原理系列十:HMR 原理全解析
一、什么是 HMR
HMR 全稱 Hot Module Replacement,中文語境通常翻譯為模塊熱更新,它能夠在保持頁面狀態(tài)的情況下動(dòng)態(tài)替換資源模塊,提供絲滑順暢的 Web 頁面開發(fā)體驗(yàn)。
HMR 最初由 Webpack 設(shè)計(jì)實(shí)現(xiàn),至今已幾乎成為現(xiàn)代工程化工具必備特性之一。
1.1 HMR 之前
在 HMR 之前,應(yīng)用的加載、更新是一種頁面級(jí)別的原子操作,即使只是單個(gè)代碼文件發(fā)生變更都需要刷新整個(gè)頁面才能最新代碼映射到瀏覽器上,這會(huì)丟失之前在頁面執(zhí)行過的所有交互與狀態(tài),例如:
對于復(fù)雜表單場景,這意味著你可能需要重新填充非常多字段信息
彈框消失,你必須重新執(zhí)行交互動(dòng)作才會(huì)重新彈出
再小的改動(dòng),例如更新字體大小,改變備注信息都會(huì)需要整個(gè)頁面重新加載執(zhí)行,影響開發(fā)體驗(yàn)。引入 HMR 后,雖然無法覆蓋所有場景,但大多數(shù)小改動(dòng)都可以實(shí)時(shí)熱更新到頁面上,從而確保連續(xù)、順暢的開發(fā)調(diào)試體驗(yàn),對開發(fā)效率有較大增益效果。
1.2 使用 HMR
Webpack 生態(tài)下,只需要經(jīng)過簡單的配置即可啟動(dòng) HMR 功能,大致上分兩步:
配置 devServer.hot 屬性為 true,如:
- // webpack.config.js
- module.exports = {
- // ...
- devServer: {
- // 必須設(shè)置 devServer.hot = true,啟動(dòng) 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ù)雜,幸運(yùn)的是我們通常不太需要對此過多關(guān)注,因?yàn)闃I(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 即可,很容易上手。
二、實(shí)現(xiàn)原理
Webpack HMR 特性的原理并不復(fù)雜,核心流程:
- 使用 webpack-dev-server (后面簡稱 WDS)托管靜態(tài)資源,同時(shí)以 Runtime 方式注入 HMR 客戶端代碼
- 瀏覽器加載頁面后,與 WDS 建立 WebSocket 連接
- Webpack 監(jiān)聽到文件變化后,增量構(gòu)建發(fā)生變更的模塊,并通過 WebSocket 發(fā)送 hash 事件
- 瀏覽器接收到 hash 事件后,請求 manifest 資源文件,確認(rèn)增量變更范圍
- 瀏覽器加載發(fā)生變更的增量模塊
- Webpack 運(yùn)行時(shí)觸發(fā)變更模塊的 module.hot.accept 回調(diào),執(zhí)行代碼變更邏輯
- done
接下來我會(huì)展開 HMR 的核心源碼,詳細(xì)講解 Webpack 5 中 Hot Module Replacement 原理的關(guān)鍵部分,內(nèi)容略微晦澀,不感興趣的同學(xué)可以直接跳到下一章。
2.1 注入 HMR 客戶端運(yùn)行時(shí)
執(zhí)行 npx webpack serve 命令后,WDS 調(diào)用 HotModuleReplacementPlugin 插件向應(yīng)用的主 Chunk 注入一系列 HMR Runtime,包括:
- 用于建立 WebSocket 連接,處理 hash 等消息的運(yùn)行時(shí)代碼
- 用于加載熱更新資源的 RuntimeGlobals.hmrDownloadManifest 與 RuntimeGlobals.hmrDownloadUpdateHandlers 接口
- 用于處理模塊更新策略的 module.hot.accept 接口
- 等等
關(guān)于 Webpack Runtime,可參考 Webpack 原理系列六:徹底理解 Webpack 運(yùn)行時(shí)。
經(jīng)過 HotModuleReplacementPlugin 處理后,構(gòu)建產(chǎn)物中即包含了所有運(yùn)行 HMR 所需的客戶端運(yùn)行時(shí)與接口。這些 HMR 運(yùn)行時(shí)會(huì)在瀏覽器執(zhí)行一套基于 WebSocket 消息的時(shí)序框架,如圖:
2.2 增量構(gòu)建
除注入客戶端代碼外,HotModuleReplacementPlugin 插件還會(huì)借助 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)計(jì)信息對象 stats。WDS 則監(jiān)聽 done 鉤子,在回調(diào)中通過 WebSocket 發(fā)送模塊更新消息:
- {"type":"hash","data":"${stats.hash}"}
實(shí)際效果:
2.3 加載更新
客戶端接受到 hash 消息后,首先發(fā)出 manifest 請求獲取本輪熱更新涉及的 chunk,如:
注意,在 Webpack 4 及之前,熱更新文件以模塊為單位,即所有發(fā)生變化的模塊都會(huì)生成對應(yīng)的熱更新文件; Webpack 5 之后熱更新文件以 chunk 為單位,如上例中,main chunk 下任意文件的變化都只會(huì)生成 main.[hash].hot-update.js 更新文件。
manifest 請求完成后,客戶端 HMR 運(yùn)行時(shí)開始下載發(fā)生變化的 chunk 文件,將最新模塊代碼加載到本地。
2.4module.hot.accept回調(diào)
經(jīng)過上述步驟,瀏覽器加載完最新模塊代碼后,HMR 運(yùn)行時(shí)會(huì)繼續(xù)觸發(fā) module.hot.accept 回調(diào),將最新代碼替換到運(yùn)行環(huán)境中。
module.hot.accept 是 HMR 運(yùn)行時(shí)暴露給用戶代碼的重要接口之一,它在 Webpack HMR 體系中開了一個(gè)口子,讓用戶能夠自定義模塊熱替換的邏輯。module.hot.accept 接口簽名如下:
- module.hot.accept(path?: string, callback?: function);
它接受兩個(gè)參數(shù):
- path:指定需要攔截變更行為的模塊路徑
- callback:模塊更新后,將最新模塊代碼應(yīng)用到運(yùn)行環(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ā)生變動(dòng)就觸發(fā)回調(diào),將 ./bar.js 導(dǎo)出的值應(yīng)用到頁面上,從而實(shí)現(xiàn)熱更新效果。
module.hot.accept 的作用并不復(fù)雜,但使用過程中還是有一些值得注意的點(diǎn),下面細(xì)講。
2.4.1 失敗兜底
module.hot.accept 函數(shù)只接受具體路徑的 path 參數(shù),也就是說我們無法通過 glob 或類似風(fēng)格的方式批量注冊熱更新回調(diào)。
一旦某個(gè)模塊沒有注冊對應(yīng)的 module.hot.accept 函數(shù)后,HMR 運(yùn)行時(shí)會(huì)執(zhí)行兜底策略,通常是刷新頁面,確保頁面上運(yùn)行的始終是最新的代碼。
2.4.2 更新事件冒泡
在 Webpack HMR 框架中,module.hot.accept 函數(shù)只能捕獲當(dāng)前模塊對應(yīng)子孫模塊的更新事件,例如對于下面的模塊依賴樹:
示例中,更新事件會(huì)沿著模塊依賴樹自底向上逐級(jí)傳遞,從 foo 到 index ,從 bar-1 到 bar 再到 index,但不支持反向或跨子樹傳遞,也就是說:
- 在 foo.js 中無法捕獲 bar.js 及其子模塊的變更事件
- 在 bar-1.js 中無法捕獲 bar.js 的變更事件
這一特性與 DOM 事件規(guī)范中的冒泡過程極為相似,使用時(shí)如果摸不準(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)前文件的變更事件,并從模塊第一行開始重新運(yùn)行該模塊的代碼,例如:
- // src/bar.js
- console.log('bar');
- module.hot.accept();
示例模塊發(fā)生變動(dòng)之后,會(huì)從頭開始重復(fù)執(zhí)行 console.log 語句。
2.5 小結(jié)
回顧整個(gè) HMR 過程,所有的狀態(tài)流轉(zhuǎn)均由 WebSocket 消息驅(qū)動(dòng),這部分邏輯由 HMR 運(yùn)行時(shí)控制,開發(fā)者幾乎無感。
唯一需要開發(fā)者關(guān)心的是為每一個(gè)需要處理熱更新的文件注冊 module.hot.accept 回調(diào),所幸這部分需求已經(jīng)被許多成熟的 Loader 處理,作為示例,下一節(jié)我們挖掘 vue-loader 源碼,學(xué)習(xí)如何靈活使用 module.hot.accept 函數(shù)處理文件更新。
三、 vue-loader 如何實(shí)現(xiàn) HMR
vue-loader 是一個(gè)用于處理 Vue Single File Component 的 Webpack 加載器,它能夠?qū)⑷缦赂袷降膬?nèi)容轉(zhuǎn)譯為可在瀏覽器運(yùn)行的等價(jià)代碼:
除常規(guī)的代碼轉(zhuǎn)譯外,在 HMR 模式下,vue-loader 還會(huì)為每一個(gè) 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í)行時(shí),調(diào)用 api.createRecord 記錄組件配置,api 為 vue-hot-reload-api 庫暴露的接口
- 執(zhí)行 module.hot.accept() 語句,監(jiān)聽當(dāng)前模塊變更事件,當(dāng)模塊發(fā)生變化時(shí)調(diào)用 api.reload
- 執(zhí)行 module.hot.accept("xxx.vue?vue&type=template&xxxx", fn) ,監(jiān)聽 Vue 文件 template 代碼的變更事件,當(dāng) template 模塊發(fā)生變更時(shí)調(diào)用 api.rerender
為什么需要調(diào)用兩次 module.hot.accept?
這是因?yàn)?vue-loader 在做轉(zhuǎn)譯時(shí),會(huì)將 SFC 不同板塊拆解成多個(gè) 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)時(shí),會(huì)根據(jù)情況執(zhí)行 vue-hot-reload-api 暴露的 reload 與 rerender 函數(shù),兩者最終都會(huì)觸發(fā)組件實(shí)例的 $forceUpdate 函數(shù)強(qiáng)制執(zhí)行重新渲染。
四、總結(jié)
最后再回顧一下,Webpack 的 HMR 特性有兩個(gè)重點(diǎn),一是監(jiān)聽文件變化并通過 WebSocket 發(fā)送變更消息;二是需要客戶端提供配合,通過 module.hot.accept 接口明確告知 Webpack 如何執(zhí)行代碼替換。整體盤下來,并沒有想象中那么困難。
本文轉(zhuǎn)載自微信公眾號(hào)「Tecvan」