【webpack 進階】聊聊 webpack 熱更新以及原理
什么是熱更新
模塊熱替換 (hot module replacement 或 HMR) 是 webpack 提供的最有用的功能之一。它允許在運行時更新所有類型的模塊,而無需完全刷新
一般的刷新我們分兩種:
一種是頁面刷新,不保留頁面狀態(tài),就是簡單粗暴,直接 window.location.reload()。另一種是基于 WDS (Webpack-dev-server)的模塊熱替換,只需要局部刷新頁面上發(fā)生變化的模塊,同時可以保留當前的頁面狀態(tài),比如復選框的選中狀態(tài)、輸入框的輸入等。
可以看到相比于第一種,熱更新對于我們的開發(fā)體驗以及開發(fā)效率都具有重大的意義
HMR 作為一個 Webpack 內(nèi)置的功能,可以通過 HotModuleReplacementPlugin 或 --hot 開啟。
具體我們?nèi)绾卧? webpack 中使用這個功能呢?
熱更新的使用以及簡單分析
如何使用熱更新
npm install webpack webpack-dev-server --save-dev
設置 HotModuleReplacementPlugin,HotModuleReplacementPlugin 是 webpack 是自帶的
plugins: {
HotModuleReplacementPlugin: new webpack.HotModuleReplacementPlugin()
}
再設置一下 devServer
devServer: {
contentBase: path.resolve(__dirname, 'dist'),
hot: true, // 重點關注
historyApiFallback: true,
compress: true
}
hot為true,代表開啟熱更新
兩個重要的文件
當我們改變我們項目的文件的時候,比如我修改 Vue 的一個 方法:
更改前:
clickMe() {
console.log('我是 Gopal,歡迎關注「前端雜貨鋪」');
}
更改后:
clickMe() {
console.log('我是 Gopal,歡迎關注「前端雜貨鋪」,一起學習成長吧');
}
瀏覽器會去請求兩個文件

接下來我們看看這兩個文件:
JSON文件,h代表本次新生成的Hash值為0c256052432b51ed32c8—— 本次輸出的Hash值會被作為下次熱更新的標識。c表示當前要熱更新的文件對應的是哪個模塊,可以讓webpack知道它要更新哪個模塊
{
"h": "0c256052432b51ed32c8",
"c": {
"201": true
}
}
js文件,就是本次修改的代碼,重新編譯打包后的,大致是下面這個樣子(已刪減一些并格式化過,這里看不懂沒關系的,就記住是返回要更新的模塊就好了),webpackHotUpdate方法就是用來更新模塊的,201對應的是哪個模塊(我們稱它為模塊標識),其他的就是要更新的模塊的內(nèi)容了
webpackHotUpdate(201, {
"./src/views/moveTransfer/list/index.vue?vue&type=script&lang=js&": function (
module,
exports,
__webpack_require__
) {
"use strict";
var _Object$defineProperty = __webpack_require__(
/*! @babel/runtime-corejs3/core-js-stable/object/define-property */ "./node_modules/@babel/runtime-corejs3/core-js-stable/object/define-property.js"
);
_Object$defineProperty(exports, "__esModule", {
value: true,
});
exports.default = void 0;
var _default = {
data: function data() {
return {};
},
computed: {},
methods: {
clickMe: function clickMe() {
console.log("我是 Gopal,歡迎關注「前端雜貨鋪」,一起學習成長吧");
},
},
};
exports.default = _default;
},
});
那么問題來了,我修改了文件,瀏覽器是怎么知道要更新的呢?
了解一下 Websocket
熱更新使用到了 Websocket,這里不會細講 Websocket,可以看下阮一峰老師的 WebSocket 教程 [1],下面是一個 簡單的例子 [2]
// 執(zhí)行上面語句之后,客戶端就會與服務器進行連接。
var ws = new WebSocket("wss://echo.websocket.org");
// 實例對象的 onopen 屬性,用于指定連接成功后的回調函數(shù)
ws.onopen = function(evt) {
console.log("Connection open ...");
ws.send("Hello WebSockets!");
};
// 實例對象的 onmessage 屬性,用于指定收到服務器數(shù)據(jù)后的回調函數(shù)。可以接受二進制數(shù)據(jù),blob 對象或者 Arraybuffer 對象
ws.onmessage = function(evt) {
console.log( "Received Message: " + evt.data);
ws.close();
};
// 實例對象的 onclose 屬性,用于指定連接關閉后的回調函數(shù)。
ws.onclose = function(evt) {
console.log("Connection closed.");
};
上面通過 new Websocket 創(chuàng)建一個客戶端與服務端通信的實例,并通過 onmessage 屬性,接受指定服務器返回的數(shù)據(jù),并進行相應的處理。
這里大概解釋下,為什么是 Websocket ?因為 Websocket 是一種雙向協(xié)議,它最大的特點就是 服務器可以主動向客戶端推送消息,客戶端也可以主動向服務器發(fā)送信息。這是 HTTP 不具備的,熱更新實際上就是服務器端的更新通知到客戶端,所以選擇了 Websocket
接下來讓我們進一步的討論關于熱更新的原理
熱更新原理
熱更新的過程
幾個重要的概念(這里有一個大致的概念就好,后面會把它們串起來):
Webpack-complier:webpack的編譯器,將JavaScript編譯成bundle(就是最終的輸出文件)HMR Server:將熱更新的文件輸出給HMR RuntimeBunble Server:提供文件在瀏覽器的訪問,也就是我們平時能夠正常通過localhost訪問我們本地網(wǎng)站的原因HMR Runtime:開啟了熱更新的話,在打包階段會被注入到瀏覽器中的bundle.js,這樣bundle.js就可以跟服務器建立連接,通常是使用websocket,當收到服務器的更新指令的時候,就去更新文件的變化bundle.js:構建輸出的文件
啟動階段
文件經(jīng)過 Webpack-complier 編譯好后傳輸給 Bundle Server,Bundle Server 可以讓瀏覽器訪問到我們打包出來的文件
下面流程圖中的 1、2、A、B 階段
文件熱更新階段
文件經(jīng)過 Webpack-complier 編譯好后傳輸給 HMR Server,HMR Server 知道哪個資源 (模塊) 發(fā)生了改變,并通知 HMR Runtime 有哪些變化(也就是上面我們看到的兩個請求),HMR Runtime 就會更新我們的代碼,這樣我們?yōu)g覽器就會更新并且不需要刷新
下面流程圖的 1、2、3、4、5 階段
參考 19 | webpack 中的熱更新及原理分析 [3]

深入 —— 源碼閱讀
我們還看回上圖,其中啟動階段圖中的 1、2、A、B 階段就不講解了,主要看熱更新階段主要講 3、4 和 5 階段
在開始接下開的閱讀前,我們再回到最初的問題上我本地修改了文件,瀏覽器是怎么知道要更新的呢?
通過上面的流程圖,其實我們可以猜測,本地實際上啟動了一個 HMR Server 服務,而且在啟動 Bundle Server 的時候已經(jīng)往我們的 bundle.js 中注入了 HMR Runtime(主要用來啟動 Websocket,接受 HMR Server 發(fā)來的變更)
所以我們聚焦以下幾點:
Webpack如何啟動了 HMR ServerHMR Server 如何跟 HMR Runtime 進行通信的 HMR Runtime接受到變更之后,如何生效的
以下的源碼解析分別對應的版本是:
webpack—— 5.24.3webpack-dev-server—— 4.0.0-beta.0webpack-dev-middleware—— 4.1.0
啟動 HMR Server
這個工作主要是在 webpack-dev-server 中完成的
看 lib/Server.js setupApp 方法,下面的 express 服務實際上對應的是 Bundle Server
setupApp() {
// Init express server
// eslint-disable-next-line new-cap
// 初始化 express 服務
// 使用 express 框架啟動本地 server,讓瀏覽器可以請求本地的靜態(tài)資源。
this.app = new express();
}
啟動服務結束之后就通過 createSocketServer 創(chuàng)建 websocket 服務
listen(port, hostname, fn) {
this.hostname = hostname;
return (
findPort(port || this.options.port)
.then((port) => {
this.port = port;
return this.server.listen(port, hostname, (err) => {
if (this.options.hot || this.options.liveReload) {
// 啟動 express 服務之后,啟動 websocket 服務
this.createSocketServer();
}
});
})
);
}
createSocketServer() {
this.socketServer = new this.SocketServerImplementation(this);
this.socketServer.onConnection((connection, headers) => {
});
}
HMR Server 和 HMR Runtime 的通信
首先要通信的第一個問題在于 —— 通信的時機,什么時候我去通知客戶端我的文件更新。通過 webpack 創(chuàng)建的 compiler 實例(監(jiān)聽本地文件的變化、文件改變自動編譯、編譯輸出),可以往 compiler.hooks.done 鉤子(代表 webpack 編譯完之后觸發(fā))注冊事件, 當監(jiān)聽到一次 webpack 編譯結束,就會調用 sendStats 方法
看 lib/Server.js 中的 setupHooks 方法
// lib/Server.js
// 綁定監(jiān)聽事件
setupHooks() {
// ...
const addHooks = (compiler) => {
// 監(jiān)聽 webpack 的 done 鉤子,tapable 提供的監(jiān)聽方法
// done 標識編譯結束
const { compile, invalid, done } = compiler.hooks;
compile.tap('webpack-dev-server', invalidPlugin);
invalid.tap('webpack-dev-server', invalidPlugin);
done.tap('webpack-dev-server', (stats) => {
// 當監(jiān)聽到一次webpack編譯結束,就會調用 sendStats 方法
this.sendStats(this.sockets, this.getStats(stats));
this.stats = stats;
});
};
}
當監(jiān)聽到一次 webpack 編譯結束,就會調用 sendStats 方法,里面會向客戶端發(fā)送 hash 和 ok 事件
// lib/Server.js
// send stats to a socket or multiple sockets
sendStats(sockets, stats, force) {
// ok和 hash
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-src/default/index.js 中,會去更新 hash,并且在 ok 的時候去進行檢查更新 reloadApp
// client-src/default/index.js
const onSocketMessage = {
// 更新 current Hash
hash(hash) {
status.currentHash = hash;
},
'progress-update': function progressUpdate(data) {
if (options.useProgress) {
log.info(`${data.percent}% - ${data.msg}.`);
}
sendMessage('Progress', data);
},
ok() {
sendMessage('Ok');
if (options.useWarningOverlay || options.useErrorOverlay) {
overlay.clear();
}
if (options.initial) {
return (options.initial = false);
}
// 進行更新檢查等操作
reloadApp(options, status);
}
};
接下來我們看看 client-src/default/utils/reloadApp.js 中的 reloadApp。這里又利用 node.js 的 EventEmitter,發(fā)出 webpackHotUpdate 消息。這里又將更新的事情給回了 webpack(為了更好的維護代碼,以及職責劃分的更明確。)
function reloadApp(
{ hotReload, hot, liveReload },
{ isUnloading, currentHash }
) {
// ...
if (hot) {
log.info('App hot update...');
// hotEmitter 其實就是 EventEmitter 的實例
const hotEmitter = require('webpack/hot/emitter');
// 又利用 node.js 的 EventEmitter,發(fā)出 webpackHotUpdate 消息。
// websocket 僅僅用于客戶端(瀏覽器)和服務端進行通信。而真正做事情的活還是交回給了 webpack。
hotEmitter.emit('webpackHotUpdate', currentHash);
if (typeof self !== 'undefined' && self.window) {
// broadcast update to window
self.postMessage(`webpackHotUpdate${currentHash}`, '*');
}
}
// ...
}
module.exports = reloadApp;
在 webpack 的 hot/dev-server.js 中,監(jiān)聽 webpackHotUpdate 事件,并執(zhí)行 check 方法。并在 check 方法中調用 module.hot.check 方法進行熱更新。
// hot/dev-server.js
// 監(jiān)聽webpackHotUpdate事件
hotEmitter.on("webpackHotUpdate", function (currentHash) {
lastHash = currentHash;
if (!upToDate() && module.hot.status() === "idle") {
log("info", "[HMR] Checking for updates on the server...");
check();
}
});
var check = function check() {
// moudle.hot.check 開始熱更新
// 之后的源碼都是HotModuleReplacementPlugin塞入到bundle.js中的哦,我就不寫文件路徑了
module.hot
.check(true)
.then(function (updatedModules) {
// ...
})
.catch(function (err) {
// ...
});
};
至于 module.hot.check ,實際上通過 HotModuleReplacementPlugin 已經(jīng)注入到我們 chunk 中了(也就是我們上面所說的 HMR Runtime),所以后面就是它是如何更新 bundle.js 的呢?
HMR Runtime 中更新 bundle.js
如果我們仔細看我們的打包后的文件的話,開啟熱更新之后生成的代碼會比不開啟多出很多東西(為了更加直觀看到,可以將其輸出到本地),這些就是幫助 webpack 在瀏覽器端去更新 bundle.js 的 HMR Runtime 代碼
來看打包后的代碼中新增了一個 createModuleHotObject
module.hot = createModuleHotObject(options.id, module);
實際上這個函數(shù)就是用來返回一個 hot 對象,所以調用 module.hot.check 的時候,實際上就是執(zhí)行 hotCheck 函數(shù)
function createModuleHotObject(moduleId, me) {
var hot = {
// Module API
addDisposeHandler: function (callback) {
hot._disposeHandlers.push(callback);
},
removeDisposeHandler: function (callback) {
var idx = hot._disposeHandlers.indexOf(callback);
if (idx >= 0) hot._disposeHandlers.splice(idx, 1);
},
// Management API
check: hotCheck,
apply: hotApply,
status: function (l) {
if (!l) return currentStatus;
registeredStatusHandlers.push(l);
},
addStatusHandler: function (l) {
registeredStatusHandlers.push(l);
},
removeStatusHandler: function (l) {
var idx = registeredStatusHandlers.indexOf(l);
if (idx >= 0) registeredStatusHandlers.splice(idx, 1);
},
};
currentChildModule = undefined;
return hot;
}
其中就有 hotCheck 中調用了 __webpack_require__.hmrM
function hotCheck(applyOnUpdate) {
setStatus("check");
return __webpack_require__.hmrM().then(function (update) {
}
}
__webpack_require__.hmrM—— 加載.hot-update.json
來看 __webpack_require__.hmrM, 其中 __webpack_require__.p 指的是我們本地服務的域名,類似 http://0.0.0.0:9528 , 另外 __webpack_require__.hmrF 去獲取 .hot-update.json 文件的地址,就是我們之前提到的重要文件之一
__webpack_require__.hmrM = () => {
if (typeof fetch === "undefined") throw new Error("No browser support: need fetch API");
return fetch(__webpack_require__.p + __webpack_require__.hmrF()).then((response) => {
if(response.status === 404) return; // no update available
if(!response.ok) throw new Error("Failed to fetch update manifest " + response.statusText);
return response.json();
});
};
/* webpack/runtime/get update manifest filename */
(() => {
__webpack_require__.hmrF = () => ("main." + __webpack_require__.h() + ".hot-update.json");
})();
加載要更新的模塊
下面來看如何加載我們要更新的模塊的,可以看到打包出來的代碼中有 loadUpdateChunk
function loadUpdateChunk(chunkId) {
return new Promise((resolve, reject) => {
var url = __webpack_require__.p + __webpack_require__.hu(chunkId);
// create error before stack unwound to get useful stacktrace later
var error = new Error();
var loadingEnded = (event) => {
// ...加載后的處理
};
__webpack_require__.l(url, loadingEnded);
});
}
再來看 __webpack_require__.l,主要通過類似 JSONP 的方式進行,因為 JSONP 獲取的代碼可以直接執(zhí)行。
__webpack_require__.l = (url, done, key, chunkId) => {
// ...
if (!script) {
script = document.createElement("script");
script.charset = "utf-8";
script.timeout = 120;
if (__webpack_require__.nc) {
script.setAttribute("nonce", __webpack_require__.nc);
}
script.setAttribute("data-webpack", dataWebpackPrefix + key);
script.src = url;
}
// ...
needAttach && document.head.appendChild(script);
};
還記得我們一開始提到的返回的 JS 中就是一個 webpackHotUpdate 函數(shù)么?實際上在我們的 HMR Runtime 中就是全局定義了(下面的名稱是 webpackHotUpdatelearn_hot_reload,應該是 webpack 版本不一樣導致的,不影響理解)至于生成的代碼是如何生效的,請移步我的另外一篇文章 ——【W(wǎng)ebpack 進階】Webpack 打包后的代碼是怎樣的?[4]
// webpackHotUpdate + 項目名
self["webpackHotUpdatelearn_hot_reload"] = (chunkId, moreModules, runtime) => {
for(var moduleId in moreModules) {
if(__webpack_require__.o(moreModules, moduleId)) {
currentUpdate[moduleId] = moreModules[moduleId];
if(currentUpdatedModulesList) currentUpdatedModulesList.push(moduleId);
}
}
if(runtime) currentUpdateRuntime.push(runtime);
if(waitingUpdateResolves[chunkId]) {
waitingUpdateResolves[chunkId]( "chunkId");
waitingUpdateResolves[chunkId] = undefined;
}
};
所以,客戶端接受到服務器端推動的消息后,如果需要熱更新,瀏覽器發(fā)起 http 請求去服務器端獲取新的模塊資源解析并局部刷新頁面
以上整體的流程如下所示:

總結
本文介紹了 webpack 熱更新的簡單使用、相關的流程以及原理。小結一下,webpack 如果開啟了熱更新的時候
HMR Runtime通過HotModuleReplacementPlugin已經(jīng)注入到我們chunk中了除了開啟一個
Bundle Server,還開啟了HMR Server,主要用來和HMR Runtime中通信在編譯結束的時候,通過
compiler.hooks.done,監(jiān)聽并通知客戶端客戶端接收到之后,就會調用
module.hot.check等,發(fā)起 http 請求去服務器端獲取新的模塊資源解析并局部刷新頁面

參考
模塊熱替換 (hot module replacement)[5] 輕松理解 webpack 熱更新原理 [6] WebSocket 教程 [7] 搞懂 webpack 熱更新原理 [8] 看完這篇,再也不怕被問 Webpack 熱更新 [9] 從零實現(xiàn) webpack 熱更新 HMR[10]
參考資料
WebSocket 教程: https://www.ruanyifeng.com/blog/2017/05/websocket.html
[2]簡單的例子: https://jsbin.com/muqamiqimu/edit?js,console
[3]19 | webpack 中的熱更新及原理分析: https://time.geekbang.org/course/detail/100028901-98391
[4]【W(wǎng)ebpack 進階】Webpack 打包后的代碼是怎樣的?: https://juejin.cn/post/6937086236926410783
[5]模塊熱替換 (hot module replacement): https://webpack.docschina.org/concepts/hot-module-replacement/
[6]輕松理解 webpack 熱更新原理: https://juejin.cn/post/6844904008432222215
[7]WebSocket 教程: https://www.ruanyifeng.com/blog/2017/05/websocket.html
[8]搞懂 webpack 熱更新原理: https://juejin.cn/post/6844903933157048333#heading-29
[9]看完這篇,再也不怕被問 Webpack 熱更新: https://www.infoq.cn/article/dioufdrtt3rocojvlrcl
[10]從零實現(xiàn) webpack 熱更新 HMR: https://time.geekbang.org/course/detail/100028901-98391
