HMR 热重载
约 4052 字大约 14 分钟
2025-06-17
概念
热重载(Hot Module Replacement) 是一种在开发过程中 实时更新代码修改并自动应用到运行中的应用程序 的技术,无需手动刷新页面,同时尽可能保留应用的当前状态(如组件状态、表单输入、路由位置等)。它显著提升了开发效率,尤其适用于现代前端框架(如 React、Vue、Angular)和模块化开发工具(如 Webpack、Vite)。
如何开启
Webpack 中开启 HMR 功能,需要在 Webpack 配置中进行相应的设置。
module.exports = {
// ... 其他配置
devServer: {
hot: true, // 开启 HMR 功能
},
};
核心流程简述
- 使用 DevServer 启动本地开发服务器,并托管静态资源
- 浏览器访问 DevServer,并建立 WebSocket 连接
- DevServer 监听文件变化,通知 Webpack 进行增量编译
- Webpack 会生成一个更新补丁和 Manifest 文件
- DevServer 发送
hash
消息和ok
消息通知浏览器更新 - 浏览器请求更新补丁和 Manifest 文件
- 浏览器的 HMR 客户端执行热更新
- 状态恢复,恢复之前的页面滚动位置、表单输入等
Webpack 的 HMR(Hot Module Replacement,热模块替换)基本流程可以分为以下几个关键阶段:
核心流程图
首先我们来看流程图,然后再去详细分析
文字版
- Webpack 在打包时,自动在每个 bundle 中添加 HMR 的 runtime 代码
- 使用 DevServer 托管静态资源
- 浏览器加载页面时,通过
socket.js
与 WDS 建立 WebSocket 连接 - DevServer 通过
chokidar
监听到文件变化后,通知 Webpack 进行增量编译。 - Webpack 根据文件名,找到对应的模块,并重新计算 hash 值,与保存在依赖图里的旧数据对比,确定是否要编译
- Webpack 进行增量编译,生成对应 manifest 文件和补丁文件
- Webpack 在构建过程中,通过钩子随时向 DevServer 返回当前编译进展。
- DevServer 根据钩子返回的编译进展,向浏览器推送
progress-update
消息。 - Webpack 在编译完成后,通过
done
钩子通知 DevServer 编译完成。 - DevServer 向浏览器推送
hash
消息和ok
消息。 - 浏览器接收到
ok
消息后,根据hash
消息携带的hash
值请求 manifest 资源文件,确认增量变更范围 - 根据增量变更范围来请求下载补丁文件
- 浏览器接收到补丁文件后,执行热更新逻辑
- 热更新完成后,Runtime 会进行状态恢复。
主要环节
以下仅作为对流程图上关键节点的补充说明:
1. 启用 HMR
在 Webpack 配置中启用 HotModuleReplacementPlugin
,并配置 devServer.hot: true
。
// webpack.config.js
plugins: [new webpack.HotModuleReplacementPlugin()],
devServer: { hot: true }
2. 在客户端注入 runtime
代码
Webpack 在打包时自动注入 runtime
代码,浏览器在打开资源时,自动运行 runtime
代码,并和 devServer 之间建立 WebSocket 连接。
runtime
代码主要包含两部分:
webpack-dev-server/client
:负责管理和 devServer 之间的 WebSocket 链接,请求 manifest 数据、补丁文件。webpack/hot/dev-server
:用于处理 HMR 运行时的模块补丁替换和状态管理。
3. 文件变更监听、触发重新编译
开发服务器 devServer 使用 chokidar
监听文件系统的变化。
当文件被修改时,devServer 能够通知 Webpack 发生变更的具体文件,Webpack 通过读取新的文件内容,计算对应的 Hash 值,来和内部 graph 中的旧数据进行对比,来实现增量编译。
编译过程中,Webpack 会执行一系列钩子,返回给 devServer 当前的编译进展,devServer 通过 WebSocket 向浏览器推送 progress-update
编译进度消息。
4. 生成编译补丁
Webpack 在完成增量编译后,会生成 Hash、Manifest、补丁文件。
hash
:本次编译的唯一标识,作为版本标识用于后续模块更新[hash].hot-update.json
:更新清单,记录哪些模块需要更新[hash].[module-id].hot-update.js
:包含更新后的模块代码
注意
在 webpack 5 以前
Manifest 信息
Manifest 信息指的就是 [hash].hot-update.json
文件的内容,包含了本次编译的 hash
、需要更新的 chunk
以及需要更新的模块。
{
"h": "a1b2c3d4", // 本次编译的 hash
"c": { "main": true }, // 需要更新的 chunk
"u": ["./src/Button.js"] // 需要更新的模块
}
{
"h": "a1b2c3d4", // 本次更新的全局 Hash 值
"c": ["main", "vendors"], // 需要更新的 Chunk ID 列表
"u": ["./src/moduleA.js", "./node_modules/lodash.js"], // 需要更新的模块 ID 列表(Webpack 4 保留字段)
"r": false // 是否需要回退到整页刷新(true 表示 HMR 失败)
}
{
//c:包含Chunk 的 ID 和名称。
"c": {
"0": "main", // 例如,"0": "main"意味着ID为0的入口点是main。
"1": "about"
},
// m:模块 ID 到文件路径的映射。
"m": {
"0": "modules/main.js", // 例如,"0": "modules/main.js"表示 ID 为 0 的模块对应于 modules/main.js。
"1": "modules/about.js"
},
// e:入口点ID到其包含的模块ID数组的映射。
"e": {
"0": [0], // 例如,"0": [0]表示ID为0的入口点仅包含ID为0的模块。
"1": [1]
},
// h:热更新哈希,用于验证模块是否是最新的。
"h": "8b1a9983c59fd8b13298",
// c+x, c+y, c+z:组合入口点的ID,用于支持多入口点的情况。
// 例如,"c+x": [0, 1]表示由入口点0和1组合的组合入口点。
"c+x": [0, 1],
"c+y": [0, 1],
"c+z": [0, 1]
}
m:
e:入口点 ID 到其包含的模块 ID 数组的映射。例如,"0": [0]表示 ID 为 0 的入口点仅包含 ID 为 0 的模块。
h:热更新哈希,用于验证模块是否是最新的。
c+x, c+y, c+z:组合入口点的 ID,用于支持多入口点的情况。例如,"c+x": [0, 1]表示由入口点 0 和 1 组合的组合入口点。
补丁文件
补丁文件指的就是 [hash].[module-id].hot-update.js
文件的内容,包含了更新后的模块代码。
其中最重要的部分就是:
webpackHotUpdate("main", {
"./src/Button.js": (/* 新模块函数 */)
});
5. WebSocket 推送编译进度
编译期间,devServer 会通过一系列的钩子,将 Webpack 编译打包的各个阶段的状态信息,通过 WebSocket 向浏览器推送一系列消息:
progress-update
消息:编译进度消息,包含编译进度百分比和当前编译状态等信息[{ "type": "progress-update", "data": { "percent": 0, "msg": "compiling" } }]
hash
消息:携带最新编译的hash
值。[{ "type": "hash", "data": "15b70e9df55bc075daeb" }]
ok
消息:表示编译完成,可以开始更新。[{ "type": "ok" }]
HMR 的详细消息列表示例
Data | Length | Time |
---|---|---|
a["{"type":"progress-update","data":{"percent":0,"msg":"compiling"}}"] | 82 | 10:58:59.196 |
a["{"type":"progress-update","data":{"percent":10,"msg":"building modules (0/1 modules)"}}"] | 104 | 10:58:59.228 |
a["{"type":"progress-update","data":{"percent":10,"msg":"building modules (1/1 modules)"}}"] | 104 | 10:58:59.894 |
a["{"type":"progress-update","data":{"percent":10,"msg":"building modules (1/2 modules)"}}"] | 104 | 10:58:59.896 |
a["{"type":"progress-update","data":{"percent":10,"msg":"building modules (1/3 modules)"}}"] | 104 | 10:58:59.898 |
a["{"type":"progress-update","data":{"percent":10,"msg":"building modules (1/4 modules)"}}"] | 104 | 10:58:59.907 |
a["{"type":"progress-update","data":{"percent":10,"msg":"building modules (2/4 modules)"}}"] | 104 | 10:59:00.061 |
a["{"type":"progress-update","data":{"percent":10,"msg":"building modules (3/4 modules)"}}"] | 104 | 10:59:00.243 |
a["{"type":"progress-update","data":{"percent":10,"msg":"building modules (4/4 modules)"}}"] | 104 | 10:59:00.442 |
a["{"type":"progress-update","data":{"percent":71,"msg":"sealing"}}"] | 81 | 10:59:00.464 |
a["{"type":"progress-update","data":{"percent":72,"msg":"optimizing"}}"] | 84 | 10:59:00.542 |
a["{"type":"progress-update","data":{"percent":73,"msg":"basic module optimization"}}"] | 99 | 10:59:00.542 |
a["{"type":"progress-update","data":{"percent":74,"msg":"module optimization"}}"] | 93 | 10:59:00.543 |
a["{"type":"progress-update","data":{"percent":75,"msg":"advanced module optimization"}}"] | 102 | 10:59:00.543 |
a["{"type":"progress-update","data":{"percent":76,"msg":"basic chunk optimization"}}"] | 98 | 10:59:00.592 |
a["{"type":"progress-update","data":{"percent":77,"msg":"chunk optimization"}}"] | 92 | 10:59:00.618 |
a["{"type":"progress-update","data":{"percent":78,"msg":"advanced chunk optimization"}}"] 101 | 101 | 10:59:00.618 |
a["{"type":"progress-update","data":{"percent":79,"msg":"module and chunk tree optimization"}}"] | 108 | 10:59:00.619 |
a["{"type":"progress-update","data":{"percent":80,"msg":"chunk modules optimization"}}"] | 100 | 10:59:00.625 |
a["{"type":"progress-update","data":{"percent":81,"msg":"advanced chunk modules optimization"}}"] | 109 | 10:59:00.629 |
a["{"type":"progress-update","data":{"percent":82,"msg":"module reviving"}}"] | 89 | 10:59:00.636 |
a["{"type":"progress-update","data":{"percent":83,"msg":"module order optimization"}}"] | 99 | 10:59:00.860 |
a["{"type":"progress-update","data":{"percent":84,"msg":"module id optimization"}}"] | 96 | 10:59:00.861 |
a["{"type":"progress-update","data":{"percent":85,"msg":"chunk reviving"}}"] | 88 | 10:59:00.861 |
a["{"type":"progress-update","data":{"percent":86,"msg":"chunk order optimization"}}"] | 98 | 10:59:00.861 |
a["{"type":"progress-update","data":{"percent":87,"msg":"chunk id optimization"}}"] | 95 | 10:59:00.861 |
a["{"type":"progress-update","data":{"percent":88,"msg":"hashing"}}"] | 81 | 10:59:00.861 |
a["{"type":"progress-update","data":{"percent":89,"msg":"module assets processing"}}"] | 98 | 10:59:01.002 |
a["{"type":"progress-update","data":{"percent":90,"msg":"chunk assets processing"}}"] | 97 | 10:59:01.009 |
a["{"type":"progress-update","data":{"percent":91,"msg":"additional chunk assets processing"}}"] | 108 | 10:59:02.207 |
a["{"type":"progress-update","data":{"percent":92,"msg":"recording"}}"] | 83 | 10:59:02.278 |
a["{"type":"progress-update","data":{"percent":91,"msg":"additional asset processing"}}"] | 101 | 10:59:02.278 |
a["{"type":"progress-update","data":{"percent":92,"msg":"chunk asset optimization"}}"] | 98 | 10:59:02.279 |
a["{"type":"progress-update","data":{"percent":94,"msg":"asset optimization"}}"] | 92 | 10:59:02.280 |
a["{"type":"progress-update","data":{"percent":95,"msg":"emitting"}}"] | 82 | 10:59:02.675 |
a["{"type":"progress-update","data":{"percent":100,"msg":"Compilation completed"}}"] | 96 | 10:59:02.806 |
a["{"type":"hash","data":"2772cb30b9670a5848e9"}"] | 58 | 10:59:02.946 |
a["{"type":"ok"}"] | 22 | 10:59:02.946 |
6. 浏览器检查更新
浏览器(即runtime
)收到 ok
消息后,根据 hash
消息中携带的 hash
值,向 devServer 发起 AJAX 请求 Manifest 来获取更新清单。
Webpack 5 基于 Fetch API 实现,之前的版本基于 XMLHttpRequest。
// 从此为 webpack 5,使用 Fetch API 实现
//
function hotCheck() {
// 通过当前 Hash 请求 Manifest
fetch(`${currentHash}.hot-update.json`).then((manifest) => {
manifest.u.forEach((moduleId) => {
// 加载每个变更模块的补丁
loadUpdateChunk(moduleId, manifest.h);
});
});
}
7. 浏览器下载补丁文件
根据 Manifest 响应数据的 u
字段列表,依次通过 JSONP 请求向 devServer 来下载更新补丁。
// 通过动态创建 script 标签,发起 JSONP 请求
function fetchUpdateChunk(chunkId, hash) {
const script = document.createElement("script");
script.src = `${chunkId}.${hash}.hot-update.js?callback=__webpack_hmr`;
document.head.appendChild(script);
}
为什么不使用 WebSocket 来获取更新?
因为 WebSocket 是一种基于 TCP 协议的长连接,比较适合实时的、高频次的短消息双向传输。如果使用 WebSocket 来实现,可能涉及到对补丁文件的加密、解密、分片传输、代码解析、代码执行等操作,极大的增加了实现的复杂度,同时也不利于我们对传输过程的观察。
选择 JSONP 的理由
JSONP 通过动态创建 <script>
标签来发起 http 请求的方式,绕过了浏览器的同源策略,并且目标代码可以在下载完成后可以自动执行。整个过程可以在浏览器控制台查看。
Webpack 5 支持 AJAX
Webpack 5 中支持多种请求方式,包括:JSONP、EventSource、Fetch API、XMLHttpRequest。
Webpack 5 默认使用 Fetch API (AJAX),如果遇到不支持的浏览器,会降级到 JSONP。基于 HTTP/1.1 或 HTTP/2 协议。
// webpack 运行时中的核心代码(简化版)
async function hotDownloadUpdateChunk(chunkId) {
const url = `${__webpack_require__.p}${chunkId}.${currentHash}.hot-update.js`;
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const script = await response.text();
new Function(script)(); // 执行补丁代码
} catch (err) {
fallbackToJsonp(chunkId); // 降级到 JSONP
}
}
8. devServer 如何响应更新请求
devServer 通过 Express 拦截请求,判断是否为 HMR 请求,是则读取对应的文件返回给浏览器。
在 Webpack 5 之前,补丁文件和 manifest 是写到内存里的。Webpack 5 实现了持久化缓存,补丁文件和 manifest 是写到文件系统里的。两个版本中读取的方式略有差异,但是本质都是把文件内容返回给浏览器。
// 拦截 .hot-update.json 和 .hot-update.js 请求
app.get("/*.hot-update.json", (req, res) => {
const compilation = compiler.compilation;
const manifest = generateManifest(compilation); // 生成清单文件
res.jsonp(manifest); // 关键点:使用 jsonp() 方法响应
});
app.get("/*.hot-update.js", (req, res) => {
const chunkId = parseChunkId(req.path);
const patchCode = generatePatch(chunkId); // 生成补丁代码
res.type("js").send(patchCode);
});
// webpack-dev-server/lib/Server.js
setupHmrHotUpdateRoute() {
// 拦截 .hot-update.json 和 .hot-update.js 请求
this.app.get(/.*\.hot-update\.js(on)?$/, (req, res) => {
const filename = req.path.slice(1); // 移除首字符 '/'
const content = this.compiler.outputFileSystem.readFileSync(filename);
res.type('js').send(content);
});
}
9. 应用更新
// 伪代码:HMR 核心逻辑
function hotApply() {
// 1. 从缓存中删除旧模块
delete require.cache[moduleId];
// 2. 执行新模块代码
modules[moduleId] = hotUpdate[moduleId];
// 3. 触发模块的 `accept` 回调
callAcceptHandlers();
}
10. 模块更新策略
普通模块:直接替换代码,保留模块状态(如 React 组件的
state
)。有状态模块:通过
module.hot.dispose
保存状态,新模块通过module.hot.data
恢复状态。if (module.hot) { module.hot.dispose((data) => { data.counter = currentState.counter; // 保存状态 }); if (module.hot.data) { currentState.counter = module.hot.data.counter; // 恢复状态 } }
11. 冒泡更新机制(页面刷新范围)
runtime
使用冒泡更新机制来确定页面刷新范围。从变更的模块向上查找,直到遇到:
- 父模块:通过
module.hot.accept
显式接受更新。 - 未找到
accept
: 冒泡到入口,触发页面刷新。
12. 更新失败的处理
- 回退到完整页面刷新(可通过
devServer.hotOnly: true
禁用)。 - 通过
module.hot.status()
获取状态(如fail
或abort
)。
13. WebSocket 自动重连
开发服务器会自动重连 WebSocket,确保通信恢复。
总结
HMR 的核心流程可以简化为: 监听变化 → 增量编译 → 推送通知 → 下载补丁 → 替换模块 → 保持状态。 理解这一流程有助于:
- 优化 HMR 性能(如缩小
accept
范围)。 - 调试 HMR 失效问题(如检查 WebSocket 连接或模块依赖关系)。
- 实现框架(如 React、Vue)的热更新集成。
附录
Webpack 3.6 的实现
// 请求 manifest 文件
function hotDownloadManifest(requestTimeout) {
// eslint-disable-line no-unused-vars
requestTimeout = requestTimeout || 10000;
return new Promise(function (resolve, reject) {
if (typeof XMLHttpRequest === "undefined")
return reject(new Error("No browser support"));
try {
var request = new XMLHttpRequest();
var requestPath =
__webpack_require__.p + "" + hotCurrentHash + ".hot-update.json";
request.open("GET", requestPath, true);
request.timeout = requestTimeout;
request.send(null);
} catch (err) {
return reject(err);
}
request.onreadystatechange = function () {
if (request.readyState !== 4) return;
if (request.status === 0) {
// timeout
reject(new Error("Manifest request to " + requestPath + " timed out."));
} else if (request.status === 404) {
// no update available
resolve();
} else if (request.status !== 200 && request.status !== 304) {
// other failure
reject(new Error("Manifest request to " + requestPath + " failed."));
} else {
// success
try {
// 拿到 manifest 文件内容解析为 JSON
var update = JSON.parse(request.responseText);
} catch (e) {
reject(e);
return;
}
resolve(update); // 返回解析后的 JSON 内容
}
};
});
}
function hotCheck(apply) {
if (hotStatus !== "idle")
throw new Error("check() is only allowed in idle status");
hotApplyOnUpdate = apply;
hotSetStatus("check");
return hotDownloadManifest(hotRequestTimeout).then(function (update) {
if (!update) {
hotSetStatus("idle");
return null;
}
hotRequestedFilesMap = {};
hotWaitingFilesMap = {};
// 拿到所有变更的 chrunk
hotAvailableFilesMap = update.c;
// 拿到编译对应的 hash 值
hotUpdateNewHash = update.h;
hotSetStatus("prepare");
var promise = new Promise(function (resolve, reject) {
hotDeferred = {
resolve: resolve,
reject: reject,
};
});
hotUpdate = {};
var chunkId = 0;
{
/*globals chunkId */
hotEnsureUpdateChunk(chunkId);
}
if (
hotStatus === "prepare" &&
hotChunksLoading === 0 &&
hotWaitingFiles === 0
) {
hotUpdateDownloaded();
}
return promise;
});
}
function hotDownloadUpdateChunk(chunkId) {
// eslint-disable-line no-unused-vars
var head = document.getElementsByTagName("head")[0];
var script = document.createElement("script");
script.type = "text/javascript";
script.charset = "utf-8";
script.src =
__webpack_require__.p +
"" +
chunkId +
"." +
hotCurrentHash +
".hot-update.js";
head.appendChild(script);
}
更新日志
e7112
-1于