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