在前端开发里,「改代码 → 看效果」是最高频的动作。如果每次保存都整页刷新,不仅慢,还会把当前页面状态全部清空——填了一半的表单没了,弹窗关了,路由跳回首页。**HMR(Hot Module Replacement,模块热替换)**就是为了让这个过程既快又不丢状态。
一、为什么需要 HMR
1. 整页刷新会丢失状态
传统开发靠「自动刷新」(Live Reload):文件一变,浏览器整页重载。它解决了手动刷新的问题,但代价是:
- 页面所有 JS 重新执行,应用状态全部重置。
- 单页应用会跳回初始路由。
- 需要重新点击、重新输入,才能回到刚才调试的位置。
调试一个三层弹窗里的逻辑时,改一行动样式就要重新点开三次,效率极低。
2. 整页刷新太慢
页面越复杂,重新加载、重新渲染的时间越长。状态丢失 + 重建成本,让「改一下看看」变得很痛苦。
3. 我们其实只改了一个模块
绝大多数时候,一次改动只涉及一个模块(一个组件、一段样式、一个工具函数)。没必要让整个应用重来——只要把变化的那一个模块替换掉,其它保持不动就好。这正是 HMR 的思路。
二、HMR 是什么
HMR 全称 Hot Module Replacement,直译是「热模块替换」。它的定义是:
在应用运行过程中,用新版本的模块替换掉旧模块,而不刷新整个页面。
它和「自动刷新」的区别:
| 自动刷新(Live Reload) | 热替换(HMR) | |
|---|---|---|
| 更新范围 | 整个页面 | 仅变更的模块 |
| 应用状态 | 丢失 | 保留 |
| 速度 | 慢 | 快 |
| 体验 | 回到初始状态 | 停留在原地 |
它背后的机制
webpack 的 HMR 由三部分协作完成:
- webpack-dev-server(HMR Server):通过 WebSocket 与浏览器保持长连接,监听文件变化并触发重新编译。
- HMR Runtime:被打进产物、运行在浏览器里的一段代码,负责接收更新、替换模块。
- 编译产出的更新包:每次改动会生成
hot-update.json(清单)和hot-update.js(新模块代码)。
一次热更新的完整流程大致是:
文件改动
→ dev-server 重新编译
→ 通过 WebSocket 把新的 hash 推给浏览器
→ HMR Runtime 请求 hot-update.json,得知哪些 chunk 变了
→ 下载对应的 hot-update.js
→ 用新模块替换旧模块,执行 accept 回调
→ 页面局部更新,其它状态保持不变
理解这条链路,后面排查「HMR 为什么不生效」就会轻松很多。
三、怎么用
1. 开启 HMR
在 webpack-dev-server 中打开 hot:
module.exports = {
mode: 'development',
devServer: {
hot: true,
},
};
启动:
npx webpack serve
从 webpack-dev-server v4 起,hot 默认就是 true,但显式写出来更清晰。
2. CSS 的热更新是自动的
只要用了 style-loader(开发环境),CSS 的 HMR 开箱即用。改样式时会直接替换页面上的 <style>,不刷新、不丢状态,这也是为什么开发环境的样式建议用 style-loader 而不是 MiniCssExtractPlugin。
3. JS 模块需要「接受」更新
对普通 JS 模块,webpack 并不知道「替换后该做什么」。如果没有任何模块声明「我能处理这个更新」,更新会一路上抛,最终触发整页刷新。要让某个模块支持热替换,需要用 module.hot.accept 声明:
// src/index.js
import { render } from './render.js';
render();
if (module.hot) {
module.hot.accept('./render.js', () => {
// render.js 更新后重新执行
const { render: nextRender } = require('./render.js');
nextRender();
});
}
module.hot 提供了几个常用 API:
| API | 作用 |
|---|---|
module.hot.accept(deps, cb) | 声明本模块接受依赖更新 |
module.hot.dispose(cb) | 模块被替换前做清理(如移除事件监听) |
module.hot.decline() | 声明本模块不可热替换 |
module.hot.invalidate() | 主动让自己失效,触发上层处理 |
module.hot.status() | 查询当前 HMR 状态 |
手动写 accept 比较繁琐,实际项目里通常交给框架插件。
4. 框架的 HMR 方案
- React:使用
react-refresh+@pmmmwh/react-refresh-webpack-plugin,组件更新时保留状态,体验接近完美。
npm install -D react-refresh @pmmmwh/react-refresh-webpack-plugin
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
module.exports = {
module: {
rules: [
{
test: /\.jsx?$/,
use: {
loader: 'babel-loader',
options: {
plugins: ['react-refresh/babel'],
},
},
},
],
},
plugins: [
new ReactRefreshWebpackPlugin(),
],
};
- Vue:
vue-loader内置了 HMR 支持,配置好 loader 即可。 - 通用场景:也可以接入
hot-loader或框架官方脚手架(如 CRA、Vue CLI)里已有的方案。
5. 只允许 HMR,禁止整页刷新
如果希望「HMR 失败就不要偷偷刷新页面」,可以设为 hot: 'only':
devServer: {
hot: 'only',
}
这样更新无法被接受时会报错提示,而不是悄悄整页刷新——有助于发现哪些模块没有正确处理热替换。
四、HMR 不生效?常见原因
mode不是 development:生产模式会关掉相关能力。- 没用
webpack serve:直接webpack build不会启动 HMR。 - CSS 用了
MiniCssExtractPlugin:抽离成 link 后 HMR 支持有限,通常会退化为整页刷新。开发环境请用style-loader。 - JS 模块没有
accept处理,也没有框架插件:更新无人接收,只能整页刷新。 - 产物文件名带
[contenthash]:HMR 依赖稳定的文件名,开发环境不要加 hash。 - 被
devServer.hot: false或liveReload配置覆盖:确认没有冲突设置。
五、最小可用示例
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'development',
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'js/[name].js', // 开发环境不加 contenthash
},
module: {
rules: [
{ test: /\.css$/i, use: ['style-loader', 'css-loader'] },
],
},
plugins: [
new HtmlWebpackPlugin({ template: './public/index.html' }),
],
devServer: {
hot: true,
open: true,
port: 8080,
client: { overlay: true },
},
};
配合 package.json:
{
"scripts": {
"dev": "webpack serve --mode development"
}
}
运行 npm run dev 后,改 CSS 会即时生效且不刷新,改 JS 组件(配好框架插件后)也会保留状态。
小结
围绕三个问题回顾:
- 为什么:整页刷新会丢失应用状态、重建慢,而一次改动往往只涉及一个模块。
- 是什么:HMR 在运行时用新模块替换旧模块,不刷新页面,从而保留状态;它由 dev-server、HMR Runtime、更新包三方协作完成。
- 怎么用:开发环境设
devServer.hot: true;CSS 自动热更新;JS 用module.hot.accept或框架插件(React Refresh / vue-loader);必要时用hot: 'only'禁止降级刷新。
用好 HMR,能把「改一下看看」的时间从几秒压缩到几乎无感,是提升开发体验最划算的一笔投入。