Vite 的 HMR 原理(热模块替换)
面试速答(30 秒版 TL;DR)
- Vite 开发态不做整包打包,而是基于浏览器原生 ESM「按需加载模块」。
- HMR 的本质是:文件变更 → 服务端根据模块图定位影响范围 → 通过 WebSocket 通知浏览器 → 浏览器用动态
import()拉取新模块(带时间戳绕过缓存)→ 执行import.meta.hot.accept/dispose完成局部替换。 - 若沿着 import 链路找不到“可接受更新”的边界(HMR boundary),就退化为 full reload(整页刷新)。
1. 先把概念拎清:HMR 不是“热刷新”
很多人把 HMR 当成“自动刷新页面”,这不准确:
- Live Reload(热刷新):文件一改 → 浏览器整页刷新 → 应用状态丢失
- HMR(热模块替换):文件一改 → 只替换受影响模块 → 尽量保持运行时状态
Vite 的目标是:能局部替换就局部替换;替换不了再刷新。
2. Vite HMR 为什么快:把“重活”从打包器挪走
在开发环境,Vite 利用两个关键事实:
- 浏览器会为每个 ESM 模块发起独立请求(按 import 链路加载)
- 模块更新时不需要重建 bundle,只要让浏览器重新请求“变更的模块”
所以一次更新通常只涉及:
- 服务端:定位受影响模块 + 失效缓存 + 发送消息
- 浏览器:重新请求少量模块 + 执行框架运行时的替换逻辑
这也是为什么 Vite 的 HMR 速度通常对项目规模不太敏感:更新成本更接近“与变更相关的模块数量”,而不是“整个项目模块数量”。
3. 三个核心组件:文件监听、模块图、HMR 通道
3.1 文件监听:从“改了什么”开始
Vite 需要第一时间知道“哪个文件变了”。开发服务器会监听项目文件(常见实现是基于 chokidar 的跨平台 FS watch),拿到变更事件后进入 HMR 流程。
3.2 模块图(Module Graph):从“文件”映射到“模块依赖关系”
HMR 的关键不在于“知道文件变了”,而在于“知道谁会被影响”。
Vite 在内存中维护一张模块依赖图(可以理解为有向图):
- 节点:一个模块(URL / 文件路径 / 转换结果缓存等)
- 边:
import关系 - 额外关系:谁导入了我(importers),我导入了谁(importedModules)
当 A 变更时,Vite 会沿着 A -> importers 方向向上回溯,寻找更新边界。
3.3 HMR 通道:WebSocket + 浏览器 HMR Client
HMR 要做到“改完立刻生效”,需要一条从服务端到浏览器的实时通道:
- 服务端:在 Dev Server 内维护 WebSocket 连接
- 浏览器:在页面里注入 HMR 客户端(通常来自
@vite/client)
这条通道只负责“通知发生了什么变化”,真正的新代码仍然走 HTTP 请求按需拉取。
4. 更新边界(HMR Boundary):能否局部替换的判定
4.1 决策规则:向上找,直到有人“接得住”
当某个模块变更,Vite 会在模块图里向上查找 importers,直到出现以下情况之一:
- 找到模块声明了
import.meta.hot.accept(...)(或框架插件自动注入了可接受更新的逻辑)→ 可以局部替换 - 一直找到入口仍无人接受 → full reload
4.2 import.meta.hot:模块的“可热更新声明”
Vite 在 ESM 模块里提供 import.meta.hot,让模块能描述:
- 我是否接受更新(accept)
- 替换前如何清理副作用(dispose)
- 如何跨更新保存少量状态(data)
if (import.meta.hot) {
import.meta.hot.accept((next) => {
// 用 next 导出的新实现去替换旧实现
})
import.meta.hot.dispose(() => {
// 清理定时器、事件监听、全局副作用等
})
}
模块热替换不是“重新执行一遍就完了”。如果旧模块留下了副作用(监听器、定时器、订阅、单例缓存),不清理就会出现“越改越卡”“重复绑定”的问题。
5. 浏览器侧如何拿到新代码:动态 import + 时间戳绕缓存
ESM 模块在浏览器里是有缓存语义的:同一个 URL 通常只会加载一次。
因此 HMR 客户端在重新加载模块时,会把请求 URL 变成“新 URL”,常见做法是追加时间戳参数:
- 旧:
/src/foo.ts - 新:
/src/foo.ts?t=1700000000000
这样浏览器会发起新请求,拿到最新转换后的代码。
6. 为什么 CSS 更新几乎总能成功?
CSS 是 HMR 的“优等生”,原因是它天然可替换:
- CSS 变更不需要重新执行 JS 逻辑
- 只要替换对应的
<style>/<link>内容即可生效
所以你经常看到:
- 改样式:几乎永远是 HMR(无刷新)
- 改 JS 逻辑:取决于是否存在边界、是否能安全替换
7. 框架层的“魔法”:Vue / React 为何能保状态?
一般业务代码很少手写 import.meta.hot.accept,因为框架插件已经帮你做了两件事:
- 把模块变更映射为“组件级别的替换”(而不是粗暴地整页刷新)
- 尽量把替换限制在最小边界,减少状态丢失
典型表现:
- Vue SFC:模板/样式/脚本变更会走不同的热更新路径,尽量保留组件状态
- React:Fast Refresh 会以“组件边界”为单位替换,并尽量保留 Hooks 状态
当变更触碰到了框架判定的“不可安全替换点”(例如导出签名变化、模块副作用不透明、边界不成立),框架会选择退化策略:重挂载组件,甚至触发 full reload。
8. 典型题 & 标准答法
8.1 为什么 Vite 的 HMR 速度通常不随项目变大而明显变慢?
答题要点:
- 开发态不构建 bundle,更新不需要重新打包 chunk
- 通过模块图精确定位影响范围
- 浏览器按需重新请求变更模块(少量请求)
8.2 什么情况下会从 HMR 退化成整页刷新?
答题要点:
- 沿着 importers 链路找不到 accept 边界
- 发生“不可安全替换”的变更(由框架运行时判定)
- 入口模块或 HTML 级别资源变更(通常需要 full reload)
8.3 import.meta.hot.accept 是“接受谁的更新”?
答题要点:
- 可以接受“自身更新”(模块自己变了)
- 也可以接受“依赖更新”(指定依赖变了触发回调)
- 不写 accept 通常意味着:我接不住更新,继续向上找边界
9. 常见坑(工程视角)
- 模块级副作用没清理:事件监听/定时器/订阅累积,越改越慢 → 用
dispose清理 - 把状态放在模块单例里:HMR 时模块实例可能被替换,状态表现不符合预期 → 状态放到框架状态里或用 HMR data 持久化
- “为什么改了 node_modules 不生效?”:依赖通常走预构建与强缓存,很多变更需要重启 Dev Server 或清理缓存
10. 速记要点(可背)
- 触发:文件变更
- 定位:模块图回溯 importers
- 边界:
accept/框架边界成立则局部替换 - 传输:WS 只发通知,代码走 HTTP 拉取
- 绕缓存:
?t=timestamp让浏览器重新请求模块 - 兜底:无边界或不安全 → full reload