聊聊Webpack熱更新以及原理
什么是熱更新
模塊熱替換(hot module replacement 或 HMR)是 webpack 提供的最有用的功能之一。它允許在運(yùn)行時(shí)更新所有類(lèi)型的模塊,而無(wú)需完全刷新
一般的刷新我們分兩種:
- 一種是頁(yè)面刷新,不保留頁(yè)面狀態(tài),就是簡(jiǎn)單粗暴,直接 window.location.reload()。
 - 另一種是基于 WDS (Webpack-dev-server) 的模塊熱替換,只需要局部刷新頁(yè)面上發(fā)生變化的模塊,同時(shí)可以保留當(dāng)前的頁(yè)面狀態(tài),比如復(fù)選框的選中狀態(tài)、輸入框的輸入等。
 
可以看到相比于第一種,熱更新對(duì)于我們的開(kāi)發(fā)體驗(yàn)以及開(kāi)發(fā)效率都具有重大的意義
HMR 作為一個(gè) Webpack 內(nèi)置的功能,可以通過(guò) HotModuleReplacementPlugin 或 --hot 開(kāi)啟。
具體我們?nèi)绾卧?webpack 中使用這個(gè)功能呢?
熱更新的使用以及簡(jiǎn)單分析
如何使用熱更新
- npm install webpack webpack-dev-server --save-dev
 
設(shè)置 HotModuleReplacementPlugin,HotModuleReplacementPlugin 是 webpack 是自帶的
- plugins: {
 - HotModuleReplacementPlugin: new webpack.HotModuleReplacementPlugin()
 - }
 
再設(shè)置一下 devServer
- devServer: {
 - contentBase: path.resolve(__dirname, 'dist'),
 - hot: true, // 重點(diǎn)關(guān)注
 - historyApiFallback: true,
 - compress: true
 - }
 
- hot 為 true,代表開(kāi)啟熱更新
 
兩個(gè)重要的文件
當(dāng)我們改變我們項(xiàng)目的文件的時(shí)候,比如我修改 Vue 的一個(gè) 方法:
更改前:
- clickMe() {
 - console.log('我是 Gopal,歡迎關(guān)注「前端雜貨鋪」');
 - }
 
更改后:
- clickMe() {
 - console.log('我是 Gopal,歡迎關(guān)注「前端雜貨鋪」,一起學(xué)習(xí)成長(zhǎng)吧');
 - }
 
瀏覽器會(huì)去請(qǐng)求兩個(gè)文件
接下來(lái)我們看看這兩個(gè)文件:
- JSON 文件,h 代表本次新生成的 Hash 值為 0c256052432b51ed32c8——本次輸出的 Hash 值會(huì)被作為下次熱更新的標(biāo)識(shí)。c 表示當(dāng)前要熱更新的文件對(duì)應(yīng)的是哪個(gè)模塊,可以讓 webpack 知道它要更新哪個(gè)模塊
 
- {
 - "h": "0c256052432b51ed32c8",
 - "c": {
 - "201": true
 - }
 - }
 
- js 文件,就是本次修改的代碼,重新編譯打包后的,大致是下面這個(gè)樣子(已刪減一些并格式化過(guò),這里看不懂沒(méi)關(guān)系的,就記住是返回要更新的模塊就好了),webpackHotUpdate 方法就是用來(lái)更新模塊的,201 對(duì)應(yīng)的是哪個(gè)模塊(我們稱(chēng)它為模塊標(biāo)識(shí)),其他的就是要更新的模塊的內(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,歡迎關(guān)注「前端雜貨鋪」,一起學(xué)習(xí)成長(zhǎng)吧");
 - },
 - },
 - };
 - exports.default = _default;
 - },
 - });
 
那么問(wèn)題來(lái)了,我修改了文件,瀏覽器是怎么知道要更新的呢?
了解一下 Websocket
熱更新使用到了 Websocket,這里不會(huì)細(xì)講 Websocket,可以看下阮一峰老師的 WebSocket 教程,下面是一個(gè) 簡(jiǎn)單的例子
- // 執(zhí)行上面語(yǔ)句之后,客戶(hù)端就會(huì)與服務(wù)器進(jìn)行連接。
 - var ws = new WebSocket("wss://echo.websocket.org");
 - // 實(shí)例對(duì)象的 onopen 屬性,用于指定連接成功后的回調(diào)函數(shù)
 - ws.onopen = function(evt) {
 - console.log("Connection open ...");
 - ws.send("Hello WebSockets!");
 - };
 - // 實(shí)例對(duì)象的 onmessage 屬性,用于指定收到服務(wù)器數(shù)據(jù)后的回調(diào)函數(shù)。可以接受二進(jìn)制數(shù)據(jù),blob 對(duì)象或者 Arraybuffer 對(duì)象
 - ws.onmessage = function(evt) {
 - console.log( "Received Message: " + evt.data);
 - ws.close();
 - };
 - // 實(shí)例對(duì)象的 onclose 屬性,用于指定連接關(guān)閉后的回調(diào)函數(shù)。
 - ws.onclose = function(evt) {
 - console.log("Connection closed.");
 - };
 
上面通過(guò) new Websocket 創(chuàng)建一個(gè)客戶(hù)端與服務(wù)端通信的實(shí)例,并通過(guò) onmessage屬性,接受指定服務(wù)器返回的數(shù)據(jù),并進(jìn)行相應(yīng)的處理。
這里大概解釋下,為什么是 Websocket ?因?yàn)?Websocket 是一種雙向協(xié)議,它最大的特點(diǎn)就是 服務(wù)器可以主動(dòng)向客戶(hù)端推送消息,客戶(hù)端也可以主動(dòng)向服務(wù)器發(fā)送信息。這是 HTTP 不具備的,熱更新實(shí)際上就是服務(wù)器端的更新通知到客戶(hù)端,所以選擇了 Websocket
接下來(lái)讓我們進(jìn)一步的討論關(guān)于熱更新的原理
熱更新原理
熱更新的過(guò)程
幾個(gè)重要的概念(這里有一個(gè)大致的概念就好,后面會(huì)把它們串起來(lái)):
- Webpack-complier :webpack 的編譯器,將 JavaScript 編譯成 bundle(就是最終的輸出文件)
 - HMR Server:將熱更新的文件輸出給 HMR Runtime
 - Bunble Server:提供文件在瀏覽器的訪問(wèn),也就是我們平時(shí)能夠正常通過(guò) localhost 訪問(wèn)我們本地網(wǎng)站的原因
 - HMR Runtime:開(kāi)啟了熱更新的話,在打包階段會(huì)被注入到瀏覽器中的 bundle.js,這樣 bundle.js 就可以跟服務(wù)器建立連接,通常是使用 websocket ,當(dāng)收到服務(wù)器的更新指令的時(shí)候,就 去更新文件的變化
 - bundle.js:構(gòu)建輸出的文件
 
啟動(dòng)階段
文件經(jīng)過(guò) Webpack-complier 編譯好后傳輸給 Bundle Server,Bundle Server 可以讓瀏覽器訪問(wèn)到我們打包出來(lái)的文件
下面流程圖中的 1、2、A、B階段
文件熱更新階段
文件經(jīng)過(guò) Webpack-complier 編譯好后傳輸給 HMR Server,HMR Server 知道哪個(gè)資源(模塊)發(fā)生了改變,并通知 HMR Runtime 有哪些變化(也就是上面我們看到的兩個(gè)請(qǐng)求),HMR Runtime 就會(huì)更新我們的代碼,這樣我們?yōu)g覽器就會(huì)更新并且不需要刷新
下面流程圖的 1、2、3、4、5 階段
參考 19 | webpack中的熱更新及原理分析
深入——源碼閱讀
我們還看回上圖,其中啟動(dòng)階段圖中的 1、2、A、B階段就不講解了,主要看熱更新階段主要講 3、4 和 5 階段
在開(kāi)始接下開(kāi)的閱讀前,我們?cè)倩氐阶畛醯膯?wèn)題上我本地修改了文件,瀏覽器是怎么知道要更新的呢?
通過(guò)上面的流程圖,其實(shí)我們可以猜測(cè),本地實(shí)際上啟動(dòng)了一個(gè) HMR Server 服務(wù),而且在啟動(dòng) Bundle Server 的時(shí)候已經(jīng)往我們的 bundle.js 中注入了 HMR Runtime(主要用來(lái)啟動(dòng) Websocket,接受 HMR Server 發(fā)來(lái)的變更)
所以我們聚焦以下幾點(diǎn):
- Webpack 如何啟動(dòng)了 HMR Server
 - HMR Server 如何跟 HMR Runtime 進(jìn)行通信的
 - HMR Runtime 接受到變更之后,如何生效的
 
以下的源碼解析分別對(duì)應(yīng)的版本是:
- webpack——5.24.3
 - webpack-dev-server——4.0.0-beta.0
 - webpack-dev-middleware——4.1.0
 
啟動(dòng) HMR Server
這個(gè)工作主要是在 webpack-dev-server 中完成的
看 lib/Server.js setupApp 方法,下面的 express 服務(wù)實(shí)際上對(duì)應(yīng)的是 Bundle Server
- setupApp() {
 - // Init express server
 - // eslint-disable-next-line new-cap
 - // 初始化 express 服務(wù)
 - // 使用 express 框架啟動(dòng)本地 server,讓瀏覽器可以請(qǐng)求本地的靜態(tài)資源。
 - this.app = new express();
 - }
 
啟動(dòng)服務(wù)結(jié)束之后就通過(guò) createSocketServer 創(chuàng)建 websocket 服務(wù)
- 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) {
 - // 啟動(dòng) express 服務(wù)之后,啟動(dòng) websocket 服務(wù)
 - this.createSocketServer();
 - }
 - });
 - })
 - );
 - }
 
- createSocketServer() {
 - this.socketServer = new this.SocketServerImplementation(this);
 - this.socketServer.onConnection((connection, headers) => {
 - });
 - }
 
HMR Server 和 HMR Runtime 的通信
首先要通信的第一個(gè)問(wèn)題在于——通信的時(shí)機(jī),什么時(shí)候我去通知客戶(hù)端我的文件更新。通過(guò) webpack 創(chuàng)建的 compiler 實(shí)例(監(jiān)聽(tīng)本地文件的變化、文件改變自動(dòng)編譯、編譯輸出),可以往 compiler.hooks.done 鉤子(代表 webpack 編譯完之后觸發(fā))注冊(cè)事件, 當(dāng)監(jiān)聽(tīng)到一次 webpack 編譯結(jié)束,就會(huì)調(diào)用 sendStats 方法
看 lib/Server.js 中的 setupHooks 方法
- // lib/Server.js
 - // 綁定監(jiān)聽(tīng)事件
 - setupHooks() {
 - // ...
 - const addHooks = (compiler) => {
 - // 監(jiān)聽(tīng) webpack 的 done 鉤子,tapable 提供的監(jiān)聽(tīng)方法
 - // done 標(biāo)識(shí)編譯結(jié)束
 - const { compile, invalid, done } = compiler.hooks;
 - compile.tap('webpack-dev-server', invalidPlugin);
 - invalid.tap('webpack-dev-server', invalidPlugin);
 - done.tap('webpack-dev-server', (stats) => {
 - // 當(dāng)監(jiān)聽(tīng)到一次webpack編譯結(jié)束,就會(huì)調(diào)用 sendStats 方法
 - this.sendStats(this.sockets, this.getStats(stats));
 - this.stats = stats;
 - });
 - };
 - }
 
當(dāng)監(jiān)聽(tīng)到一次 webpack 編譯結(jié)束,就會(huì)調(diào)用 sendStats 方法,里面會(huì)向客戶(hù)端發(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 中,會(huì)去更新 hash,并且在 ok 的時(shí)候去進(jìn)行檢查更新 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);
 - }
 - // 進(jìn)行更新檢查等操作
 - reloadApp(options, status);
 - }
 - };
 
接下來(lái)我們看看 client-src/default/utils/reloadApp.js 中的 reloadApp。這里又利用 node.js 的 EventEmitter,發(fā)出webpackHotUpdate 消息。這里又將更新的事情給回了 webpack(為了更好的維護(hù)代碼,以及職責(zé)劃分的更明確。)
- function reloadApp(
 - { hotReload, hot, liveReload },
 - { isUnloading, currentHash }
 - ) {
 - // ...
 - if (hot) {
 - log.info('App hot update...');
 - // hotEmitter 其實(shí)就是 EventEmitter 的實(shí)例
 - const hotEmitter = require('webpack/hot/emitter');
 - // 又利用 node.js 的 EventEmitter,發(fā)出 webpackHotUpdate 消息。
 - // websocket 僅僅用于客戶(hù)端(瀏覽器)和服務(wù)端進(jìn)行通信。而真正做事情的活還是交回給了 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)聽(tīng) webpackHotUpdate 事件,并執(zhí)行 check 方法。并在 check 方法中調(diào)用 module.hot.check 方法進(jìn)行熱更新。
- // hot/dev-server.js
 - // 監(jiān)聽(tīng)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 開(kāi)始熱更新
 - // 之后的源碼都是HotModuleReplacementPlugin塞入到bundle.js中的哦,我就不寫(xiě)文件路徑了
 - module.hot
 - .check(true)
 - .then(function (updatedModules) {
 - // ...
 - })
 - .catch(function (err) {
 - // ...
 - });
 - };
 
至于 module.hot.check ,實(shí)際上通過(guò) HotModuleReplacementPlugin 已經(jīng)注入到我們 chunk 中了(也就是我們上面所說(shuō)的 HMR Runtime),所以后面就是它是如何更新 bundle.js 的呢?
HMR Runtime 中更新 bundle.js
如果我們仔細(xì)看我們的打包后的文件的話,開(kāi)啟熱更新之后生成的代碼會(huì)比不開(kāi)啟多出很多東西(為了更加直觀看到,可以將其輸出到本地),這些就是幫助 webpack 在瀏覽器端去更新 bundle.js 的 HMR Runtime 代碼
來(lái)看打包后的代碼中新增了一個(gè) createModuleHotObject
- module.hot = createModuleHotObject(options.id, module);
 
實(shí)際上這個(gè)函數(shù)就是用來(lái)返回一個(gè) hot 對(duì)象,所以調(diào)用 module.hot.check 的時(shí)候,實(shí)際上就是執(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 中調(diào)用了 __webpack_require__.hmrM
- function hotCheck(applyOnUpdate) {
 - setStatus("check");
 - return __webpack_require__.hmrM().then(function (update) {
 - }
 - }
 
__webpack_require__.hmrM——加載.hot-update.json
來(lái)看 __webpack_require__.hmrM, 其中 __webpack_require__.p 指的是我們本地服務(wù)的域名,類(lèi)似 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");
 - })();
 
加載要更新的模塊
下面來(lái)看如何加載我們要更新的模塊的,可以看到打包出來(lái)的代碼中有 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);
 - });
 - }
 
再來(lái)看 __webpack_require__.l,主要通過(guò)類(lèi)似 JSONP 的方式進(jìn)行,因?yàn)镴SONP獲取的代碼可以直接執(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);
 - };
 
還記得我們一開(kāi)始提到的返回的 JS 中就是一個(gè) webpackHotUpdate 函數(shù)么?實(shí)際上在我們的 HMR Runtime 中就是全局定義了(下面的名稱(chēng)是 webpackHotUpdatelearn_hot_reload,應(yīng)該是 webpack 版本不一樣導(dǎo)致的,不影響理解)至于生成的代碼是如何生效的,請(qǐng)移步我的另外一篇文章——【W(wǎng)ebpack 進(jìn)階】Webpack 打包后的代碼是怎樣的?
- // webpackHotUpdate + 項(xiàng)目名
 - 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]();
 - waitingUpdateResolves[chunkId] = undefined;
 - }
 - };
 
所以,客戶(hù)端接受到服務(wù)器端推動(dòng)的消息后,如果需要熱更新,瀏覽器發(fā)起 http 請(qǐng)求去服務(wù)器端獲取新的模塊資源解析并局部刷新頁(yè)面
以上整體的流程如下所示:
總結(jié)
本文介紹了 webpack 熱更新的簡(jiǎn)單使用、相關(guān)的流程以及原理。小結(jié)一下,webpack 如果開(kāi)啟了熱更新的時(shí)候
- HMR Runtime 通過(guò) HotModuleReplacementPlugin 已經(jīng)注入到我們 chunk 中了
 - 除了開(kāi)啟一個(gè) Bundle Server,還開(kāi)啟了 HMR Server,主要用來(lái)和 HMR Runtime 中通信
 - 在編譯結(jié)束的時(shí)候,通過(guò) compiler.hooks.done,監(jiān)聽(tīng)并通知客戶(hù)端
 - 客戶(hù)端接收到之后,就會(huì)調(diào)用 module.hot.check 等,發(fā)起 http 請(qǐng)求去服務(wù)器端獲取新的模塊資源解析并局部刷新頁(yè)面
 



















 
 
 










 
 
 
 