1. 起因

新同事反馈说改了组件代码之后页面不自动刷新,得手动 F5,一开始以为是他本地环境问题,结果自己电脑上跑了一下也复现了,查下来是几个配置项凑在一起把 HMR 搞坏了,记一下排查过程。

2. 先确认 HMR 到底有没有真的开

很多项目用 webpack-dev-server 起服务,容易想当然地认为默认就是热更新,但实际上得显式开启:

1
2
3
4
5
6
// webpack.config.js
module.exports = {
devServer: {
hot: true, // webpack 5 + webpack-dev-server 4 默认是 true,但老项目升级过来的配置未必带这行
},
};

打开浏览器控制台,如果看到的是整页刷新(页面白屏一下再出现),而不是局部替换模块,那就是 HMR 没生效,走的是 liveReload(自动刷新整页)而不是真正的模块热替换。这两者从用户视角看起来都是”代码改了页面就更新了”,很容易被误认为是同一个东西,但背后的机制完全不同——排查这类问题的第一步就是先分清楚现在到底是哪种。

3. React 组件状态丢失,其实是没接 react-refresh

HMR 本身开着,但每次改代码组件状态都被重置(比如一个展开的折叠面板,改完代码就自动收起了),这是因为 webpack 的 HMR 只负责替换模块本身,不负责保留 React 组件的运行时状态,这部分需要额外的插件配合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// webpack.config.js
const ReactRefreshWebpackPlugin = require('@pmmp/webpack-plugin-react-refresh'); // 或官方的 @pmmmwh/react-refresh-webpack-plugin
const ReactRefreshBabelPlugin = require('react-refresh/babel');

module.exports = {
plugins: [
new ReactRefreshWebpackPlugin(),
],
module: {
rules: [
{
test: /\.jsx?$/,
use: {
loader: 'babel-loader',
options: {
plugins: [require.resolve('react-refresh/babel')],
},
},
},
],
},
};

不接这一层的话,module.hot.accept 虽然生效了,但组件是被整个卸载重建的,state 自然就没了。当时项目是从 CRA 迁出来自己配的 webpack,迁移的时候这块配置漏掉了,属于典型的”能跑但体验不对”的坑,不细看控制台日志根本发现不了。

4. 入口文件里手动写的 module.hot.accept 反而添乱

翻旧代码的时候发现入口文件里手写了这么一段:

1
2
3
4
5
if (module.hot) {
module.hot.accept('./App', () => {
render(<App />);
});
}

这是老版本 React(Fast Refresh 出来之前)的手动 HMR 接入方式,用来处理某个特定模块更新后手动触发重新渲染。项目引入 react-refresh 插件之后,这段代码其实已经不需要了,两边的更新机制混在一起,偶尔会出现”改了代码但页面没反应,得再手动保存一次才生效”这种奇怪的滞后现象。删掉这段手写逻辑、完全交给 react-refresh 处理之后问题就消失了。排查这类问题最容易被忽略的地方就是”新机制引入了,但老代码没跟着清理”,两套逻辑互相干扰,表现出来的现象反而比单纯没开 HMR 更难定位。

5. 文件监听在某些环境下不触发

还有一种情况是改了文件但 webpack 压根没检测到变化,这在 Docker 容器里跑开发环境、或者用网络挂载盘(比如某些云开发环境的文件系统)时比较常见,本质是操作系统的文件变更通知机制在这些环境下不可靠,需要退回轮询模式:

1
2
3
4
5
6
7
8
9
10
11
module.exports = {
devServer: {
watchFiles: {
paths: ['src/**/*'],
options: {
usePolling: true,
interval: 1000,
},
},
},
};

usePolling 会有一定的 CPU 开销,不是默认该开的选项,但在虚拟化文件系统的环境下,这基本是唯一能让 HMR 正常工作的办法。

6. 小结

HMR 失效这类问题排查起来比较烦的地方是”表现相似、原因完全不同”——整页刷新代替局部更新、状态丢失、更新延迟、完全不触发,背后分别对应配置没开、没接 React 插件、新老机制冲突、文件监听失效这几种不同原因。先在浏览器控制台确认现象到底是哪一种,再对症去查对应的配置项,比把所有配置都试一遍要快得多。