何为 HMR?
模块热替换(HMR - Hot Module Replacement)功能会在应用程序运行过程中替换、添加或删除模块,而无需重新加载整个页面
当我们对代码进行修改并保存后,webpack 将对代码重新打包,并将改动的模块发送到浏览器,浏览器通过新的模块替换老的模块,从而实现局部更新且不需要刷新页面
为何需要 HMR?
在 HMR 出现之后,程序的加载都是页面级别的,即使是单个文件的发生改变,都需要刷新整个页面才能够获得最新的代码,且在此之前的数据都会丢失。
当我们遇到如下情况的时候
- 分步表单,意味着一次更改我们需要填写很多的数据
- 弹窗信息,意味着必须重新执行弹窗交互
再细小的操作,更新样式文件、备注信息等等操作都需要刷新页面重新加载执行,极大的影响了开发效率。引入 HMR 能够将这些细小的更改通过模块热替换的方式更新到页面上,从而提升开发的效率。
如何使用 HMR?
在 webpack 的配置中,针对于 devServer 配置hot:true
1
2module.exports = {
3
4 devServer: {
5
6 hot: true
7 }
8};
在代码里面需要配置module.hot.accept
接口,声明如何将模块安全地替换为最新代码
1if (module.hot) {
2 module.hot.accept(["./hello.js"], () => {
3 render();
4 });
5}
6
7
webpack 编译构建流程
1
2- hello.js
3- index.js
4- package.json
5- webpack.config.js
6
7const config = {
8 entry: "./index.js",
9 output: {
10 filename: "bundle.js",
11 path: path.resolve("dist"),
12 },
13 plugins: [new HtmlWebpackPlugin()],
14 mode: "development",
15 stats: {
16 modules: false,
17 hash: true,
18 },
19};
做好相关的配置之后,我们启动项目后,能够通过控制台发现生成了一个 hash 值,且通过浏览器打开网站之后,能够发现 websocket 中也传递了{type: "hash", data: "d76e2c3053202b29bf20"}
对应的 hash 值
我们更新文件,触发新的编译,控制台中也会更新对应的数据
能够发现生成了新的hash
值,且生成了[hash].hot-update.json
/[hash].hot-update.js
新的文件,文件上的hash
值是上一次生成的hash
值。
根据新生成文件名可以发现,上次输出的hash
值会作为本次编译新生成的文件标识。依次类推,本次输出的hash
值会被作为下次热更新的标识。
通过浏览器可以看到一次更新之后,会请求对应的[hash].hot-update.json
/[hash].hot-update.js
文件
c
: 描述哪些 chunk 包含在此次更新中r
: 指示是否需要重新加载 Webpack runtime 代码m
: 列出本次更新中被修改的模块及其对应的新代码
热更新实现的原理
webpack-dev-server 启动本地服务
上述的 webpack 配置代码,我们通过webpack-dev-server
启动代码
1
2{
3 "scripts": {
4 "dev": "webpack-dev-server",
5 "build": "webpack"
6 },
7}
所有的命令行可以在对应项目的package.json
的bin
命令中找到对应的入口文件
1{
2 "name": "webpack-dev-server",
3 "bin": "bin/webpack-dev-server.js",
4}
执行pnpm dev 之后大致的流程(简易版本)
1setupApp() {
2
3 this.app = new (memoize(() => require("express")))();
4}
5
6createServer() {
7 this.server = require((type)).createServer(options, this.app);
8}
9
10createWebSocketServer() {
11
12 this.webSocketServer = new (require("./servers/WebsocketServer"))(this);
13}
在整个启动本地服务时,涉及到的仓库很多,重点都在new Server()
之后的操作
- 在
new Server
之前会先启动webpack
,生成compiler
实例。compiler
上有很多方法,比如可以启动webpack
所有编译工作,以及监听本地文件的变化 - 使用
express
启动本地服务,使得浏览器可以访问本地的静态资源 - 本地
server
启动成功之后再去创建websocket
服务,建立本地服务和浏览器的双向通信
修改 entry 配置
在我们启动本地服务之前,代码中修改了entry
入口,自动注入了websocket
客户端代码和热更新替换的代码
在进入start
阶段的时候会调用initialize
方法
client/index.js
为websocket
客户端的代码,因为websocket
是双向通信,上一步通过createServer
是创建的本地服务端的websocket
代码,还需要客户端代码,因此需要把客户端websocket
代码塞到代码中
hot/dev-server.js
主要用于检查更新逻辑
监听 webpack 编译结束
当修改完entry
入口之后,会执行setupHooks
方法,注册监听事件,监听webpack
编译完成
1setupHooks() {
2
3 this.compiler.hooks.done.tap(
4 "webpack-dev-server",
5 (stats) => {
6 if (this.webSocketServer) {
7 this.sendStats(this.webSocketServer.clients, this.getStats(stats));
8 }
9 this.stats = stats;
10 },
11 );
12}
13
14sendStats(clients, stats, force) {
15 this.currentHash = stats.hash;
16 this.sendMessage(clients, "hash", stats.hash);
17
18 if ((stats.errors).length > 0 ||(stats.warnings).length > 0) {
19 const hasErrors = (stats.errors).length > 0;
20
21 if ((stats.warnings).length > 0) {
22 let params;
23 if (hasErrors) {
24 params = { preventReloading: true };
25 }
26 this.sendMessage(clients, "warnings", stats.warnings, params);
27 }
28 if ((stats.errors).length > 0) {
29 this.sendMessage(clients, "errors", stats.errors);
30 }
31 } else {
32 this.sendMessage(clients, "ok");
33 }
34}
每当webpack
编译完成就会出发done
hook,从而调用sendStats
方法通过websocket
给浏览器发送消息,hash
/ok
事件,浏览器能够拿到最新的hash
值,检查更新逻辑
监听文件变化
每次文件发生变化之后,都需要触发文件编译,那么久还需要监听文件发生改变。该操作主要是通过webpack-dev-middleware
库实现的。
在start
函数中,会执行setupDevMiddleware
方法,该方法主要是执行webpack-dev-middleware
库的。
webpack-dev-middleware: 该库主要做文件相关的操作,本地文件输出以及监听 webpack-dev-server: 该库主要只负责启动服务和前置准备工作
在webpack-dev-middleware
中主要实现
1compiler.watch(watchOptions, errorHandler)
2
3compiler.outputFileSystem = memfs.createFsFromVolume(new memfs.Volume())
调用了compiler.watch
方法开启对文件的编译,文件变化的时候重新编译文件。
更改outputFileSystem
,使用memory-fs
将所有的output存储在内存中,减少对文件系统的操作
浏览器接收热更新的通知
在上面讲到每一次webpack
编译结束之后,都会通过done
hook 调用sendStats
方法通过websocket
传递相关的数据。
客户端中会被注入webpack-dev-server/client/index.js
代码,主要用于接收相关数据
1var onSocketMessage = {
2 hash: function hash(_hash) {
3 status.previousHash = status.currentHash;
4 status.currentHash = _hash;
5 },
6 ok: function ok() {
7 sendMessage("Ok");
8 if (options.overlay) {
9 overlay.send({
10 type: "DISMISS"
11 });
12 }
13 reloadApp(options, status);
14 }
15};
16
17
18socket(socketURL, onSocketMessage, options.reconnect);
19
20
21reloadApp(){
22 if (hot && allowToHot) {
23 log.info("App hot update...");
24 hotEmitter.emit("webpackHotUpdate", status.currentHash);
25 if (typeof self !== "undefined" && self.window) {
26 self.postMessage("webpackHotUpdate".concat(status.currentHash), "*");
27 }
28 }
29}
注入的客户端代码职责
socket
方法建立了websocket
和服务端的连接,并注册了一系列的监听事件hash
事件,更新最新一次打包后的hash
值ok
事件,进行热更新检查
ok
事件中执行reloadApp
方法,通过eventEmitter
发出webpackHotUpdate
事件,通知webpack
该干活了
那么,webpack肯定是需要监听webpackHotUpdate
事件的,没错,就在之前放入webpack/hot/dev-server.js
代码中
1var check = function check() {
2 module.hot
3 .check(true)
4 .then(function (updatedModules) {
5 if (!updatedModules) {
6 window.location.reload();
7 return;
8 }
9 if (upToDate()) {
10 log("info", "[HMR] App is up to date.");
11 }
12 })
13 .catch(function (err) {});
14};
15
16var hotEmitter = require("./emitter");
17
18hotEmitter.on("webpackHotUpdate", function (currentHash) {
19 lastHash = currentHash;
20 if (!upToDate() && module.hot.status() === "idle") {
21 log("info", "[HMR] Checking for updates on the server...");
22 check();
23 }
24});
能够看到hot/dev-server.js
监听了webpackHotUpdate
事件,并且会去执行module.hot.check
方法
HotModuleReplacementPlugin
我们能够浏览器的Sources
的bundle.js
中找到上述代码,创建对应hot
对象,里面就能够对应的check
方法。注入的代码可以在HotModuleReplacement.runtime.js找到
当我们配置hot
属性的时候webpack-dev-server
会自动转成HotModuleReplacementPlugin
1if (this.options.hot) {
2 const HMRPluginExists = compiler.options.plugins.find(
3 (p) => p && p.constructor === webpack.HotModuleReplacementPlugin,
4 );
5
6 if (HMRPluginExists) {
7 this.logger.warn(
8 `"hot: true" automatically applies HMR plugin, you don't have to add it manually to your webpack configuration.`,
9 );
10 } else {
11
12 const plugin = new webpack.HotModuleReplacementPlugin();
13
14 plugin.apply(compiler);
15 }
16}
HotModuleReplacementPlugin
会悄悄的加一些代码到产物中
module.hot.check
上述知道了 module.hot.check 的来源,现在看看该check
函数具体做了什么事情
-
调用
$hmrDownloadManifest$
获取当前的hash.hot-update.json
1 2__webpack_require__.hmrM = () => { 3 if (typeof fetch === "undefined") 4 throw new Error("No browser support: need fetch API"); 5 return fetch(__webpack_require__.p + __webpack_require__.hmrF()).then( (response) => { 6 7 if (response.status === 404) 8 return; 9 10 if (!response.ok) 11 throw new Error("Failed to fetch update manifest " + response.statusText); 12 13 return response.json(); 14 } 15 ); 16} 17__webpack_require__.hmrF = () => ("main." + __webpack_require__.h() + ".hot-update.json");
-
再调用
$hmrDownloadUpdateHandlers$["jsonp"]
请求js
文件1 2 3__webpack_require__.hu = (chunkId) => { 4 return "" + chunkId + "." + __webpack_require__.h() + ".hot-update.js"; 5} 6 7
apply
终于到了最后一步热更新的操作,所有的代码逻辑都在internalApply
中
-
删除过期的模块
1var queue = outdatedModules.slice(); 2while (queue.length > 0) { 3 moduleId = queue.pop(); 4 5 module = installedModules[moduleId]; 6 7 delete outdatedDependencies[moduleId]; 8} 9
-
将新的模块添加到更新列表,__webpack_require__执行相关模块的代码
1appliedUpdate[moduleId] = newModuleFactory; 2 3for (var updateModuleId in appliedUpdate) { 4 if (__webpack_require__.o(appliedUpdate, updateModuleId)) { 5 __webpack_require__.m[updateModuleId] = appliedUpdate[updateModuleId]; 6 } 7}
-
执行
hot._acceptedDependencies
的callback
总结
上述我们通过八个步骤大致讲解了HMR
的实现原理
- 通过
webpack-dev-server
创建本地服务,修改entry
入口,注入websocket
客户端代码和热更新替换代码hot-server
到bundle
中 - 在
webpack
创建的时候会通过HotModuleReplacementPlugin
向bundle
中注入热更新代码 - 通过
compiler.watch
开启文件监听,每一次编译完成触发compiler.hooks.done
;监听compiler.hooks.done
每次完成编译之后给客户端发送hash/ok
事件 webpack/client
接收到ok
事件之后,通过eventEmitter
佛那个送webpackHotUpdate
事件- 在
webpack/hot-server
中会监听webpackHotUpdate
事件,从而执行module.hot.check
(HotModuleReplacementPlugin
注入的代码)完成热更新操作
参考文章