跳到主要内容

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 利用两个关键事实:

  1. 浏览器会为每个 ESM 模块发起独立请求(按 import 链路加载)
  2. 模块更新时不需要重建 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,直到出现以下情况之一:

  1. 找到模块声明了 import.meta.hot.accept(...)(或框架插件自动注入了可接受更新的逻辑)→ 可以局部替换
  2. 一直找到入口仍无人接受 → 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(() => {
// 清理定时器、事件监听、全局副作用等
})
}
为什么要 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,因为框架插件已经帮你做了两件事:

  1. 把模块变更映射为“组件级别的替换”(而不是粗暴地整页刷新)
  2. 尽量把替换限制在最小边界,减少状态丢失

典型表现:

  • 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