什么是热更新
模块热替换(hot module replacement 或 HMR
)是 webpack
提供的最有用的功能之一。它允许在运行时更新所有类型的模块,而无需完全刷新
一般的刷新我们分两种:
- 一种是页面刷新,不保留页面状态,就是简单粗暴,直接
window.location.reload()
。 - 另一种是基于
WDS
(Webpack-dev-server
) 的模块热替换,只需要局部刷新页面上发生变化的模块,同时可以保留当前的页面状态,比如复选框的选中状态、输入框的输入等。
可以看到相比于第一种,热更新对于我们的开发体验以及开发效率都具有重大的意义。
HMR
作为一个 Webpack
内置的功能,可以通过 HotModuleReplacementPlugin
或 --hot
开启。
具体我们如何在 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
对应的是哪个模块(我们称它为模块标识),其他的就是要更新的模块的内容了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 教程,下面是一个 简单的例子// 执行上面语句之后,客户端就会与服务器进行连接。 var ws = new WebSocket("wss://echo.websocket.org");// 实例对象的 onopen 属性,用于指定连接成功后的回调函数 ws.onopen = function(evt) { console.log("Connection open ..."); ws.send("Hello WebSockets!"); };// 实例对象的 onmessage 属性,用于指定收到服务器数据后的回调函数。可以接受二进制数据,blob 对象或者 Arraybuffer 对象 ws.onmessage = function(evt) {console.log( "Received Message: " + evt.data);ws.close(); };// 实例对象的 onclose 属性,用于指定连接关闭后的回调函数。 ws.onclose = function(evt) {console.log("Connection closed."); };
上面通过
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
阶段
- 文件经过
-
-
深入——源码阅读
我们还看回上图,其中启动阶段图中的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
接受到变更之后,如何生效的
-
启动
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,让浏览器可以请求本地的静态资源。this.app = new express(); }
启动服务结束之后就通过
createSocketServer
创建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
创建的compiler
实例(监听本地文件的变化、文件改变自动编译、编译输出),可以往compiler.hooks.done
钩子(代表webpack
编译完之后触发)注册事件, 当监听到一次webpack
编译结束,就会调用sendStats
方法。看
lib/Server.js
中的setupHooks
方法// lib/Server.js // 绑定监听事件 setupHooks() {// ...const addHooks = (compiler) => {// 监听 webpack 的 done 钩子,tapable 提供的监听方法// 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) => {// 当监听到一次webpack编译结束,就会调用 sendStats 方法this.sendStats(this.sockets, this.getStats(stats));this.stats = stats;});}; }
当监听到一次
webpack
编译结束,就会调用sendStats
方法,里面会向客户端发送hash
和ok
事件// lib/Server.js // send stats to a socket or multiple sockets sendStats(sockets, stats, force) {// ok和 hashthis.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 Hashhash(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
,发出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,发出 webpackHotUpdate 消息。// websocket 仅仅用于客户端(浏览器)和服务端进行通信。而真正做事情的活还是交回给了 webpack。hotEmitter.emit('webpackHotUpdate', currentHash);if (typeof self !== 'undefined' && self.window) {// broadcast update to windowself.postMessage(`webpackHotUpdate${currentHash}`, '*');}}// ... }module.exports = reloadApp;
在
webpack
的hot/dev-server.js
中,监听webpackHotUpdate
事件,并执行check
方法。并在check
方法中调用module.hot.check
方法进行热更新。// hot/dev-server.js // 监听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
已经注入到我们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
函数function createModuleHotObject(moduleId, me) {var hot = {// Module APIaddDisposeHandler: function (callback) {hot._disposeHandlers.push(callback);},removeDisposeHandler: function (callback) {var idx = hot._disposeHandlers.indexOf(callback);if (idx >= 0) hot._disposeHandlers.splice(idx, 1);},// Management APIcheck: 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 availableif(!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 latervar error = new Error();var loadingEnded = (event) => {// ...加载后的处理};__webpack_require__.l(url, loadingEnded);}); }
再来看
__webpack_require__.l
,主要通过类似JSONP
的方式进行,因为JSONP
获取的代码可以直接执行。__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
函数么?实际上在我们的HMR Runtime
中就是全局定义了(下面的名称是webpackHotUpdatelearn_hot_reload
,应该是webpack
版本不一样导致的,不影响理解)// 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]();waitingUpdateResolves[chunkId] = undefined;} };
所以,
客户端接受到服务器端推动的消息后,如果需要热更新,浏览器发起 http 请求去服务器端获取新的模块资源解析并局部刷新页面
-
-
总结
本文介绍了webpack
热更新的简单使用、相关的流程以及原理。小结一下,webpack
如果开启了热更新的时候-
HMR Runtime
通过HotModuleReplacementPlugin
已经注入到我们chunk
中了 -
除了开启一个
Bundle Server
,还开启了HMR Server
,主要用来和HMR Runtime
中通信 -
在编译结束的时候,通过
compiler.hooks.done
,监听并通知客户端 -
客户端接收到之后,就会调用
module.hot.check
等,发起http
请求去服务器端获取新的模块资源解析并局部刷新页面
-