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