120 行代碼幫你了解 Webpack 下的 HMR 機制
本文轉載自微信公眾號「微醫大前端技術」,作者朱海華。轉載本文請聯系微醫大前端技術公眾號。
HMR 的背景
在使用Webpack Dev Server以后 可以讓我們在開發工程中 專注于 Coding, 因為它可以監聽代碼的變化 從而實現打包更新,并且最后通過自動刷新的方式同步到瀏覽器,便于我們及時查看效果。但是 Dev Server 從監聽到打包再到通知瀏覽器整體刷新頁面 就會導致一個讓人困擾的問題 那就是 無法保存應用狀態 因此 針對這個問題,Webpack 提供了一個新的解決方案 Hot Module Replacement
HMR 簡單概念
Hot Module Replacement 是指當我們對代碼修改并保存后,Webpack 將會對代碼進行重新打包,并將新的模塊發送到瀏覽器端,瀏覽器用新的模塊替換掉舊的模塊,以實現在不刷新瀏覽器的前提下更新頁面。最明顯的優勢就是相對于傳統的live reload而言,HMR 并不會丟失應用的狀態,提高開發效率。在開始深入了解 Webpack HMR 之前 我們可以先簡單過一下下面這張流程圖
HRM 流程概覽
1597240262452-5ecbaec0-6245-4ed5-9195-59c7a38e8b24.png
Webpack Compile: watch 打包本地文件 寫入內存
Boundle Server: 啟一個本地服務,提供文件在瀏覽器端進行訪問
HMR Server: 將熱更新的文件輸出給 HMR Runtime
HRM Runtime: 生成的文件,注入至瀏覽器內存
Bundle: 構建輸出文件
HMR 入門體驗
開啟 HMR 其實也極其容易 因為 HMR 本身就已經集成在了 Webpack 里 開啟方式有兩種
- 直接通過運行 webpack-dev-server 命令時 加入 --hot參數 直接開啟 HMR
- 寫入配置文件 代碼如下
- // ./webpack.config.js
- const webpack = require('webpack')
- module.exports = {
- // ...
- devServer: {
- // 開啟 HMR 特性 如果不支持 MMR 則會 fallback 到 live reload
- hot: true,
- },
- plugins: [
- // ...
- // HMR 依賴的插件
- new webpack.HotModuleReplacementPlugin()
- ]
- }
HMR 中的 Server 和 Client
devServer 通知瀏覽器文件變更
通過翻閱 webpack-dev-server 源碼 在這一過程中,依賴于 sockjs 提供的服務端與瀏覽器端之間的橋梁,在 devServer 啟動的同時,建立了一個 webSocket 長鏈接,用于通知瀏覽器在 webpack 編譯和打包下的各個狀態,同時監聽 compile 下的 done 事件,當 compile 完成以后,通過 sendStats 方法, 將重新編譯打包好的新模塊 hash 值發送給瀏覽器。
- // webpack-dev-server/blob/master/lib/Server.js
- sendStats(sockets, stats, force) {
- const shouldEmit =
- !force &&
- stats &&
- (!stats.errors || stats.errors.length === 0) &&
- (!stats.warnings || stats.warnings.length === 0) &&
- stats.assets &&
- stats.assets.every((asset) => !asset.emitted);
- if (shouldEmit) {
- this.sockWrite(sockets, 'still-ok');
- return;
- }
- this.sockWrite(sockets, 'hash', stats.hash);
- if (stats.errors.length > 0) {
- this.sockWrite(sockets, 'errors', stats.errors);
- } else if (stats.warnings.length > 0) {
- this.sockWrite(sockets, 'warnings', stats.warnings);
- } else {
- this.sockWrite(sockets, 'ok');
- }
- }
Client 接收到服務端消息做出響應
webpack-dev-server/client 當接收到 type 為 hash 消息后會將 hash 值暫時緩存起來,同時當接收到到 type 為 ok 的時候,對瀏覽器執行 reload 操作。
reload 策略選擇
- function reloadApp(
- { hotReload, hot, liveReload },
- { isUnloading, currentHash }
- ) {
- if (isUnloading || !hotReload) {
- return;
- }
- if (hot) {
- log.info('App hot update...');
- const hotEmitter = require('webpack/hot/emitter');
- hotEmitter.emit('webpackHotUpdate', currentHash);
- if (typeof self !== 'undefined' && self.window) {
- // broadcast update to window
- self.postMessage(`webpackHotUpdate${currentHash}`, '*');
- }
- }
- // allow refreshing the page only if liveReload isn't disabled
- else if (liveReload) {
- let rootWindow = self;
- // use parent window for reload (in case we're in an iframe with no valid src)
- const intervalId = self.setInterval(() => {
- if (rootWindow.location.protocol !== 'about:') {
- // reload immediately if protocol is valid
- applyReload(rootWindow, intervalId);
- } else {
- rootWindow = rootWindow.parent;
- if (rootWindow.parent === rootWindow) {
- // if parent equals current window we've reached the root which would continue forever, so trigger a reload anyways
- applyReload(rootWindow, intervalId);
- }
- }
- });
- }
- function applyReload(rootWindow, intervalId) {
- clearInterval(intervalId);
- log.info('App updated. Reloading...');
- rootWindow.location.reload();
- }
通過翻閱 webpack-dev-server/client源碼,我們可以看到,首先會根據 hot 配置決定是采用哪種更新策略,刷新瀏覽器或者代碼進行熱更新(HMR),如果配置了 HMR,就調用 webpack/hot/emitter 將最新 hash 值發送給 webpack,如果沒有配置模塊熱更新,就直接調用 applyReload下的location.reload 方法刷新頁面。
webpack 根據 hash 請求最新模塊代碼
在這一步,其實是 webpack 中三個模塊(三個文件,后面英文名對應文件路徑)之間配合的結果,首先是 webpack/hot/dev-server(以下簡稱 dev-server) 監聽第三步 webpack-dev-server/client 發送的 webpackHotUpdate 消息,調用 webpack/lib/HotModuleReplacement.runtime(簡稱 HMR runtime)中的 check 方法,檢測是否有新的更新,在 check 過程中會利用 webpack/lib/JsonpMainTemplate.runtime(簡稱 jsonp runtime)中的兩個方法 hotDownloadUpdateChunk 和 hotDownloadManifest , 第二個方法是調用 AJAX 向服務端請求是否有更新的文件,如果有將發更新的文件列表返回瀏覽器端,而第一個方法是通過 jsonp 請求最新的模塊代碼,然后將代碼返回給 HMR runtime,HMR runtime 會根據返回的新模塊代碼做進一步處理,可能是刷新頁面,也可能是對模塊進行熱更新。
在這個過程中,其實是 webpack 三個模塊配合執行之后獲取的結果
webpack/hot/dev-server監聽 client 發送的webpackHotUpdate消息
- // ....
- var hotEmitter = require("./emitter");
- hotEmitter.on("webpackHotUpdate", function (currentHash) {
- lastHash = currentHash;
- if (!upToDate() && module.hot.status() === "idle") {
- log("info", "[HMR] Checking for updates on the server...");
- check();
- }
- });
- log("info", "[HMR] Waiting for update signal from WDS...");
- } else {
- throw new Error("[HMR] Hot Module Replacement is disabled.");
[HMR runtime/check()](https://github.com/webpack/webpack/blob/v4.41.5/lib/HotModuleReplacement.runtime.js)檢測是否有新的更新,check 過程中會利用webpack/lib/web/JsonpMainTemplate.runtime.js中的hotDownloadUpdateChunk(通過 jsonp 請求新的模塊代碼并且返回給 HMR Runtime)以及hotDownloadManifest(發送 AJAx 請求向 Server 請求是否有更新的文件,如果有則會將新的文件返回給瀏覽器)
獲取更新文件列表
獲取模塊更新以后的最新代碼
HMR Runtime 對模塊進行熱更新
這里就是整個 HMR 最關鍵的步驟了,而其中 最關鍵的 無非就是hotApply這個方法了,由于代碼量實在太多,這里我們直接進入過程解析(關鍵代碼),有興趣的同學可以閱讀一下源碼。
找出 outdatedModules 和 outdatedDependencies
刪除過期的模塊以及對應依賴
- // remove module from cache
- delete installedModules[moduleId];
- // when disposing there is no need to call dispose handler
- delete outdatedDependencies[moduleId];
新模塊添加至 modules 中
- for(moduleId in appliedUpdate) {
- if(Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
- modules[moduleId] = appliedUpdate[moduleId];
- }
- }
至此 一整個模塊替換的流程已經結束了,已經可以獲取到最新的模塊代碼了,接下來就輪到業務代碼如何知曉模塊已經發生了變化~
HMR 中的 hot 成員
HotModuleReplaceMentPlugin
由于我們編寫的 JavaScript 代碼是沒有任何規律可言的模塊,可以導出的是一個模塊、函數、甚至于只是一個字符串 而對于這些毫無規律可言的模塊來說 Webpack 是無法提供一個通用的模塊替換方案去處理的 因此在這種情況下,還想要體驗完整的 HMR 開發流程 是需要我們自己手動處理 當 JS 模塊更新以后,如何將更新以后的 JS 模塊替換至頁面當中 因此 HotModuleReplacementPlugin 為我們提供了一系列關于 HMR 的 API 而其中 最關鍵的部分則是hot.accept。
接下來 我們將嘗試 自己手動處理 JS 模塊更新 并通知到瀏覽器實現對應的局部刷新
:::info 當前主流開發框架 Vue、React 都提供了統一的模塊替換函數, 因此 Vue、React 項目并不需要針對 HMR 做手動的代碼處理,同時 css 文件也由 style-loader 統一處理 因此也不需要額外的處理,因此接下去的代碼處理邏輯,全部建立在純原生開發的基礎之上實現 :::
回到代碼中來 假設當前 main.js 文件如下
- // ./src/main.js
- import createChild from './child'
- const child = createChild()
- document.body.appendChild(child)
main.js 是 Webpack 打包的入口文件 在文件中引入了 Child 模塊 因此 當 Child 模塊里的業務代碼更改以后 webpack 必然會重新打包,并且重新使用這些更新以后的模塊,所以,我們需要在 main.js 里實現去處理它所依賴的這些模塊更新后的熱替換邏輯
在 HMR 已開啟的情況下,我們可以通過訪問全局的module對象下的hot 成員它提供了一個accept 方法,這個方法用來注冊當某個模塊更新以后需要如何處理,它接受兩個參數 一個是需要監聽模塊的 path(相對路徑),第二個參數就是當模塊更新以后如何處理 其實也就是一個回調函數
- // main.js
- // 監聽 child 模塊變化
- module.hot.accept('./child', () => {
- console.log('老板好,child 模塊更新啦~')
- })
當做完這些以后,重新運行 npm run serve 同時修改 child 模塊 你會發現,控制臺會輸出以上的 console 內容,同時,瀏覽器也不會自動更新了,因此,我們可以得出一個結論 當你手動處理了某個模塊的更新以后,是不會出發自動刷新機制的,接下來 就來一起看看 其中的原理 以及 如何實現 HMR 中的 JS 模塊替換邏輯
module.hot.accept 原理
為什么我們只有調用了moudule.hot.accept才可以實現熱更新, 翻看源碼 其實可以發現實現如下
- // 部分源碼
- accept: function (dep, callback, errorHandler) {
- if (dep === undefined) hot._selfAccepted = true;
- else if (typeof dep === "function") hot._selfAccepted = dep;
- else if (typeof dep === "object" && dep !== null) {
- for (var i = 0; i < dep.length; i++) {
- hot._acceptedDependencies[dep[i]] = callback || function () {};
- hot._acceptedErrorHandlers[dep[i]] = errorHandler;
- }
- } else {
- hot._acceptedDependencies[dep] = callback || function () {};
- hot._acceptedErrorHandlers[dep] = errorHandler;
- }
- },
- // module.hot.accept 其實等價于 module.hot._acceptedDependencies('./child) = render
- // 業務邏輯實現
- module.hot.accept('./child', () => {
- console.log('老板好,child 模塊更新啦~')
- })
accept 往hot._acceptedDependencies這個對象里存入局部更新的 callback, 當模塊改變時,對模塊需要做的變更,搜集到_acceptedDependencies中,同時當被監聽的模塊內容發生了改變以后,父模塊可以通過_acceptedDependencies知道哪些內容發生了變化。
實現 JS 模塊替換
當了解了 accpet 方法以后,其實我們要考慮的事情就非常簡單了,也就是如何實現 cb 里的業務邏輯,其實當 accept 方法執行了以后,在其回調里是可以獲取到最新的被修改了以后的模塊的函數內容的
- // ./src/main.js
- import createChild from './child'
- console.log(createChild) // 未更新前的函數內容
- module.hot.accept('./child', ()=> {
- console.log(createChild) // 此時已經可以獲取更新以后的函數內容
- })
既然是可以獲取到最新的函數內容 其實也就很簡單了 我們只需要移除之前的 dom 節點 并替換為最新的 dom 節點即可,同時我們也需要記錄節點里的內容狀態,當節點替換為最新的節點以后,追加更新原本的內容狀態
- // ./src/main.js
- import createChild from './child'
- const child = createChild()
- document.body.appendChild(child)
- // 這里需要額外注意的是,child 變量每一次都會被移除,所以其實我們一個記錄一下每次被修改前的 child
- let lastChild = child
- module.hot.accept('./child', ()=> {
- // 記錄狀態
- const value = lastChild.innerHTML
- // 刪除節點
- document.body.remove(child)
- // 創建最新節點
- lastChild = createChild()
- // 恢復狀態
- lastChild.innerHTMl = value
- // 追加內容
- document.body.appendChild(lastChild)
- })
到這里為止,對于如何手動實現一個 child 模塊的熱更新替換邏輯已經全部實現完畢了,有興趣的同學可以自己也手動實現一下~
:::tips tips: 手動處理 HMR 邏輯過程中 如果 HMR 過程中出現報錯 導致的 HRM 失效,其實只需要在配置文件中將hot: true 修改為 hotOnly: true即可 :::
寫在最后
希望通過這篇文章,能夠幫助到大家加深對 HMR 的理解,同時解決一下開發場景會遇到的問題(例如 脫離框架自己實現模塊熱更新),