一、前言
热更新是现代前端开发中提升开发效率的重要技术之一。它允许在不刷新整个页面的前提下,对代码模块进行动态替换,并尽可能保留应用状态,提供即时反馈。
日常开发过程中,可能很多人会发现自己项目的热更新经常失效 ,很多时候需要手动刷新页面才行 ,这里就以 Umi Max项目为例探讨下热更新问题及解决方案。
二、热更新的机制
1. 什么是热更新(HMR)
热更新 (Hot Module Replacement,简称 HMR)是一种由构建工具(如 Webpack、Vite、Parcel 等)提供的能力,结合运行时插件,能够动态更新单个或多个模块,而无需重新加载整个页面。
1.1. 核心目标
- 只更新修改的部分
- 保留应用状态(如表单输入、组件状态等)
- 提供即时反馈,提高开发效率
2. 基本原理


2.1. 基本工作流程
(1)文件变更监听
- 构建工具通过文件系统监听器检测项目文件的变化。
(2)增量构建
- 工具仅对发生变化的模块进行编译和打包,生成更新补丁。
(3)推送更新
- 开发服务器通过 fetch 或 socket 推送更新信息到浏览器。
(4)模块替换与执行
- 浏览器端接收更新后,动态替换旧模块,并通知应用完成更新。
2.2. 核心技术机制
(1)模块系统的动态性
- 前端框架(如 React、Vue)基于模块系统(ES Modules / CommonJS)构建。
- 模块可以被动态加载、卸载和替换。
(2)模块热更新协议
- 构建工具定义了一套标准化的协议来描述模块的更新过程。
- 更新内容通常以 JSON 或 JS 文件形式传输。
(3)状态保留机制
- 需结合运行时的热替换插件
- 利用框架的响应式系统或 Hook 机制,在更新模块的同时保留状态。
- React 使用
useState、useRef等 Hook 来保存状态; - Vue 使用
data()和setup()中的响应式变量。
- React 使用
3. react-refresh
react-refresh,是 react 官方推出的运行时热更新方案,当前广泛使用。
- 在 react 生态下,热更新机制经历了两个重要阶段:
- 传统模式(react hot loader)和 新模式(react fast refresh,即 react-refresh)。
- 传统模式由于稳定性差且与函数式组件及 hooks 的兼容性不佳,在 react 16.8 后逐渐废弃。
- umi max 项目的 mfsu/mako 也是使用的 react-refresh。
3.1. 和 HMR 的关系
HMR 是底层机制:
- HMR 是 Webpack、Vite 等构建工具提供的模块热替换能力。
- 它监听文件变化,通过 socket/fetch 通知浏览器更新模块,但不关心具体内容。
react-refresh 是运行时实现:
- React Refresh 是 React 提供的运行时热更新方案;
- 它通过 Babel 插件 + 运行时代码,实现组件状态保留和更新逻辑。

3.2. 工作原理
(1)编译阶段(Babel 插件)
- 使用
@babel/plugin-react-refresh注入组件签名(Signature),并注册逻辑(injectIntoGlobalHook);- 每个组件都会生成一个唯一的 ID,用于运行时识别。
(2)运行时阶段(React Refresh Runtime)
- 通过
react-refresh/runtime跟踪组件变化;- 如果组件未发生结构性变化,则保留其状态,页面组件自动更新;
- 否则,触发完整刷新,即页面自动刷新。
3.3. 使用方式
- 方式一:使用成熟的框架,例如:create-react-app、umi max,默认自带。
- 方式二:自行配置(webpack 为例)
-
npm install @pmmmwh/react-refresh-webpack-plugin react-refresh// webpack.config.js
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');module.exports = {
plugins: [
new ReactRefreshWebpackPlugin(),
],
module: {
rules: [
{
test: /.[jt]sx?$/,
use: [
{
loader: 'babel-loader',
options: {
plugins: ['react-refresh/babel'],
},
},
],
},
],
},
};
-
三、ts/js 热更新
1. 失效问题
1.1. 背景
蚂蚁中后台场景下,曾推出站点应用二合一模式(极简流程),研发流程和访问速度相比以往都有较大的提升,在该模式下,以往的主应用变成了主题模式的接入,提供导航栏、侧边栏等通用 UI 和 API 的配置能力。
- 在接入主题时通常需要 配置 externals ,在本地开发状态下 react 和 react-dom 使用了 cdn 形式的依赖资源。
- 就是这里的 externals 配置导致了热更新失效。
- 准确的说,是 react-refresh 失效,而 HMR 未失效,所以能看到代码变动的推送消息在浏览器端能收到,但未触发组件自动更新。
1.2. externals 导致
项目 externals 配置 react 和 react-dom 使用 cdn 资源时,react-refresh 热更新机制就会失效。
- 当然,手动刷新页面还是能看到最新效果,不过仍然会严重影响开发效率。
- 这个其实是 react-refresh 存在已久的问题,具体可参考:https://github.com/facebook/react/issues/17552
1.3. 根本原因
在开发环境中,react-refresh/runtime 提供了 injectIntoGlobalHook 方法,用于向全局注册 API,供 react 渲染器调用。
如果 react 和 react-dom 通过 CDN 加载,它会在 react-refresh 初始化之前执行,导致钩子注入失败。
2. 解决方案
2.1. chrome 扩展(推荐)
方案一:chrome 扩展
- 安装 react 调试工具扩展:React Developer Tools,并开启,然后刷新下页面,热更新就生效了。
- 该扩展插件是 react 官方推出的,稳定性有保障。

为什么上述扩展插件能解决?
- 原因是 react developer tools 扩展在加载时,会在 window 上注入
__REACT_DEVTOOLS_GLOBAL_HOOK__对象,并添加相关 API,这个对象和 react-refresh 有深度联动以提供代码调试能力,正好补足了热更新所需的运行环境。
2.2. js 插入
方案二:js 插入
-
基于以上扩展插件提供的
__REACT_DEVTOOLS_GLOBAL_HOOK__对象能提供热更新运行环境,可以实现一段简化版的 js 代码,插入到 html 的 head 中提前执行,从而修正运行时的热更新问题。 -
js 代码:
*TypeScript(function (globalObject) { var rendererIdCounter = 0; var rendererMap = new Map; var devToolsHook = { renderers: rendererMap, supportsFiber: true, inject: function (renderer) { var id = ++rendererIdCounter; rendererMap.set(id, renderer); return id; }, }; Object.defineProperty(globalObject, "__REACT_DEVTOOLS_GLOBAL_HOOK__", { configurable: true, enumerable: false, get: function () { return devToolsHook; } }); })(window);
-
umi max 下的使用方式,在 config.ts 里配置:
*TypeScriptimport { defineConfig } from 'xxx'; const isLocalDev = process.env.NODE_ENV === 'development'; export default defineConfig({ ..., headScripts: isLocalDev ? [ { content: `上述js代码字符串`, }, // 主题等其他插入的代码放下面 ..., ] : [], externals: isLocalDev ? { react: 'React', 'react-dom': 'ReactDOM', } : {}, });
- 本地重启项目,刷新页面,热更新即可生效。
四、样式 热更新
1. 失效问题
1.1. 背景
- umi max 官方推荐的 css 样式方案是使用 styled-components,并推荐大颗粒度的方式。
- 我们在写代码时,可能会把样式代码抽离到一个单独的文件里,例如 index.style.ts。
- 这样写代码时会发现,修改样式,页面并不会自动更新,手动刷新页面才生效。
总结:
- styled-components 方案下,样式文件单独抽离后,样式热更新会失效。
1.2. 原因分析
- 首先需要说明,HMR 是模块化热更新,只更新变化的模块/组件。
- 在 styled-components 方案下,样式组件的 dom 节点除了自己添加类名外,还会多出两个动态类名:

- 官方文档说明
- 例如类名:xx__sc-crrtmM hEYPxh testWrap
- 第一个类名是拼接了 hash,这个 hash 值和样式文件路径有关
- 中间的类名是纯 hash,这个 hash 值和样式内容有关
- 重点就是中间这个 hash:
- 样式代码修改后,样式内容发生变化,生成了新的 hash;但由于样式文件被单独抽离,ts 组件代码并没有改动,所以 dom 节点也没触发更新,使用的还是已过期的 hash 值,导致新的样式作用不上。
- 例如类名:xx__sc-crrtmM hEYPxh testWrap
css modules 样式方案不会有这个问题:
- 虽然也会在类名上拼接一个 hash,但这个 hash 值默认是和样式文件的路径相关,修改 css 代码不会改变 hash 值,所以不存在失效问题。

2. 解决方案
2.1. 合并样式文件
- 解决方式还是得把样式和 ts 组件合并一个文件里。
- 考虑到代码结构清晰性、可维护性,建议使用 styled-components 时采用大颗粒度模式 (即默认只在根节点套一层样式组件),并将样式代码放到最底部 (类 Vue 文件结构,数据 + dom + 样式),这样整体结构依然清晰完整。
- 项目的 lint 校验规则 上需要处理下,配置一下项目的 .eslintrc.js 文件,添加一条 rule:
- js 本身有预解析能力,没必要严格控制定义顺序,用不好反而容易使结构混乱。



