一、前言 - webpack熱更新
Hot Module Replacement,簡稱HMR,無需完全刷新整個頁面的同時,更新模塊。HMR的好處,在日常開發工作中體會頗深:節省寶貴的開發時間、提升開發體驗。
刷新我們一般分為兩種:
- 一種是頁面刷新,不保留頁面狀態,就是簡單粗暴,直接
window.location.reload()。 - 另一種是基於
WDS (Webpack-dev-server)的模塊熱替換,只需要局部刷新頁面上發生變化的模塊,同時可以保留當前的頁面狀態,比如復選框的選中狀態、輸入框的輸入等。
HMR作為一個Webpack內置的功能,可以通過HotModuleReplacementPlugin或--hot開啟。那么,HMR到底是怎么實現熱更新的呢?下面讓我們來了解一下吧!
二、webpack的編譯構建過程
項目啟動后,進行構建打包,控制台會輸出構建過程,我們可以觀察到生成了一個 Hash值:a93fd735d02d98633356。

然后,在我們每次修改代碼保存后,控制台都會出現 Compiling…字樣,觸發新的編譯中...可以在控制台中觀察到:
- 新的Hash值:
a61bdd6e82294ed06fa3 - 新的json文件:
a93fd735d02d98633356.hot-update.json - 新的js文件:
index.a93fd735d02d98633356.hot-update.js

首先,我們知道Hash值代表每一次編譯的標識。其次,根據新生成文件名可以發現,上次輸出的Hash值會作為本次編譯新生成的文件標識。依次類推,本次輸出的Hash值會被作為下次熱更新的標識。
然后看一下,新生成的文件是什么?每次修改代碼,緊接着觸發重新編譯,然后瀏覽器就會發出 2 次請求。請求的便是本次新生成的 2 個文件。如下:

首先看json文件,返回的結果中,h代表本次新生成的Hash值,用於下次文件熱更新請求的前綴。c表示當前要熱更新的文件對應的是index模塊。
再看下生成的js文件,那就是本次修改的代碼,重新編譯打包后的。

還有一種情況是,如果沒有任何代碼改動,直接保存文件,控制台也會輸出編譯打包信息的。
- 新的Hash值:
d2e4208eca62aa1c5389 - 新的json文件:
a61bdd6e82294ed06fa3.hot-update.json

但是我們發現,並沒有生成新的js文件,因為沒有改動任何代碼,同時瀏覽器發出的請求,可以看到c值為空,代表本次沒有需要更新的代碼。
小聲說下,webapck以前的版本這種情況hash值是不會變的,后面可能出於什么原因改版了。細節不用在意,了解原理才是真諦!!!
最后思考下🤔,瀏覽器是如何知道本地代碼重新編譯了,並迅速請求了新生成的文件?是誰告知了瀏覽器?瀏覽器獲得這些文件又是如何熱更新成功的?那讓我們帶着疑問看下熱更新的過程,從源碼的角度看原理。
三、熱更新實現原理
相信大家都會配置webpack-dev-server熱更新,我就不示意例子了。自己網上查下即可。接下來我們就來看下webpack-dev-server是如何實現熱更新的?(源碼都是精簡過的,第一行會注明代碼路徑,看完最好結合源碼食用一次)。
1. webpack-dev-server啟動本地服務
我們根據webpack-dev-server的package.json中的bin命令,可以找到命令的入口文件bin/webpack-dev-server.js。
// node_modules/webpack-dev-server/bin/webpack-dev-server.js // 生成webpack編譯主引擎 compiler let compiler = webpack(config); // 啟動本地服務 let server = new Server(compiler, options, log); server.listen(options.port, options.host, (err) => { if (err) {throw err}; });
本地服務代碼:
// node_modules/webpack-dev-server/lib/Server.js class Server { constructor() { this.setupApp(); this.createServer(); } setupApp() { // 依賴了express this.app = new express(); } createServer() { this.listeningApp = http.createServer(this.app); } listen(port, hostname, fn) { return this.listeningApp.listen(port, hostname, (err) => { // 啟動express服務后,啟動websocket服務 this.createSocketServer(); } } }
這一小節代碼主要做了三件事:
- 啟動
webpack,生成compiler實例。compiler上有很多方法,比如可以啟動webpack所有編譯工作,以及監聽本地文件的變化。 - 使用
express框架啟動本地server,讓瀏覽器可以請求本地的靜態資源。 - 本地
server啟動之后,再去啟動websocket服務,如果不了解websocket,建議簡單了解一下websocket速成。通過websocket,可以建立本地服務和瀏覽器的雙向通信。這樣就可以實現當本地文件發生變化,立馬告知瀏覽器可以熱更新代碼啦!
上述代碼主要干了三件事,但是源碼在啟動服務前又做了很多事,接下來便看看webpack-dev-server/lib/Server.js還做了哪些事?
2. 修改webpack.config.js的entry配置
啟動本地服務前,調用了updateCompiler(this.compiler)方法。這個方法中有 2 段關鍵性代碼。一個是獲取websocket客戶端代碼路徑,另一個是根據配置獲取webpack熱更新代碼路徑。
// 獲取websocket客戶端代碼 const clientEntry = `${require.resolve( '../../client/' )}?${domain}${sockHost}${sockPath}${sockPort}`; // 根據配置獲取熱更新代碼 let hotEntry; if (options.hotOnly) { hotEntry = require.resolve('webpack/hot/only-dev-server'); } else if (options.hot) { hotEntry = require.resolve('webpack/hot/dev-server'); }
修改后的webpack入口配置如下:
// 修改后的entry入口 { entry: { index: [ // 上面獲取的clientEntry 'xxx/node_modules/webpack-dev-server/client/index.js?http://localhost:8080', // 上面獲取的hotEntry 'xxx/node_modules/webpack/hot/dev-server.js', // 開發配置的入口 './src/index.js' ], }, }
為什么要新增了 2 個文件?在入口默默增加了 2 個文件,那就意味會一同打包到bundle文件中去,也就是線上運行時。
(1)webpack-dev-server/client/index.js
首先這個文件用於websocket的,因為websoket是雙向通信,如果不了解websocket,建議簡單了解一下websocket速成。我們在第 1 步 webpack-dev-server初始化 的過程中,啟動的是本地服務端的websocket。那客戶端也就是我們的瀏覽器,瀏覽器還沒有和服務端通信的代碼呢?總不能讓開發者去寫吧hhhhhh。因此我們需要把websocket客戶端通信代碼偷偷塞到我們的代碼中。客戶端具體的代碼后面會在合適的時機細講哦。
(2)webpack/hot/dev-server.js
這個文件主要是用於檢查更新邏輯的,這里大家知道就好,代碼后面會在合適的時機(第5步)細講。
3. 監聽webpack編譯結束
修改好入口配置后,又調用了setupHooks方法。這個方法是用來注冊監聽事件的,監聽每次webpack編譯完成。
// node_modules/webpack-dev-server/lib/Server.js // 綁定監聽事件 setupHooks() { const {done} = compiler.hooks; // 監聽webpack的done鈎子,tapable提供的監聽方法 done.tap('webpack-dev-server', (stats) => { this._sendStats(this.sockets, this.getStats(stats)); this._stats = stats; }); };
當監聽到一次webpack編譯結束,就會調用_sendStats方法通過websocket給瀏覽器發送通知,ok和hash事件,這樣瀏覽器就可以拿到最新的hash值了,做檢查更新邏輯。
// 通過websoket給客戶端發消息 _sendStats() { this.sockWrite(sockets, 'hash', stats.hash); this.sockWrite(sockets, 'ok'); }
4. webpack監聽文件變化
每次修改代碼,就會觸發編譯。說明我們還需要監聽本地代碼的變化,主要是通過setupDevMiddleware方法實現的。
這個方法主要執行了webpack-dev-middleware庫。很多人分不清webpack-dev-middleware和webpack-dev-server的區別。其實就是因為webpack-dev-server只負責啟動服務和前置准備工作,所有文件相關的操作都抽離到webpack-dev-middleware庫了,主要是本地文件的編譯和輸出以及監聽,無非就是職責的划分更清晰了。
那我們來看下webpack-dev-middleware源碼里做了什么事:
// node_modules/webpack-dev-middleware/index.js compiler.watch(options.watchOptions, (err) => { if (err) { /*錯誤處理*/ } }); // 通過“memory-fs”庫將打包后的文件寫入內存 setFs(context, compiler);
(1)調用了compiler.watch方法,在第 1 步中也提到過,compiler的強大。這個方法主要就做了 2 件事:
- 首先對本地文件代碼進行編譯打包,也就是
webpack的一系列編譯流程。 - 其次編譯結束后,開啟對本地文件的監聽,當文件發生變化,重新編譯,編譯完成之后繼續監聽。
為什么代碼的改動保存會自動編譯,重新打包?這一系列的重新檢測編譯就歸功於compiler.watch這個方法了。監聽本地文件的變化主要是通過文件的生成時間是否有變化,這里就不細講了。
(2)執行setFs方法,這個方法主要目的就是將編譯后的文件打包到內存。這就是為什么在開發的過程中,你會發現dist目錄沒有打包后的代碼,因為都在內存中。原因就在於訪問內存中的代碼比訪問文件系統中的文件更快,而且也減少了代碼寫入文件的開銷,這一切都歸功於memory-fs。
5. 瀏覽器接收到熱更新的通知
我們已經可以監聽到文件的變化了,當文件發生變化,就觸發重新編譯。同時還監聽了每次編譯結束的事件。當監聽到一次webpack編譯結束,_sendStats方法就通過websoket給瀏覽器發送通知,檢查下是否需要熱更新。下面重點講的就是_sendStats方法中的ok和hash事件都做了什么。
那瀏覽器是如何接收到websocket的消息呢?回憶下第 2 步驟增加的入口文件,也就是websocket客戶端代碼。
'xxx/node_modules/webpack-dev-server/client/index.js?http://localhost:8080'
這個文件的代碼會被打包到bundle.js中,運行在瀏覽器中。來看下這個文件的核心代碼吧。
// webpack-dev-server/client/index.js var socket = require('./socket'); var onSocketMessage = { hash: function hash(_hash) { // 更新currentHash值 status.currentHash = _hash; }, ok: function ok() { sendMessage('Ok'); // 進行更新檢查等操作 reloadApp(options, status); }, }; // 連接服務地址socketUrl,?http://localhost:8080,本地服務地址 socket(socketUrl, onSocketMessage); function reloadApp() { if (hot) { log.info('[WDS] App hot update...'); // hotEmitter其實就是EventEmitter的實例 var hotEmitter = require('webpack/hot/emitter'); hotEmitter.emit('webpackHotUpdate', currentHash); } }
socket方法建立了websocket和服務端的連接,並注冊了 2 個監聽事件。
hash事件,更新最新一次打包后的hash值。ok事件,進行熱更新檢查。
熱更新檢查事件是調用reloadApp方法。比較奇怪的是,這個方法又利用node.js的EventEmitter,發出webpackHotUpdate消息。這是為什么?為什么不直接進行檢查更新呢?
個人理解就是為了更好的維護代碼,以及職責划分的更明確。websocket僅僅用於客戶端(瀏覽器)和服務端進行通信。而真正做事情的活還是交回給了webpack。
那webpack怎么做的呢?再來回憶下第 2 步。入口文件還有一個文件沒有講到,就是:
'xxx/node_modules/webpack/hot/dev-server.js'
這個文件的代碼同樣會被打包到bundle.js中,運行在瀏覽器中。這個文件做了什么就顯而易見了吧!先瞄一眼代碼:
// node_modules/webpack/hot/dev-server.js var check = function check() { module.hot.check(true) .then(function(updatedModules) { // 容錯,直接刷新頁面 if (!updatedModules) { window.location.reload(); return; } // 熱更新結束,打印信息 if (upToDate()) { log("info", "[HMR] App is up to date."); } }) .catch(function(err) { window.location.reload(); }); }; var hotEmitter = require("./emitter"); hotEmitter.on("webpackHotUpdate", function(currentHash) { lastHash = currentHash; check(); });
webpack監聽到了
webpackHotUpdate事件,並獲取最新了最新的
hash值,然后終於進行檢查更新了。檢查更新呢調用的是
module.hot.check方法。那么問題又來了,
module.hot.check又是哪里冒出來了的!答案是
HotModuleReplacementPlugin搞得鬼。這里留個疑問,繼續往下看。
6. HotModuleReplacementPlugin
前面好像一直是webpack-dev-server做的事,那HotModuleReplacementPlugin在熱更新過程中又做了什么偉大的事業呢?
首先你可以對比下,配置熱更新和不配置時bundle.js的區別。內存中看不到?直接執行webpack命令就可以看到生成的bundle.js文件啦。不要用webpack-dev-server啟動就好了。
(1)沒有配置的。
(2)配置了HotModuleReplacementPlugin或--hot的。

哦~ 我們發現moudle新增了一個屬性為hot,再看hotCreateModule方法。 這不就找到module.hot.check是哪里冒出來的。

經過對比打包后的文件,__webpack_require__中的moudle以及代碼行數的不同。我們都可以發現HotModuleReplacementPlugin原來也是默默的塞了很多代碼到bundle.js中呀。這和第 2 步驟很是相似哦!為什么,因為檢查更新是在瀏覽器中操作呀。這些代碼必須在運行時的環境。
你也可以直接看瀏覽器Sources下的代碼,會發現webpack和plugin偷偷加的代碼都在哦。在這里調試也很方便。

HotModuleReplacementPlugin如何做到的?這里我就不講了,因為這需要你對
tapable以及
plugin機制有一定了解,可以看下我寫的文章
Webpack插件機制之Tapable-源碼解析。當然你也可以選擇跳過,只關心熱更新機制即可,畢竟信息量太大。
7. moudle.hot.check 開始熱更新
通過第 6 步,我們就可以知道moudle.hot.check方法是如何來的啦。那都做了什么?之后的源碼都是HotModuleReplacementPlugin塞入到bundle.js中的哦,我就不寫文件路徑了。
- 利用上一次保存的
hash值,調用hotDownloadManifest發送xxx/hash.hot-update.json的ajax請求; - 請求結果獲取熱更新模塊,以及下次熱更新的
Hash標識,並進入熱更新准備階段。
hotAvailableFilesMap = update.c; // 需要更新的文件 hotUpdateNewHash = update.h; // 更新下次熱更新hash值 hotSetStatus("prepare"); // 進入熱更新准備狀態
- 調用
hotDownloadUpdateChunk發送xxx/hash.hot-update.js請求,通過JSONP方式。
function hotDownloadUpdateChunk(chunkId) { var script = document.createElement("script"); script.charset = "utf-8"; script.src = __webpack_require__.p + "" + chunkId + "." + hotCurrentHash + ".hot-update.js"; if (null) script.crossOrigin = null; document.head.appendChild(script); }
這個函數體為什么要單獨拿出來,因為這里要解釋下為什么使用JSONP獲取最新代碼?主要是因為JSONP獲取的代碼可以直接執行。為什么要直接執行?我們來回憶下/hash.hot-update.js的代碼格式是怎么樣的。

可以發現,新編譯后的代碼是在一個webpackHotUpdate函數體內部的。也就是要立即執行webpackHotUpdate這個方法。
再看下webpackHotUpdate這個方法。
window["webpackHotUpdate"] = function (chunkId, moreModules) { hotAddUpdateChunk(chunkId, moreModules); } ;
hotAddUpdateChunk方法會把更新的模塊moreModules賦值給全局全量hotUpdate。hotUpdateDownloaded方法會調用hotApply進行代碼的替換。
function hotAddUpdateChunk(chunkId, moreModules) { // 更新的模塊moreModules賦值給全局全量hotUpdate for (var moduleId in moreModules) { if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) { hotUpdate[moduleId] = moreModules[moduleId]; } } // 調用hotApply進行模塊的替換 hotUpdateDownloaded(); }
8. hotApply 熱更新模塊替換
熱更新的核心邏輯就在hotApply方法了。 hotApply代碼有將近 400 行,還是挑重點講了
①刪除過期的模塊,就是需要替換的模塊
通過hotUpdate可以找到舊模塊
var queue = outdatedModules.slice(); while (queue.length > 0) { moduleId = queue.pop(); // 從緩存中刪除過期的模塊 module = installedModules[moduleId]; // 刪除過期的依賴 delete outdatedDependencies[moduleId]; // 存儲了被刪掉的模塊id,便於更新代碼 outdatedSelfAcceptedModules.push({ module: moduleId }); }
②將新的模塊添加到 modules 中
appliedUpdate[moduleId] = hotUpdate[moduleId]; for (moduleId in appliedUpdate) { if (Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) { modules[moduleId] = appliedUpdate[moduleId]; } }
③通過__webpack_require__執行相關模塊的代碼
for (i = 0; i < outdatedSelfAcceptedModules.length; i++) { var item = outdatedSelfAcceptedModules[i]; moduleId = item.module; try { // 執行最新的代碼 __webpack_require__(moduleId); } catch (err) { // ...容錯處理 } }
hotApply的確比較復雜,知道大概流程就好了,這一小節,要求你對webpack打包后的文件如何執行的有一些了解,大家可以自去看下。
