HMR 热更新的原理是什么?为什么有时会退化成整页刷新?
简化版
HMR 的核心是开发服务器监听文件变化,把变更模块和更新边界通知浏览器,浏览器只替换可接受更新的模块。若模块链上没有合适的 accept 边界,或副作用无法安全恢复,就会退化成整页刷新。
详细版
HMR 通常包含 4 步:文件监听、模块图失效、WebSocket 通知、浏览器运行时应用更新。构建工具会记录模块之间的 import 关系,当某个文件变化时,从变更模块向上查找能接受更新的边界。
在 React、Vue 等框架里,框架插件会把组件包装成可热替换单元,因此改组件模板或样式通常不用刷新页面。改入口文件、路由初始化、全局状态单例、模块顶层副作用时,工具可能无法证明局部替换是安全的。
回答这题不能只说“WebSocket 推送”。WebSocket 只是通知通道,真正关键是模块图、失效范围、accept 边界和运行时状态保留策略。
完整版教学
一、HMR 解决的是开发反馈时间问题
传统开发流程里,一次小改动可能触发重新构建、浏览器刷新、应用重新启动和状态丢失。对复杂后台系统来说,重新点到某个深层页面可能比编译本身更浪费时间。
HMR 希望把一次改动压缩成“只重新转换变更模块,并在页面里替换它”。比如一个项目有 3000 个源码模块,修改 Button.vue 理想情况下只需要重转 1 个组件和少量相关模块,而不是重启整个应用。
记忆钩子:HMR 不是让代码不重新编译,而是把“重新编译 + 重启应用”缩小成“局部失效 + 局部替换”。
二、一次热更新通常经过哪些链路
开发服务器会同时维护文件系统监听和模块依赖图。文件变化后,工具先找到受影响模块,再决定通知浏览器执行热替换还是刷新页面。
文件保存
-> watcher 捕获变化
-> 构建工具让模块失效
-> 查找 HMR accept 边界
-> WebSocket 发送 update/full-reload
-> 浏览器 HMR runtime 加载新模块并执行回调
如果 src/components/Card.tsx 变化,浏览器可能请求一个带时间戳的新模块 URL。旧模块实例仍在内存里,新代码加载后由框架 runtime 决定怎么重新渲染、保留 state 或丢弃局部 state。
三、模块图和 accept 边界才是判断局部更新的关键
模块图记录“谁 import 了谁”。当叶子模块变化时,HMR 会向父模块传播,直到遇到一个明确声明能处理更新的模块。
// parent.js
import { render } from './view.js';
if (import.meta.hot) {
import.meta.hot.accept('./view.js', (next) => {
next.render();
});
}
上面代码的意思是:parent.js 愿意接收 view.js 的变化,并知道怎样使用新模块。如果一路向上都没有模块接收更新,工具只能刷新页面,因为它不知道局部替换后应用是否还一致。
| 变化位置 | 常见结果 | 原因 |
|---|---|---|
| 组件样式 | 局部替换 | 样式可重新注入 |
| 单个框架组件 | 热替换 | 框架插件提供边界 |
| 入口初始化 | 整页刷新 | 影响应用启动链路 |
| 顶层副作用模块 | 可能刷新 | 难以安全回滚副作用 |
四、为什么 WebSocket 只是通知通道
很多回答会把 HMR 简化为“服务端通过 WebSocket 推送”。这只说到了通信方式,没有解释怎么知道更新哪个模块、怎么加载新代码、怎么处理旧状态。
WebSocket 消息通常只包含更新类型、模块路径、时间戳或 hash。浏览器拿到消息后,还要通过 <script type="module">、动态 import 或运行时代码去加载新的模块版本。
假设更新消息大小只有 300 B,而新模块转换后有 12 KB,真正耗时通常在模块转换、网络请求、框架重渲染和浏览器执行上。通知通道快,不等于热更新一定快。
五、为什么有时会退化成整页刷新
退化成整页刷新通常不是工具偷懒,而是局部替换风险太高。比如模块在顶层注册全局事件,旧事件监听如果没有 dispose,就会留下两份监听。
window.addEventListener('resize', onResize);
if (import.meta.hot) {
import.meta.hot.dispose(() => {
window.removeEventListener('resize', onResize);
});
}
HMR 运行时需要清理旧副作用,否则每次保存都会叠加定时器、事件监听、WebSocket 连接或全局单例。框架组件的热更新容易,是因为框架能控制组件生命周期;任意业务模块则不一定。
六、框架插件为什么能保留状态
React Fast Refresh、Vue HMR 等能力,本质是在编译阶段和运行时之间建立约定。编译插件识别组件边界,运行时负责替换组件实现,并尽量保留组件树中的局部状态。
例如只改组件渲染逻辑时,状态可以保留;如果改了 hook 调用顺序、导出形态或组件签名,框架可能认为旧状态不再安全,局部状态就会被重置。
| 改动类型 | 状态保留概率 | 解释 |
|---|---|---|
| 修改 CSS | 高 | 样式替换不影响组件实例 |
| 修改模板文本 | 高 | 组件边界稳定 |
| 修改 hook 顺序 | 低 | 状态槽位对应关系改变 |
| 修改全局 store 初始化 | 低 | 影响单例与启动时序 |
七、排查 HMR 慢或频繁刷新的方法
排查时先看日志,不要直接换工具。开发服务器通常会打印是 hot update 还是 full reload,以及触发文件路径。
可以按 3 个维度定位:文件监听是否过大、模块转换是否过重、更新边界是否缺失。比如保存一个样式文件却触发 500 个模块失效,通常要检查样式入口、插件配置或路径别名是否导致依赖图过宽。
一个实用指标是记录保存到页面可交互的耗时:P50 低于 200 ms 体验很好,P95 超过 1000 ms 就会打断编码节奏。大型项目优化 HMR 的价值往往比优化冷启动更直接。
八、常见误区与追问
- 误区:HMR 就是 WebSocket。 WebSocket 只负责通知,模块图、accept 边界和运行时替换才决定能否局部更新。
- 误区:热更新一定能保留所有状态。 只有边界稳定且框架能确认安全时才适合保留状态。
- 误区:退化整页刷新就是构建工具有问题。 入口、副作用、全局单例和无法接受更新的模块链都会导致刷新。
- 追问:为什么改 CSS 通常最快? CSS 可以重新注入或替换 link,不需要重建应用状态。
- 追问:什么是 dispose 回调? 它用于在旧模块被替换前清理事件、定时器、连接等副作用。
- 追问:如何优化 HMR? 缩小监听范围、减少重型插件、拆清模块边界、避免入口层聚合过多业务逻辑。
九、加强记忆
把 HMR 记成“监听、失效、通知、替换、兜底刷新”:文件变化后,工具沿模块图找可接受更新的边界,浏览器运行时加载新模块并执行替换逻辑;如果边界不存在或副作用无法清理,就整页刷新。面试回答要把 WebSocket 放在通信层,不要把它当成全部原理。