← 返回题目列表

HMR 热更新的原理是什么?为什么有时会退化成整页刷新?

高频 中等 第 9 / 31 题 更新于 2026/07/29
前端工程化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 放在通信层,不要把它当成全部原理。