HMR 全称 hot module replacement,可以翻译为「模块热更新」,最初由 Webpack 设计实现,至今已几乎成为现代工程化必备工具之一,它能够在保持页面状态不变的情况下动态替换、删除、添加代码模块,而无需重新加载整个页面。
主要是通过以下几种方式,来显著加快开发速度:
-
保留在完全重新加载页面期间丢失的应用程序状态。
-
只更新变更内容,以节省宝贵的开发时间。
-
在源代码中 CSS/JS 产生修改时,会立刻在浏览器中进行更新,这几乎相当于在浏览器 devtools 直接更改样式。
在 HMR 之前 ,即使应用只是单个代码文件发生变更,都需要刷新整个页面,才能将最新代码映射到浏览器上,这会丢失之前在页面执行过的所有交互与状态,整体开发效率偏低 。而引入 HMR 后,虽然无法覆盖所有场景,但大多数小改动都可以通过模块热替换方式更新到页面上,从而确保连续、顺畅的开发调试体验,极大提升开发效率。
使用 HMR
Webpack 生态下,只需要经过简单的配置,即可启动 HMR 功能,大致分两步:
- 设置 devServer.hot 属性为 true:
js
module.exports = {
// ...
devServer: {
// 必须设置 devServer.hot = true,启动 HMR 功能
hot: true
}
};
- 在代码调用 module.hot.accept 接口,声明如何将模块安全地替换为最新代码
js
import component from "./component";
const currentComponent = component();
document.body.appendChild(currentComponent);
// HMR interface
if (module.hot) {
// Capture hot update
module.hot.accept("./component", () => {
const nextComponent = component();
// Replace old content with the hot loaded one
document.body.replaceChild(nextComponent, currentComponent);
currentComponent = nextComponent;
});
}
原理
- 使用 webpack-dev-server 托管静态资源,同时以 Runtime 方式注入一段处理 HMR 逻辑的客户端代码;
- 浏览器加载页面后,与 webpack-dev-server 建立 WebSocket 连接;
- Webpack 监听到文件变化后,增量构建发生变更的模块,并通过 WebSocket 发送 hash 事件;
- 浏览器接收到 hash 事件后,请求 manifest 资源文件,确认增量变更范围;
- 浏览器加载发生变更的增量模块;
- Webpack 运行时触发变更模块的 module.hot.accept 回调,执行代码变更逻辑;
当 HMR 失败后,回退到 live reload 操作,也就是进行浏览器刷新来获取最新打包代码。这就是 Webpack HMR 特性的执行过程
当我们在配置文件中配置了devServer.watchContentBase 为 true 的时候,Server 会监听这些配置文件夹中静态文件的变化,变化后会通知浏览器端对应用进行 live reload。
注意,这儿是浏览器刷新,和 HMR 是两个概念。
启动服务
js
class Server {
async initialize() {
// ...
this.setupApp();
this.createServer();
// ...
}
setupApp() {
this.app = new (getExpress())();
}
createServer() {
const { type, options } = (
this.options.server
);
// 启动静态资源服务
this.server = require((type)).createServer(
options,
this.app, // express
);
// 每次连接时将新的 socket 添加到 this.sockets 数组中
(this.server).on(
"connection",
(socket) => {
this.sockets.push(socket);
socket.once("close", () => {
this.sockets.splice(this.sockets.indexOf(socket), 1);
});
},
);
}
async start () {
// 这里对 options.webSocketServer 赋值,默认为 { type: 'ws', options: { path: 'ws' } }
await this.normalizeOptions();
// ...
await initialize()
// 创建 socket
if (this.options.webSocketServer) {
this.createWebSocketServer();
}
}
}
⭐ 使用 express 框架启动本地 server,让浏览器可以请求本地的静态资源
⭐ 本地server启动之后,再去启动websocket服务,通过websocket,可以建立本地服务和浏览器的双向通信。
增加 Entry 文件
在启动服务时候,webpack-dev-server会在webpack配置项的entry中注入两个文件:
js
addAdditionalEntries(compiler) {
const additionalEntries = [];
const isWebTarget = Server.isWebTarget(compiler);
if (this.options.client && isWebTarget) {
// ...
// 获取 webSocket 客户端代码
additionalEntries.push(
`${require.resolve("../client/index.js")}?${webSocketURLStr}`,
);
}
// 根据配置获取热更新代码
if (this.options.hot === "only") {
additionalEntries.push(require.resolve("webpack/hot/only-dev-server"));
} else if (this.options.hot) {
additionalEntries.push(require.resolve("webpack/hot/dev-server"));
}
const webpack = compiler.webpack || require("webpack");
for (const additionalEntry of additionalEntries) {
new webpack.EntryPlugin(compiler.context, additionalEntry, {
name: undefined,
}).apply(compiler);
}
}
修改后的 webpack 入口配置如下:
js
// 修改后的 entry 入口
{
entry: {
index: [
// 上面获取的clentEntry
'/node_modules/webpack-dev-server/client/index.js?http://localhost:8080',
// 上面获取的hotEntry
'/node_modules/webpack/hot/dev-server.js',
// 开发配置的入口
'./src/index.js'
]
}
}
在入口默默增加了2个文件,那就意味会一同打包到 bundle 文件中去,也就是线上运行时。
⭐️ webpack-dev-server/client/index.js
在客户端中加入 websocket 客户端通信代码,以便后续的更新替换操作
⭐️ webpack/hot/dev-server.js
负责与 webpack-dev-server 进行通信,接收关于模块更新的通知,并执行相应的模块替换操作
监听 webpack 编译结束
修改好入口配置后,又调用了setupHooks 方法,用来注册监听事件的,监听每次 webpack 编译完成
js
setupHooks() {
this.compiler.hooks.done.tap(
"webpack-dev-server",
(stats) => {
if (this.webSocketServer) {
this.sendStats(this.webSocketServer.clients, this.getStats(stats));
}
this.stats = stats;
},
);
}
当监听到一次 webpack 编译结束,就会调用 sendStats 方法通过 websocket 给浏览器发送通知,ok 和 hash 事件,这样浏览器就可以拿到最新的 hash 值了,做检查更新逻辑。
js
sendStats(clients, stats, force) {
// ...
this.currentHash = stats.hash;
// 通过 websocket 给客户端发消息
this.sendMessage(clients, "hash", stats.hash);
if (
(stats.errors).length > 0 ||
(stats.warnings).length > 0
) {
// ...
} else {
// 通过 websocket 给客户端发消息
this.sendMessage(clients, "ok");
}
}
监听文件变化
在了解如何监听文件变化前,我们需要了解到 dev-server 和 dev-middleware 的区别
-
webpack-dev-server:只负责启动服务和前置准备工作
-
webpack-dev-middleware:所有文件相关的操作都抽离至此,主要是本地文件的编译和输出以及监听
js
const context = {
// ...
options,
compiler,
watching: undefined,
outputFileSystem: undefined,
};
// ...
let watchOptions;
const errorHandler = (error) => {
if (error) {
context.logger.error(error);
}
};
if (
Array.isArray((context.compiler).compilers)
) {
watchOptions =
(context.compiler).compilers.map(
(childCompiler) => childCompiler.options.watchOptions || {},
);
context.watching =
(
context.compiler.watch(
(watchOptions),
errorHandler,
)
);
} else {
watchOptions = (context.compiler).options.watchOptions || {};
context.watching = (
context.compiler.watch(watchOptions, errorHandler)
);
}
我们可以看出 webpack-dev-middleware 是借用 webpack 的 watch 钩子开启对本地文件的监听,当文件发生变化,重新编译,编译完成之后继续监听
outputFileSystem
每次文件改变都要重新编译,性能不应该很差吗,为什么开发时没有体会到?
为了提升性能,webpack-dev-middleware 使用了一个库叫 memfs,是 Webpack 官方写的。这样每次打包之后的结果并不会进行输出(把文件写入到硬盘上会耗费很长的时间),而是将打包后的文件保留在内存中,以此来提升性能。
js
function setupOutputFileSystem(context) {
let outputFileSystem;
// 默认为 undefined
if (context.options.outputFileSystem) {
// ...
}
// 这里需要手动设置为 true,若不设置则为 undefined
else if (context.options.writeToDisk !== true) {
outputFileSystem = memfs.createFsFromVolume(new memfs.Volume());
} else {
// ...
}
// ...
context.outputFileSystem = outputFileSystem;
}
我们可以看出,如果不设置 writeToDisk 为 true,则会改写 webpack outputFileSystem 为 memfs,输出到内存中。
浏览器接收到热更新的通知
我们上文已经提到,webpack-dev-server 会对 entry 进行加工,对客户端加入 ws 的代码,并会被打包到 bundle 中
js
var socket = require('./socket');
const onSocketMessage = {
hash(hash) {
status.previousHash = status.currentHash;
status.currentHash = hash;
},
ok() {
sendMessage("Ok");
if (options.overlay) {
overlay.send({ type: "DISMISS" });
}
// 进行更新检查等操作
reloadApp(options, status);
},
}
function reloadApp({ hot, liveReload }, status) {
const search = self.location.search.toLowerCase();
const allowToHot = search.indexOf("webpack-dev-server-hot=false") === -1;
if (hot && allowToHot) {
log.info("App hot update...");
// hotEmitter 其实就是EventEmittter 的实例
hotEmitter.emit("webpackHotUpdate", status.currentHash);
if (typeof self !== "undefined" && self.window) {
// broadcast update to window
self.postMessage(`webpackHotUpdate${status.currentHash}`, "*");
}
}
}
从代码中,我们可以看出
-
hash事件:更新最新 status 的 hash 值
-
ok事件:调用 webpackHotUpdate 事件
这里就要提到 entry 新增打包的 webpack/hot/dev-server
js
if (module.hot) {
var check = function check() {
module.hot
.check(true)
.then(function (updatedModules) {})
.catch(function (err) {});
};
var hotEmitter = require("./emitter");
hotEmitter.on("webpackHotUpdate", function (currentHash) {
lastHash = currentHash;
if (!upToDate() && module.hot.status() === "idle") {
check();
}
});
}
这里调用了 module.hot 方法,那么这个方法是从哪里来的?这里我就不卖关子了,这里调用了 HotModuleReplacementPlugin 插件进行改写
热更新插件
在上面的配置中并没有配置 HotModuleReplacementPlugin,原因在于当我们设置 devServer.hot 为 true 后,devServer 会告诉 webpack 自动引入 HotModuleReplacementPlugin
- HMR Server 是服务端,用来将变化的 JS 模块通过 WebSocket 的消息通知给浏览器端。
- HMR Runtime 是浏览器端,用于接受 HMR Server 传递的模块数据,浏览器端可以看到 .hot-update.JSON 的文件过来。
这里的 hot 就是 HotModuleReplacementRuntime 中定义的
js
$hmrDownloadUpdateHandlers$ = {}
function createModuleHotObject(moduleId, me) {
var _main = currentChildModule !== moduleId;
var hot = {
// ...
// Management API
check: hotCheck,
apply: hotApply,
// ...
};
currentChildModule = undefined;
return hot;
}
}
function hotCheck(applyOnUpdate) {
return setStatus("check")
.then($hmrDownloadManifest$) // undefined
.then(function (update) {
return setStatus("prepare").then(function () {
var updatedModules = [];
currentUpdateApplyHandlers = [];
return Promise.all(
Object.keys($hmrDownloadUpdateHandlers$).reduce(function (
promises,
key
) {
$hmrDownloadUpdateHandlers$[key](
update.c,
update.r,
update.m,
promises,
currentUpdateApplyHandlers,
updatedModules
);
return promises;
}, [])
).then(function () {
return waitForBlockingPromises(function () {
if (applyOnUpdate) {
// 触发 internalApply 方法
return internalApply(applyOnUpdate);
} else {
return setStatus("ready").then(function () {
return updatedModules;
});
}
});
});
});
});
}
这里代码太多,不是很想看,但我的理解是通过
- HotModuleReplacementPlugin 在 webapck hooks上做了一些事情,例如 compilation.hooks.processAssets 钩子调用emitAsset()先后产生了新模块的js文件和menifest文件
- HMR runtime 则是注入到 bundle.js,如 module.hot.check 等方法
当文件发生改变时候,触发 webpack 增量构建,构建结束后触发 this.sendStats 方法通知提交服务器,服务器再通过 socket 告诉浏览器,浏览器再去下载更新文件,进行替换。
这里热更新主要是这两个方法 check 和 apply
- check : hotCheck 发送一个 fetch 请求来更新 Manifest 文件(xxx.hot-update.json) 会与当前的 loaded chunk 列表进行比较。然后会下载并执行 xxx.hot-update.js 文件。当所有更新 chunk 完成下载,runtime 就会切换到 ready 状态
- apply :将所有 updated module 标记为无效。对于每个无效 module,都需要在模块中有一个 update handler,或者在此模块的父级模块中有 update handler。否则,会进行无效标记冒泡,并且父级也会被标记为无效。继续每个冒泡,直到到达应用程序入口起点,或者到达带有 update handler 的 module(以最先到达为准,冒泡停止)。如果它从入口起点开始冒泡,则此过程失败。
热更新
加载热更新资源的 RuntimeGlobals.hmrDownloadManifest 与RuntimeGlobals.hmrDownloadUpdateHandlers
js
function hotCheck(applyOnUpdate) {
return setStatus("check")
.then($hmrDownloadManifest$)
.then(function (update) {
if (!update) {
return setStatus(applyInvalidatedModules() ? "ready" : "idle").then(
function () {
return null;
}
);
}
return setStatus("prepare").then(function () {
var updatedModules = [];
currentUpdateApplyHandlers = [];
return Promise.all(
Object.keys($hmrDownloadUpdateHandlers$).reduce(function (
promises,
key
) {
$hmrDownloadUpdateHandlers$[key](
update.c,
update.r,
update.m,
promises,
currentUpdateApplyHandlers,
updatedModules
);
return promises;
}, [])
).then(function () {
return waitForBlockingPromises(function () {
if (applyOnUpdate) {
return internalApply(applyOnUpdate);
} else {
return setStatus("ready").then(function () {
return updatedModules;
});
}
});
});
});
});
}
HotModuleReplacementPlugin 插件借助 Webpack 的 watch 能力,在代码文件发生变化后执行增量构建,生成:
- manifest 文件:JSON 格式文件,包含所有发生变更的模块列表,命名为 [hash].hot-update.json
- 模块变更文件:js 格式,包含编译后的模块代码,命名为 [hash].hot-update.js
增量构建完毕后,Webpack 将触发 compilation.hooks.done 钩子,并传递本次构建的统计信息对象 stats。WDS 则监听 done 钩子,在回调中通过 WebSocket 发送模块更新消息:
js
{ "type" : "hash", "data": "${stats.hash}" }
再次加载更新:客户端通过 WebSocket 接收到 hash 消息后,首先发出 manifest 请求获取本轮热更新涉及的 chunk。manifest 请求完成后,客户端 HMR 运行时开始下载发生变化的 chunk 文件,将最新模块代码加载到本地
热更新模块替换
模块热替换主要分三个阶段:
-
找出 outdatedModules 和 outdatedDependencies,并从缓存中删除过期的模块和依赖
-
添加新的模块到 modules 中
-
当下次调用 webpack_require(Webpack 重写的 require 方法)方法的时候,就是获取到了新的模块代码了
这里就不贴找出过期模块 outdatedModules 和过期依赖 outdatedDependencies 的代码了,直接看删除和添加的代码
-
当模块被删除时,调用dispose方法消除副作用;
-
当模块更新时,冒泡寻求accept方法执行模块更新逻辑并重新加载模块;
从缓存中删除过期的模块和依赖
js
return {
dispose: function () {
currentUpdateRemovedChunks.forEach(function (chunkId) {
delete $installedChunks$[chunkId];
});
currentUpdateRemovedChunks = undefined;
var queue = outdatedModules.slice();
while (queue.length > 0) {
var moduleId = queue.pop();
var module = $moduleCache$[moduleId];
if (!module) continue;
var data = {};
// Call dispose handlers
var disposeHandlers = module.hot._disposeHandlers;
for (j = 0; j < disposeHandlers.length; j++) {
disposeHandlers[j].call(null, data);
}
$hmrModuleData$[moduleId] = data;
module.hot.active = false;
// 从缓存中删除过期的模块
delete $moduleCache$[moduleId];
// 删除过期的依赖
delete outdatedDependencies[moduleId];
// remove "parents" references from all children
for (j = 0; j < module.children.length; j++) {
var child = $moduleCache$[module.children[j]];
if (!child) continue;
idx = child.parents.indexOf(moduleId);
if (idx >= 0) {
child.parents.splice(idx, 1);
}
}
}
// 更新子模块的依赖关系,将需要被移除的模块从依赖的子模块的 children 数组中移除
var dependency;
for (var outdatedModuleId in outdatedDependencies) {
if ($hasOwnProperty$(outdatedDependencies, outdatedModuleId)) {
module = $moduleCache$[outdatedModuleId];
if (module) {
moduleOutdatedDependencies =
outdatedDependencies[outdatedModuleId];
for (j = 0; j < moduleOutdatedDependencies.length; j++) {
dependency = moduleOutdatedDependencies[j];
idx = module.children.indexOf(dependency);
if (idx >= 0) module.children.splice(idx, 1);
}
}
}
}
}
}
添加新增模块
将新的模块添加到 <math xmlns="http://www.w3.org/1998/Math/MathML"> m o d u l e F a c t o r i e s moduleFactories </math>moduleFactories 中。
js
// insert new code
for (var updateModuleId in appliedUpdate) {
if ($hasOwnProperty$(appliedUpdate, updateModuleId)) {
$moduleFactories$[updateModuleId] = appliedUpdate[updateModuleId];
}
}
// Load self accepted modules
for (var o = 0; o < outdatedSelfAcceptedModules.length; o++) {
var item = outdatedSelfAcceptedModules[o];
var moduleId = item.module;
try {
// 执行最新的代码
item.require(moduleId);
} catch (err) {
// 错误处理
}
}
经过上述步骤,浏览器加载完最新模块代码后,HMR 运行时会继续触发 module.hot.accept 回调,将最新代码替换到运行环境中。
module.hot.accept 是 HMR 运行时暴露给用户代码的重要接口之一,它在 Webpack HMR 体系中开了一个口子,让用户能够自定义模块热替换的逻辑,接口签名:
js
/*
* path:指定需要拦截变更行为的模块路径;
* callback:模块更新后,将最新模块代码应用到运行环境的函数。
**/
module.hot.accept(path?: string, callback?: function);
例如,对于如下代码:
js
import component from "./component";
const currentComponent = component();
document.body.appendChild(currentComponent);
// HMR interface
if (module.hot) {
// Capture hot update
module.hot.accept("./component", () => {
const nextComponent = component();
// Replace old content with the hot loaded one
document.body.replaceChild(nextComponent, currentComponent);
currentComponent = nextComponent;
});
}
示例中,module.hot.accept 函数监听 ./component 模块的变更事件,一旦代码发生变动,就触发回调,将 ./component 导出的值替换到页面上,从而实现热更新效果。
更新失败后的兜底
模块热更新的错误处理,如果在热更新过程中出现错误,热更新将回退到刷新浏览器,这部分代码在 dev-server 代码中
js
if (module.hot) {
var check = function check() {
module.hot
.check(true)
.then(function (updatedModules) {
if (!updatedModules) {
if (typeof window !== "undefined") {
window.location.reload();
}
return;
}
// ...
if (upToDate()) {
log("info", "[HMR] App is up to date.");
}
})
.catch(function (err) {
var status = module.hot.status();
if (["abort", "fail"].indexOf(status) >= 0) {1
if (typeof window !== "undefined") {
window.location.reload();
}
} else {
log("warning", "[HMR] Update failed: " + log.formatError(err));
}
});
};
var hotEmitter = require("./emitter");
hotEmitter.on("webpackHotUpdate", function (currentHash) {
lastHash = currentHash;
if (!upToDate() && module.hot.status() === "idle") {
check();
}
});
}
dev-server 先验证是否有更新,没有代码更新的话,重载浏览器。如果在 hotApply 的过程中出现 abort 或者 fail 错误,也进行重载浏览器。