什么是热更新
模块热替换(hot module replacement 或 HMR)是 webpack 提供的最有用的功能之一。它允许在运行时更新所有类型的模块,而无需完全刷新
一般的刷新我们分两种:
-
一种是页面刷新,不保留页面状态,就是简单粗暴,直接
window.location.reload()。 -
另一种是基于
WDS (Webpack-dev-server)的模块热替换,只需要局部刷新页面上发生变化的模块,同时可以保留当前的页面状态,比如复选框的选中状态、输入框的输入等。
可以看到相比于第一种,热更新对于我们的开发体验以及开发效率都具有重大的意义
HMR 作为一个 Webpack 内置的功能,可以通过 HotModuleReplacementPlugin 或 --hot 开启。
具体我们如何在 webpack 中使用这个功能呢?
热更新的使用以及简单分析
如何使用热更新
1npm install webpack webpack-dev-server --save-dev 2
设置 HotModuleReplacementPlugin,HotModuleReplacementPlugin 是 webpack 是自带的
1plugins: { 2 HotModuleReplacementPlugin: new webpack.HotModuleReplacementPlugin() 3}
再设置一下 devServer
1devServer: { 2 contentBase: path.resolve(__dirname, 'dist'), 3 hot: true, // 重点关注 4 historyApiFallback: true, 5 compress: true 6}
hot为true,代表开启热更新
两个重要的文件
当我们改变我们项目的文件的时候,比如我修改 Vue 的一个 方法:
更改前:
1clickMe() { 2 console.log('我是 Gopal,欢迎关注「前端杂货铺」'); 3}
更改后:
1clickMe() { 2 console.log('我是 Gopal,欢迎关注「前端杂货铺」,一起学习成长吧'); 3}
浏览器会去请求两个文件

接下来我们看看这两个文件:
JSON文件,h代表本次新生成的Hash值为0c256052432b51ed32c8——本次输出的Hash值会被作为下次热更新的标识。c表示当前要热更新的文件对应的是哪个模块,可以让webpack知道它要更新哪个模块
1{ 2 "h": "0c256052432b51ed32c8", 3 "c": { 4 "201": true 5 } 6}
js文件,就是本次修改的代码,重新编译打包后的,大致是下面这个样子(已删减一些并格式化过,这里看不懂没关系的,就记住是返回要更新的模块就好了),webpackHotUpdate方法就是用来更新模块的,201对应的是哪个模块(我们称它为模块标识),其他的就是要更新的模块的内容了
1webpackHotUpdate(201, { 2 "./src/views/moveTransfer/list/index.vue?vue&type=script&lang=js&": function ( module, 3 exports, 4 __webpack_require__ ) { 5 "use strict"; 6 7 var _Object$defineProperty = __webpack_require__( 8 /*! @babel/runtime-corejs3/core-js-stable/object/define-property */ "./node_modules/@babel/runtime-corejs3/core-js-stable/object/define-property.js" 9 ); 10 11 _Object$defineProperty(exports, "__esModule", { 12 value: true, 13 }); 14 15 exports.default = void 0; 16 17 var _default = { 18 data: function data() { 19 return {}; 20 }, 21 computed: {}, 22 methods: { 23 clickMe: function clickMe() { 24 console.log("我是 Gopal,欢迎关注「前端杂货铺」,一起学习成长吧"); 25 }, 26 }, 27 }; 28 exports.default = _default; 29 }, 30}); 31
那么问题来了,我修改了文件,浏览器是怎么知道要更新的呢?
了解一下 Websocket
热更新使用到了 Websocket,这里不会细讲 Websocket,可以看下阮一峰老师的 WebSocket 教程,下面是一个 简单的例子
1// 执行上面语句之后,客户端就会与服务器进行连接。 2var ws = new WebSocket("wss://echo.websocket.org"); 3 4// 实例对象的 onopen 属性,用于指定连接成功后的回调函数 5ws.onopen = function(evt) { 6 console.log("Connection open ..."); 7 ws.send("Hello WebSockets!"); 8}; 9 10// 实例对象的 onmessage 属性,用于指定收到服务器数据后的回调函数。可以接受二进制数据,blob 对象或者 Arraybuffer 对象 11ws.onmessage = function(evt) { 12 console.log( "Received Message: " + evt.data); 13 ws.close(); 14}; 15 16// 实例对象的 onclose 属性,用于指定连接关闭后的回调函数。 17ws.onclose = function(evt) { 18 console.log("Connection closed."); 19};`
上面通过 new Websocket 创建一个客户端与服务端通信的实例,并通过 onmessage属性,接受指定服务器返回的数据,并进行相应的处理。
这里大概解释下,为什么是 Websocket ?因为 Websocket 是一种双向协议,它最大的特点就是 服务器可以主动向客户端推送消息,客户端也可以主动向服务器发送信息。这是 HTTP 不具备的,热更新实际上就是服务器端的更新通知到客户端,所以选择了 Websocket
接下来让我们进一步的讨论关于热更新的原理
热更新原理
热更新的过程
几个重要的概念(这里有一个大致的概念就好,后面会把它们串起来):
-
Webpack-complier:webpack的编译器,将JavaScript编译成bundle(就是最终的输出文件) -
HMR Server:将热更新的文件输出给HMR Runtime -
Bunble Server:提供文件在浏览器的访问,也就是我们平时能够正常通过localhost访问我们本地网站的原因 -
HMR Runtime:开启了热更新的话,在打包阶段会被注入到浏览器中的bundle.js,这样bundle.js就可以跟服务器建立连接,通常是使用websocket,当收到服务器的更新指令的时候,就去更新文件的变化 -
bundle.js:构建输出的文件
启动阶段
文件经过 Webpack-complier 编译好后传输给 Bundle Server,Bundle Server 可以让浏览器访问到我们打包出来的文件
下面流程图中的 1、2、A、B阶段
文件热更新阶段
文件经过 Webpack-complier 编译好后传输给 HMR Server,HMR Server 知道哪个资源(模块)发生了改变,并通知 HMR Runtime 有哪些变化(也就是上面我们看到的两个请求),HMR Runtime 就会更新我们的代码,这样我们浏览器就会更新并且不需要刷新
下面流程图的 1、2、3、4、5 阶段
参考 19 | webpack中的热更新及原理分析

深入——源码阅读
我们还看回上图,其中启动阶段图中的 1、2、A、B阶段就不讲解了,主要看热更新阶段主要讲 3、4 和 5 阶段
在开始接下开的阅读前,我们再回到最初的问题上我本地修改了文件,浏览器是怎么知道要更新的呢?
通过上面的流程图,其实我们可以猜测,本地实际上启动了一个 HMR Server 服务,而且在启动 Bundle Server 的时候已经往我们的 bundle.js 中注入了 HMR Runtime(主要用来启动 Websocket,接受 HMR Server 发来的变更)
所以我们聚焦以下几点:
-
Webpack如何启动了 HMR Server -
HMR Server 如何跟 HMR Runtime 进行通信的
-
HMR Runtime接受到变更之后,如何生效的
以下的源码解析分别对应的版本是:
-
webpack——
5.24.3 -
webpack-dev-server——
4.0.0-beta.0 -
webpack-dev-middleware——
4.1.0
启动 HMR Server
这个工作主要是在 webpack-dev-server 中完成的
看 lib/Server.js setupApp 方法,下面的 express 服务实际上对应的是 Bundle Server
1setupApp() { 2 // Init express server 3 // eslint-disable-next-line new-cap 4 // 初始化 express 服务 5 // 使用 express 框架启动本地 server,让浏览器可以请求本地的静态资源。 6 this.app = new express(); 7}
启动服务结束之后就通过 createSocketServer 创建 websocket 服务
1listen(port, hostname, fn) { 2 this.hostname = hostname; 3 return ( 4 findPort(port || this.options.port) 5 .then((port) => { 6 this.port = port; 7 return this.server.listen(port, hostname, (err) => { 8 if (this.options.hot || this.options.liveReload) { 9 // 启动 express 服务之后,启动 websocket 服务 10 this.createSocketServer(); 11 } 12 }); 13 }) 14 ); 15}
1createSocketServer() { 2 this.socketServer = new this.SocketServerImplementation(this); 3 4 this.socketServer.onConnection((connection, headers) => { 5 6 }); 7}
HMR Server 和 HMR Runtime 的通信
首先要通信的第一个问题在于——通信的时机,什么时候我去通知客户端我的文件更新。通过 webpack 创建的 compiler 实例(监听本地文件的变化、文件改变自动编译、编译输出),可以往 compiler.hooks.done 钩子(代表 webpack 编译完之后触发)注册事件, 当监听到一次 webpack 编译结束,就会调用 sendStats 方法
看 lib/Server.js 中的 setupHooks 方法
1// lib/Server.js 2// 绑定监听事件 3setupHooks() { 4 // ... 5 const addHooks = (compiler) => { 6 // 监听 webpack 的 done 钩子,tapable 提供的监听方法 7 // done 标识编译结束 8 const { compile, invalid, done } = compiler.hooks; 9 compile.tap('webpack-dev-server', invalidPlugin); 10 invalid.tap('webpack-dev-server', invalidPlugin); 11 done.tap('webpack-dev-server', (stats) => { 12 // 当监听到一次webpack编译结束,就会调用 sendStats 方法 13 this.sendStats(this.sockets, this.getStats(stats)); 14 this.stats = stats; 15 }); 16 }; 17}
当监听到一次 webpack 编译结束,就会调用 sendStats 方法,里面会向客户端发送 hash 和 ok 事件
1// lib/Server.js 2// send stats to a socket or multiple sockets 3sendStats(sockets, stats, force) { 4 // ok和 hash 5 this.sockWrite(sockets, 'hash', stats.hash); 6 7 if (stats.errors.length > 0) { 8 this.sockWrite(sockets, 'errors', stats.errors); 9 } else if (stats.warnings.length > 0) { 10 this.sockWrite(sockets, 'warnings', stats.warnings); 11 } else { 12 this.sockWrite(sockets, 'ok'); 13 } 14}
在 client-src/default/index.js 中,会去更新 hash,并且在 ok 的时候去进行检查更新 reloadApp
1// client-src/default/index.js 2const onSocketMessage = { 3 // 更新 current Hash 4 hash(hash) { 5 status.currentHash = hash; 6 }, 7 'progress-update': function progressUpdate(data) { 8 if (options.useProgress) { 9 log.info(`${data.percent}% - ${data.msg}.`); 10 } 11 12 sendMessage('Progress', data); 13 }, 14 ok() { 15 sendMessage('Ok'); 16 17 if (options.useWarningOverlay || options.useErrorOverlay) { 18 overlay.clear(); 19 } 20 21 if (options.initial) { 22 return (options.initial = false); 23 } 24 // 进行更新检查等操作 25 reloadApp(options, status); 26 } 27 28};
接下来我们看看 client-src/default/utils/reloadApp.js 中的 reloadApp。这里又利用 node.js 的 EventEmitter,发出webpackHotUpdate 消息。这里又将更新的事情给回了 webpack(为了更好的维护代码,以及职责划分的更明确。)
1function reloadApp( { hotReload, hot, liveReload }, 2 { isUnloading, currentHash }) { 3 // ... 4 if (hot) { 5 log.info('App hot update...'); 6 // hotEmitter 其实就是 EventEmitter 的实例 7 const hotEmitter = require('webpack/hot/emitter'); 8 // 又利用 node.js 的 EventEmitter,发出 webpackHotUpdate 消息。 9 // websocket 仅仅用于客户端(浏览器)和服务端进行通信。而真正做事情的活还是交回给了 webpack。 10 hotEmitter.emit('webpackHotUpdate', currentHash); 11 if (typeof self !== 'undefined' && self.window) { 12 // broadcast update to window 13 self.postMessage(`webpackHotUpdate${currentHash}`, '*'); 14 } 15 } 16 // ... 17} 18 19module.exports = reloadApp;
在 webpack 的 hot/dev-server.js 中,监听 webpackHotUpdate 事件,并执行 check 方法。并在 check 方法中调用 module.hot.check 方法进行热更新。
1// hot/dev-server.js 2// 监听webpackHotUpdate事件 3hotEmitter.on("webpackHotUpdate", function (currentHash) { 4 lastHash = currentHash; 5 if (!upToDate() && module.hot.status() === "idle") { 6 log("info", "[HMR] Checking for updates on the server..."); 7 check(); 8 } 9});
1var check = function check() { 2 // moudle.hot.check 开始热更新 3 // 之后的源码都是HotModuleReplacementPlugin塞入到bundle.js中的哦,我就不写文件路径了 4 module.hot 5 .check(true) 6 .then(function (updatedModules) { 7 // ... 8 }) 9 .catch(function (err) { 10 // ... 11 }); 12};
至于 module.hot.check ,实际上通过 HotModuleReplacementPlugin 已经注入到我们 chunk 中了(也就是我们上面所说的 HMR Runtime),所以后面就是它是如何更新 bundle.js 的呢?
HMR Runtime 中更新 bundle.js
如果我们仔细看我们的打包后的文件的话,开启热更新之后生成的代码会比不开启多出很多东西(为了更加直观看到,可以将其输出到本地),这些就是帮助 webpack 在浏览器端去更新 bundle.js 的 HMR Runtime 代码
来看打包后的代码中新增了一个 createModuleHotObject
module.hot = createModuleHotObject(options.id, module);
实际上这个函数就是用来返回一个 hot 对象,所以调用 module.hot.check 的时候,实际上就是执行 hotCheck 函数
1function createModuleHotObject(moduleId, me) { 2 var hot = { 3 // Module API 4 addDisposeHandler: function (callback) { 5 hot._disposeHandlers.push(callback); 6 }, 7 removeDisposeHandler: function (callback) { 8 var idx = hot._disposeHandlers.indexOf(callback); 9 if (idx >= 0) hot._disposeHandlers.splice(idx, 1); 10 }, 11 // Management API 12 check: hotCheck, 13 apply: hotApply, 14 status: function (l) { 15 if (!l) return currentStatus; 16 registeredStatusHandlers.push(l); 17 }, 18 addStatusHandler: function (l) { 19 registeredStatusHandlers.push(l); 20 }, 21 removeStatusHandler: function (l) { 22 var idx = registeredStatusHandlers.indexOf(l); 23 if (idx >= 0) registeredStatusHandlers.splice(idx, 1); 24 }, 25 }; 26 currentChildModule = undefined; 27 return hot; 28}
其中就有 hotCheck 中调用了 __webpack_require__.hmrM
1function hotCheck(applyOnUpdate) { 2 setStatus("check"); 3 return __webpack_require__.hmrM().then(function (update) { 4 } 5}
__webpack_require__.hmrM——加载.hot-update.json
来看 __webpack_require__.hmrM, 其中 __webpack_require__.p 指的是我们本地服务的域名,类似 http://0.0.0.0:9528 , 另外 __webpack_require__.hmrF 去获取 .hot-update.json 文件的地址,就是我们之前提到的重要文件之一
1__webpack_require__.hmrM = () => { 2 if (typeof fetch === "undefined") throw new Error("No browser support: need fetch API"); 3 return fetch(__webpack_require__.p + __webpack_require__.hmrF()).then((response) => { 4 if(response.status === 404) return; // no update available 5 if(!response.ok) throw new Error("Failed to fetch update manifest " + response.statusText); 6 return response.json(); 7 }); 8};
1/* webpack/runtime/get update manifest filename */ 2(() => { 3 __webpack_require__.hmrF = () => ("main." + __webpack_require__.h() + ".hot-update.json"); 4})();
加载要更新的模块
下面来看如何加载我们要更新的模块的,可以看到打包出来的代码中有 loadUpdateChunk
1function loadUpdateChunk(chunkId) { 2 return new Promise((resolve, reject) => { 3 var url = __webpack_require__.p + __webpack_require__.hu(chunkId); 4 // create error before stack unwound to get useful stacktrace later 5 var error = new Error(); 6 var loadingEnded = (event) => { 7 // ...加载后的处理 8 }; 9 __webpack_require__.l(url, loadingEnded); 10 }); 11}
再来看 __webpack_require__.l,主要通过类似 JSONP 的方式进行,因为JSONP获取的代码可以直接执行。
1__webpack_require__.l = (url, done, key, chunkId) => { 2 // ... 3 if (!script) { 4 script = document.createElement("script"); 5 6 script.charset = "utf-8"; 7 script.timeout = 120; 8 if (__webpack_require__.nc) { 9 script.setAttribute("nonce", __webpack_require__.nc); 10 } 11 script.setAttribute("data-webpack", dataWebpackPrefix + key); 12 script.src = url; 13 } 14 // ... 15 needAttach && document.head.appendChild(script); 16};
还记得我们一开始提到的返回的 JS 中就是一个 webpackHotUpdate 函数么?实际上在我们的 HMR Runtime 中就是全局定义了(下面的名称是 webpackHotUpdatelearn_hot_reload,应该是 webpack 版本不一样导致的,不影响理解)至于生成的代码是如何生效的,请移步我的另外一篇文章——【Webpack 进阶】Webpack 打包后的代码是怎样的?
1// webpackHotUpdate + 项目名 2self["webpackHotUpdatelearn_hot_reload"] = (chunkId, moreModules, runtime) => { 3 for(var moduleId in moreModules) { 4 if(__webpack_require__.o(moreModules, moduleId)) { 5 currentUpdate[moduleId] = moreModules[moduleId]; 6 if(currentUpdatedModulesList) currentUpdatedModulesList.push(moduleId); 7 } 8 } 9 if(runtime) currentUpdateRuntime.push(runtime); 10 if(waitingUpdateResolves[chunkId]) { 11 waitingUpdateResolves[chunkId](); 12 waitingUpdateResolves[chunkId] = undefined; 13 } 14};
所以,客户端接受到服务器端推动的消息后,如果需要热更新,浏览器发起 http 请求去服务器端获取新的模块资源解析并局部刷新页面
以上整体的流程如下所示:

总结
本文介绍了 webpack 热更新的简单使用、相关的流程以及原理。小结一下,webpack 如果开启了热更新的时候
-
HMR Runtime通过HotModuleReplacementPlugin已经注入到我们chunk中了 -
除了开启一个
Bundle Server,还开启了HMR Server,主要用来和HMR Runtime中通信 -
在编译结束的时候,通过
compiler.hooks.done,监听并通知客户端 -
客户端接收到之后,就会调用
module.hot.check等,发起 http 请求去服务器端获取新的模块资源解析并局部刷新页面

