面试导航 是一个专注于前、后端技术学习和面试准备的 免费 学习平台,提供系统化的技术栈学习,深入讲解每个知识点的核心原理,帮助开发者构建全面的技术体系。平台还收录了大量真实的校招与社招面经,帮助你快速掌握面试技巧,提升求职竞争力。如果你想加入我们的交流群,欢迎通过微信联系:
yunmz777
。
什么是模块热替换(HMR)?
在前端开发的早期,开发者每次修改代码后,通常需要 刷新网页 来查看修改效果。刷新时,整个页面的内容都会重新加载,而且应用的状态也会丢失。例如,如果你正在调试一个表单,输入框里的内容、滚动位置等都会被清空。
随着开发工具的进步,Live Reload 技术应运而生。它会在检测到代码变动时,自动重新加载页面。虽然这种方式让我们能更快速地看到效果,但依然会丢失页面状态,而且等待重新加载的过程仍然存在。
模块热替换(HMR) 就是对 Live Reload 的进一步优化,它能够让你在 不刷新页面 的前提下,自动更新修改的部分。更棒的是,HMR 还能够 保留页面的状态,使得开发效率显著提升。
为什么 HMR 能让开发更高效?
1. 节省等待时间
如果每次修改代码后都要等待几秒钟,甚至几十秒,才能看到效果,开发效率就会大打折扣。特别是在调试较复杂的页面或多层级的组件时,频繁的刷新会让你浪费大量时间。
HMR 解决了这个问题。你修改代码后,浏览器会即时更新修改的内容,无需刷新页面 ,可以 快速验证每个小的变动。这样你就能节省大量时间,提升开发效率。
2. 保留页面状态
在传统的开发流程中,每次页面刷新,用户的输入内容、滚动位置等状态都会丢失。这对于调试复杂的交互型应用来说是个很大的障碍。
HMR 则可以在更新代码时,保留页面上的状态。举个例子,如果你正在填表单或浏览页面时,HMR 能让你修改代码后,表单数据和滚动位置不会丢失。即便是动态状态,也能在热替换过程中得以保留。
3. 只更新变动的部分
HMR 最大的优势之一是它只会更新你 修改过的部分,而不会刷新整个页面。无论你是修改了 JavaScript、CSS,还是其他资源,浏览器只会替换这些具体的模块,而不会重新加载整个页面,节省了大量的资源和时间。
例如,如果你修改了一个组件的样式,HMR 只会更新这个组件的样式,而页面上的其他部分和用户输入都会保持不变。这就像你直接在浏览器开发者工具(DevTools)中修改样式一样。
4. 快速看到效果
HMR 可以让你几乎 实时 地看到代码修改后的效果,几乎跟打字的速度一样快。你不再需要等待长时间才能看到结果,这大大提升了开发的互动性和效率。
小结
模块热替换(HMR) 是现代前端开发中的一项非常实用的技术,它极大提升了开发效率。通过 不刷新页面 、只更新修改的部分 和 保留应用程序状态 ,HMR 能让你在开发过程中 快速验证修改的效果,避免了繁琐的页面重载操作。特别是在调试大型应用和复杂交互时,HMR 让开发者能更专注于修改和优化功能,而不必被频繁的页面重载所拖慢进度。
这种技术不仅仅是 Webpack 独有的,现在许多开发工具和框架都提供了类似的功能,帮助开发者更加高效地工作。
HMR 的基本使用
从 webpack-dev-server
v4.0.0 开始,热模块替换(HMR)功能默认是启用的。

整个结论我们可以从 webpack-dev-server
中的 Server
类中的 initialize()
方法可以得出结论,只需要将 webpack.config.js
中的 hot
设置为 true
即可。
js
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
entry: "./src/index.js",
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "./dist"),
},
mode: "development",
devServer: {
hot: true, // 开启模块热替换
},
plugins: [
new HtmlWebpackPlugin({
template: "./index.html",
}),
],
};
上面配置产生的结果是 Webpack
会为每个模块绑定一个 module.hot
对象,这个对象包含了 HMR
的 API
。借助这些 API
我们不仅可以实现对特定模块开启或关闭 HMR
,也可以添加热替换之外的逻辑。
下面是一些使用的例子:
js
// utils.js
export function sum() {
return 1 + 2;
}
export function multiplication(x, y) {
return x * y;
}
// index.js
import { multiplication, sum } from "./utils";
console.log(sum());
console.log(multiplication(3, 6));
if (module.hot) {
module.hot.accept();
}
index.js
作为引用的入口,那么我们就可以把调用 HMR
API 的代码放在该入口中,这样 HMR
对于 index.js
和其依赖的所有模块都会生效。当发现有模块发生变动时,HMR
会使用在当前浏览器环境下重新执行一遍 index.js
(包括其依赖的内容),但是页面本身不会刷新。
例如当我修改了 index.js
的内容时,浏览器会有以下输出:

而当我们修改了 utils.js
文件的时候,index.js
文件也会跟着更新,因为 index.js
有对该模块的引用:

大多数情况下,还是建议用于的开发者使用第三方提供的 HMR
解决方案,因为 HMR
触发过程中可能会出现很多预想不到的问题,导致模块更新后应用的表现和正常加载的表现不一致。
Webpack
社区还提供了许多其他 loader
和示例,可以使 HMR
与各种框架和库平滑地进行交互:
-
react-refresh:实时编辑
React
组件而不会丢失它们的状态; -
Vue Loader: 此 loader 支持 vue 组件的 HMR,提供开箱即用体验;
-
Elm Hot webpack Loader: 支持 Elm 编程语言的 HMR;
-
Angular HMR: 没有必要使用 loader!直接修改 NgModule 主文件就够了,它可以完全控制 HMR API;
-
Svelte Loader: 此 loader 开箱即用地支持 Svelte 组件的热更新;
HMR 原理
在本地开发环境中,浏览器充当客户端,而 webpack-dev-server
相当于我们的服务端。HMR 的核心原理是浏览器从服务端拉取更新后的资源。但更准确地说,HMR 并不是获取整个资源文件,而是获取 chunk diff ,也就是需要更新的 chunk 部分。
什么是 chunk?:在 Webpack 中,chunk 可以理解为一块打包好的代码块。它就像一个装有多个模块的"文件袋",其中包含着被打包和优化过的各个模块。Webpack 会将这些模块捆绑在一起形成一个或多个 chunk,这些 chunk 就是打包后需要加载的代码块。
具体来说,chunk 是 Webpack 根据配置生成的文件,它是模块的集合,而不是单一的文件。一个项目的打包过程中,可能会产生一个或多个 chunk,具体数量取决于项目的构建配置。
当在终端运行 webpack serve
命令时,webpack-dev-server
会调用 Server
类中的 initialize()
方法。在这个方法里,有一个 compilers.forEach
的循环:
js
compilers.forEach((compiler) => {
this.addAdditionalEntries(compiler);
const webpack = compiler.webpack || require("webpack");
new webpack.ProvidePlugin({
__webpack_dev_server_client__: this.getClientTransport(),
}).apply(compiler);
// TODO remove after dropping webpack v4 support
compiler.options.plugins = compiler.options.plugins || [];
if (this.options.hot) {
const HMRPluginExists = compiler.options.plugins.find(
(p) => p.constructor === webpack.HotModuleReplacementPlugin
);
if (HMRPluginExists) {
this.logger.warn(
`"hot: true" automatically applies the HMR plugin,
you don't need to add it manually in your webpack configuration.`
);
} else {
// Apply the HMR plugin
const plugin = new webpack.HotModuleReplacementPlugin();
plugin.apply(compiler);
}
}
});
在这个循环中,addAdditionalEntries()
方法被调用,主要是将 WebSocket 所需的参数拼接到 client/index.js
的 require()
请求路径中:
js
additionalEntries.push(
`${require.resolve("../client/index.js")}?${webSocketURLStr}`
);
接着,将 webpack/hot/dev-server
的请求路径也存入 additionalEntries
数组,最后通过 webpack.EntryPlugin
把它们加入到模块请求的 hooks
中:
js
if (typeof webpack.EntryPlugin !== "undefined") {
for (const additionalEntry of additionalEntries) {
new webpack.EntryPlugin(compiler.context, additionalEntry, {
// eslint-disable-next-line no-undefined
name: undefined,
}).apply(compiler);
}
}
webpack.EntryPlugin
主要用于在编译时将一个新的入口 chunk
添加到编译过程中。它会在 compiler
的 make
hook 被调用时执行,并使用 compilation.addEntry
方法将其添加到编译中,接着通过调用 handleModuleCreate
函数来开始构建过程。
我们再来看 initialize()
方法中的一段代码 new webpack.HotModuleReplacementPlugin
,其主要作用是当 hot
设置为 true
时,将 HMR
插件挂载到 plugin
上,从而启用模块热替换功能。具体操作如下:

那么我们再看看 webpack-dev-server/client/index.js
文件做了些什么?

在 hot
方法中,客户端接收服务器端发送的 hot
指令,从而开启模块热替换(HMR)模式;
在 hash
方法中,客户端接收 Webpack 每次编译生成的最新 hash
,并将从 sendStats
发送过来的 hash
值保存下来,这个 hash
将在后续的模块热更新中使用。



在 hash 方法中,当 hash 消息发送完成后,socket 还会发送一次 ok 的信息告知 webpack-dev-server,第一次编译不需要执行 reloadApp,因为第一次全部编译的代码并没有额外的代码需要更新。
webpack-dev-server/client/index.js
当接收到 type
为 hash
消息后会将 hash
值暂存起来,当接收到 type
为 ok
的消息后对应用执行 reload
操作,如下图所示,hash
消息是在 ok
消息之前:

那么我们看看 reloadApp 内部都干了些啥?这里主要做的事情是通过 hotEmitter.emit
触发 webpackHotUpdate
事件:

我们再通过浏览器控制台查看,当我们修改了代码之后会触发这个方法,代码中的 log.info()
的内容会输出在浏览器控制台上:

当 hash
值发生变化时,Webpack 会监听到浏览器端的 webpackHotUpdate
消息。在这里,webpack
的定义文件为 webpack/hot/dev-server.js
,它将新的模块 hash
值传递给客户端的 HMR
核心部分,具体位置在 webpack/lib/hmr/HotModuleReplacement.runtime.js
。这个文件的主要功能我们稍后会详细探讨。
在 dev-server
文件中,关键的 check
方法用于检测更新,并判断是触发浏览器刷新还是执行模块热替换。如果是浏览器刷新,window.location.reload()
会被自动调用;如果代码发生错误,也会调用 window.location.reload()
。最后,HMR
会重新启动。
当监听到文件变化,dev-server
可以监听到哪个文件发生变化,当我们修改 utils.js
文件时,index.js
的内容也要改变,所以会有以下输出:

check
代码如下图所示:

我们再来看看 HotModuleReplacement.runtime.js
这个文件主要做的事情是什么?
这个文件的主要功能是 hotCheck
方法,首先我们来看看 $hmrDownloadManifest$
是个什么东西,我们在 hotCheck
方法打印一下:
js
console.log($hmrDownloadManifest$);
通过查看控制台,返回的主要有以下内容:
js
__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 available
if (!response.ok)
throw new Error(
"Failed to fetch update manifest " + response.statusText
);
return response.json();
}
);
};
其中 __webpack_require__.p
的值为 http://localhost:8080/
,而 __webpack_require__.hmrF()
有以下的定义:
js
__webpack_require__.hmrF = () =>
"main." + __webpack_require__.h() + ".hot-update.json";
__webpack_require__.h = () => "376a09b5cf1d64e19a6d";
__webpack_require__.h
中的字符串正是当前返回的 hash
值,全部拼接到一起就是 [name].[hash].hot-update.json
,所以 hotCheck
的主要功能是提供客户端 HMR
运行时下载发生变化的 chunk
文件,将最新代码加载到本地:

我们通过打印,hotCheck
最终返回的结果是以下这样的结果:
js
__webpack_require__.hmrC.jsonp = function (
chunkIds,
removedChunks,
removedModules,
promises,
applyHandlers,
updatedModulesList
) {
applyHandlers.push(applyHandler);
currentUpdateChunks = {};
currentUpdateRemovedChunks = removedChunks;
currentUpdate = removedModules.reduce(function (obj, key) {
obj[key] = false;
return obj;
}, {});
currentUpdateRuntime = [];
chunkIds.forEach(function (chunkId) {
if (
__webpack_require__.o(installedChunks, chunkId) &&
installedChunks[chunkId] !== undefined
) {
promises.push(loadUpdateChunk(chunkId, updatedModulesList));
currentUpdateChunks[chunkId] = true;
} else {
currentUpdateChunks[chunkId] = false;
}
});
if (__webpack_require__.f) {
__webpack_require__.f.jsonpHmr = function (chunkId, promises) {
if (
currentUpdateChunks &&
__webpack_require__.o(currentUpdateChunks, chunkId) &&
!currentUpdateChunks[chunkId]
) {
promises.push(loadUpdateChunk(chunkId));
currentUpdateChunks[chunkId] = true;
}
};
}
};
在模块热替换的过程中,还有一个重要的步骤就是在更新之前会删除过期的模块和依赖,这个功能的代码为 webpack/lib/hmr/JavascriptHotModuleReplacement.runtime.js
:

这些代码的主要功能是移除缓存中的模块,移除过期依赖中不需要使用的处理方法,以及移除所有子元素的引用和模块子组件中过时的依赖项。
处理完这个步骤之后,将新模块代码添加到 modules
中,当下次调用 __webpack-require__
方法的时候,就是获取到了新的模块代码了:

一张图带你彻底了解 HMR 更新原理
首先我们借助一张流程图,如下所示:

接下来我们简单地讲解一下这个执行的流程。
步骤 1:Webpack 开始编译
Webpack 是一个模块化打包工具,它的主要任务是将项目中的各个模块(比如 JavaScript 文件、CSS 文件、图片等)打包成浏览器能够加载的文件。当你启动 Webpack 并运行 webpack serve
或 webpack-dev-server
时,Webpack 会进入 监听模式,它开始监视文件的变化。如果文件内容发生了变化,Webpack 会重新编译这些文件。
- 文件变化监控:Webpack 会自动监听你指定的文件夹(通常是源码目录),当文件内容有任何修改时,Webpack 会触发重新编译。这时,Webpack 会打包出一个新的构建版本,并准备将其发送给浏览器。
步骤 2:Webpack-dev-server 和 Webpack-dev-middleware
当 Webpack 完成编译后,webpack-dev-server
或 webpack-dev-middleware
就会发挥作用。它们充当 Webpack 和浏览器之间的中介,负责将 Webpack 打包生成的文件发送到浏览器。
- webpack-dev-server:是一个开发服务器,通常用于开发阶段。它会启动一个本地服务器来提供文件的服务,并且会监听文件的变化。当有文件变化时,它会自动刷新浏览器或使用热模块替换更新页面内容。
- webpack-dev-middleware:是一个用于将 Webpack 构建结果与 Express 等服务器框架结合的中间件。它同样会提供文件服务,并实现实时更新。
步骤 3:文件/模块变化
当文件发生变化,Webpack 会检测到这些变化,并重新编译更新后的代码。然后,webpack-dev-server
会通过 WebSocket 将更新的信息推送给浏览器。这个过程是实时的,并且只会推送那些发生变化的模块。
- WebSocket :WebSocket 是一种全双工通信协议,允许客户端和服务器之间进行实时、双向的数据传输。在这个步骤中,
webpack-dev-server
会通过 WebSocket 向浏览器发送更新的通知,告诉它哪些模块发生了变化。
步骤 4:SockJs
SockJs 是 WebSocket 的一种实现,它确保了浏览器和服务器之间的实时通信。在 Webpack 中,webpack-dev-server
通过 SockJs 来保证客户端和服务器之间的数据流畅传输。SockJs 会在浏览器和服务器之间建立稳定的连接,确保即使在一些网络环境不稳定的情况下,数据也能顺利地传输。
- SockJs 的作用:它为 WebSocket 提供了一种稳定的替代方案,确保浏览器端能够接收到从服务端推送的更新通知。
步骤 5:webpack/hot/dev-server
这个模块是处理 热模块替换(HMR) 的关键。它会把模块更新的信息从 webpack-dev-server
发送到浏览器,让浏览器知道哪些模块发生了变化,进而决定哪些模块需要更新。它是整个 HMR 流程的核心部分。
- 热模块替换:是 Webpack 提供的一个功能,能够在不刷新整个页面的情况下,仅更新变化的模块。这样,页面上的状态(例如表单数据、滚动位置等)不会丢失,用户体验更好。
步骤 6:HotModuleReplacement Runtime
HotModuleReplacement.runtime
是在浏览器端运行的核心模块。它负责接收来自 webpack-dev-server
的更新通知,并执行相应的模块替换操作。当它接收到来自服务器的更新信息时,它会立即在浏览器中替换掉更新的模块,而不刷新整个页面。
- 为什么不刷新页面:这正是 HMR 的优势。它能在不刷新页面的情况下,动态替换更新过的模块。比如你修改了一个 React 组件,HMR 会直接替换掉这个组件的模块,而不重新加载整个页面。
步骤 7:JsonpMain Template Runtime
在这个步骤中,Webpack 会使用模板来处理与模块更新相关的内容,主要包括加载清单文件(manifest)和模块的映射。Webpack 通过 JSONP 或 Ajax 请求从服务器获取这些内容,并动态加载需要更新的模块。
- Manifest 文件:是 Webpack 打包后的模块清单,包含了所有模块的映射关系。浏览器用这个清单来查找并加载需要更新的模块。
步骤 8:Manifest 和 Ajax
浏览器会请求 Manifest 文件,它是一个 JSON 文件,记录了 Webpack 打包过程中生成的模块和对应的文件名。通过这个文件,浏览器能够确认哪些模块需要更新。
- Ajax 请求:Ajax 用于从服务器异步获取更新的模块信息。Webpack 会通过 Ajax 请求 Manifest 文件,并获取更新后的模块列表。
步骤 9:Modules 和 JSONP
在这一步,浏览器通过 JSONP 或 Ajax 获取到更新的模块,并将其加载到页面上。具体来说,浏览器会把新模块和现有模块合并,替换掉过时的内容。
- JSONP:是一种跨域加载数据的技术,Webpack 利用 JSONP 来动态加载模块,确保模块能够跨域更新。
步骤 10:App 更新
最后,一旦新的模块加载完成,浏览器就会更新页面上的内容,展示最新的效果。这个过程发生得非常迅速,用户几乎不需要等待,页面的状态会实时更新。
- 热替换效果:在 HMR 完成后,页面的内容会立即显示最新的修改,且无需刷新页面。这样,开发者可以在修改代码的同时,实时查看修改后的效果,而不必中断开发流程。
这个过程展示了 Webpack 在开发过程中如何高效地实时更新代码。当你修改文件时,Webpack 会重新编译,并通过 webpack-dev-server
将更新的模块推送到浏览器,而 不需要刷新整个页面。通过 HMR,Webpack 实现了局部更新,保留了页面的状态,极大地提高了开发效率。
总结
模块热替换(HMR)是 Webpack 提供的一项功能,允许在开发过程中实时更新修改过的模块,而无需刷新整个页面。它通过 WebSocket 或其他通信协议与浏览器保持连接,检测到文件变化后,立即将更新的模块传递给浏览器,并替换掉旧的模块。这样不仅提高了开发效率,还保留了页面的状态,避免了页面重载带来的中断。总的来说,HMR 使得前端开发更加高效、流畅。