做前端开发时,你在IDE里改了某个文件,保存后会发现浏览器那边立刻更新了你修改的部分,这就是HMR。
HMR(Hot Module Replacement,热模块替换)让开发者修改代码后,只更新发生变化的模块,并尽可能保留页面状态。它不是“重新刷新页面”的别名,而是一套由文件监听、依赖图分析、增量编译、消息推送和运行时替换共同组成的开发时基础设施。
传统开发流程通常是:修改文件 → 重新构建 → 浏览器刷新。刷新虽然简单,但会带来三个问题:
HMR 的目标可以概括为:
找到变化的模块,只替换它及其必要的依赖边界;如果无法安全替换,再退化为整页刷新。
HMR 的本质不是某个框架 API,而是“构建时依赖图”与“浏览器端模块运行时”之间的增量同步。
flowchart TD
A[源代码文件] --> B[文件监听器]
B --> C[增量编译器]
C --> D[模块依赖图]
D --> E[变更模块与更新边界]
E --> F[开发服务器]
F <-->|WebSocket / SSE| G[浏览器 HMR Runtime]
G --> H[模块缓存与依赖关系]
G --> I[框架运行时 / 组件状态]
其中有两个关键角色:
hash、update、error、full-reload 等消息;下面以“修改一个 React/Vue 组件”为例,拆开整个链路。
sequenceDiagram
participant Dev as 开发者
participant FS as 文件系统
participant S as Dev Server
participant C as 编译器/插件
participant B as 浏览器 Runtime
participant UI as 框架组件树
Dev->>FS: 保存 Button.tsx
FS->>S: change 事件
S->>C: 重新编译受影响模块
C-->>S: 新模块代码 + hash + update 元信息
S-->>B: WebSocket: update
B->>S: 请求新模块
S-->>B: 返回新模块 / JS / CSS
B->>B: 执行 dispose,保存旧模块状态
B->>B: 替换模块缓存并执行 accept
B->>UI: 通知框架重新渲染
UI-->>Dev: 页面局部更新,状态尽量保留
开发服务器通常使用 chokidar、原生 fs.watch、Watchman 或操作系统文件事件 API。监听器发现文件变化后,不会立即把整个项目推倒重来,而是把文件路径交给编译器和依赖图。
实际项目中常见的额外处理包括:
node_modules、构建产物等目录;编译器读取变化模块,重新执行转译、语法分析、插件处理和资源生成。现代工具会缓存 AST、loader 结果或依赖解析结果,尽量只重做必要部分。
例如:
// 修改前
export default function Button() {
return <button>保存</button>
}
// 修改后:通常只需要重新生成这个模块
export default function Button() {
return <button className="primary">保存</button>
}
工具会沿依赖图向上查找“谁能够接收这个更新”。这就是 更新边界(update boundary)。
flowchart TB
App[App] --> Page[Page]
Page --> Button[Button 发生变化]
Button --> CSS[button.css]
Button -.->|向上查找 accept| Page
Page -.->|没有 accept,继续向上| App
Button --> Boundary[找到可接受边界]
Boundary --> Result[只替换边界内模块]
如果模块本身或某个父模块注册了 accept,更新就可以局部应用;如果一直找到根仍没有可接受边界,工具通常执行 full reload。这个回退机制是 HMR 稳定性的关键。
浏览器通常预先建立一条长连接。服务器发送的消息可能类似:
{
"type": "update",
"updates": [
{
"path": "/src/components/Button.tsx",
"acceptedPath": "/src/components/Button.tsx",
"timestamp": 1720000000000
}
]
}
不同工具的字段、请求 URL 和协议细节并不完全相同,但信息通常包含:更新类型、模块路径、版本标识、时间戳,以及是否需要整页刷新。
收到通知后,浏览器端 runtime 一般会:
伪代码如下:
socket.onmessage = async (message) => {
const update = JSON.parse(message.data)
if (update.type === 'full-reload') {
location.reload()
return
}
if (update.type === 'update') {
const nextModule = await fetchAndEvaluate(update.path)
hotData[update.path] = nextModule
runAcceptCallbacks(update.path, nextModule)
}
}
这里的 fetchAndEvaluate、模块缓存和路径解析都由具体工具实现;示例只用于说明流程。
一个模块可能需要在被替换前清理副作用,例如定时器、事件监听器或 WebSocket 连接:
if (import.meta.hot) {
import.meta.hot.dispose((data) => {
data.timerId = timerId
clearInterval(timerId)
})
import.meta.hot.accept((newModule) => {
render(newModule.default)
})
}
dispose 负责旧模块卸载,accept 负责接收新模块。框架插件通常会自动注入这些逻辑,所以业务代码未必需要手写。
“保留状态”不是浏览器自动完成的,而是框架运行时主动设计的结果。
以组件框架为例,热更新可能只替换组件的渲染函数,而保留组件实例、DOM 节点和状态容器:
flowchart LR
Old[旧组件定义] -->|提取可更新部分| Patch[更新组件定义]
State[组件实例状态] --> Patch
DOM[已有 DOM / Fiber / VNode] --> Patch
Patch --> New[新渲染逻辑 + 旧状态]
但状态保留有边界:
因此,HMR 的准确表述是“尽可能保留状态的局部更新”,而不是“任何修改都不刷新”。
Webpack 在构建时生成模块图,并通过 dev server 的 HMR runtime 传递更新。模块可以使用 module.hot.accept() 和 module.hot.dispose() 注册边界与清理逻辑。
特点:
Vite 开发时通常直接提供原生 ESM,浏览器按需请求模块。修改文件后,Vite 根据模块图发送更新消息,并利用 import.meta.hot 完成替换。
特点:
Fast Refresh 是 React 生态的组件级热更新方案,重点不只是“替换一个 JS 模块”,而是判断组件边界、重新执行组件代码,并尽量保留 hooks 状态。
它通常要求:
Vue 的 SFC(单文件组件)编译器可以分别处理 template、script 和 style。修改 style 时往往只替换 CSS;修改 template 时可以更新渲染函数并保留实例状态;修改 script 时通常更可能重建组件实例。
这类全栈框架在 HMR 之上还要协调:
因此它们的“热更新”往往是 HMR、Fast Refresh、SSR 重渲染和路由刷新策略的组合,而不是单一协议。
可以从四个层面理解差异:
| 层面 | 传统 Webpack HMR | Vite HMR | React Fast Refresh | Vue SFC HMR |
|---|---|---|---|---|
| 模块来源 | 打包器生成的模块图 | 原生 ESM + 开发服务器转换 | JS 模块 + React 组件签名 | SFC 拆分后的多个虚拟模块 |
| 更新单位 | 模块或模块边界 | ESM 模块 / CSS 模块 | React 组件边界 | template / script / style |
| 状态保留 | 依赖 accept 回调和插件 | 依赖 import.meta.hot 与插件 | 框架运行时识别组件与 hooks | Vue 运行时区分修改类型 |
| 失败回退 | full reload | full reload 或失效模块重载 | 组件重置或整页刷新 | 组件重建或整页刷新 |
| 核心优化 | 增量重编译与缓存 | 按需 ESM 请求 | 组件级状态连续性 | SFC 子模块级更新 |
真正的本质区别不在于消息是不是 WebSocket,也不在于命令叫不叫 hot,而在于:
accept、依赖图推导还是框架签名分析;HMR 不只用于 JavaScript:
<style> 标签或更新 <link> 的内容完成,最容易做到无刷新;flowchart TD
Change[资源发生变化]
Change --> CSS{CSS?}
CSS -->|是| ReplaceStyle[替换 style/link 内容]
CSS -->|否| JS{JavaScript 模块?}
JS -->|是| Check[检查 HMR 边界与副作用]
Check -->|安全| Swap[下载并替换模块]
Check -->|不安全| Reload[整页刷新]
JS -->|否| Asset[更新资源 URL / 重新请求]
npm run dev 之后,工具到底做了什么?我们看到的往往只是终端上一行 ready in xx ms,但在这行字打印出来之前,工具已经做了不少事情。
以一个典型的现代前端工具(不论 Webpack 还是 Vite)为例,大致会依次发生:
vite.config.* 或 webpack.config.*,合并命令行参数、环境变量、插件列表,得到最终生效的配置对象。webpack-dev-server)在指定端口监听请求,并挂载各类中间件(静态资源、代理、HTML 处理等)。node_modules 里的第三方依赖做一次"预构建"(用 esbuild 把 CommonJS 依赖转换成 ESM,并做合并,减少后续请求数量),应用自身的源码则留到浏览器实际请求时才转换。index.html 里插入一段 HMR 客户端运行时代码,这段代码负责监听 WebSocket 消息、按需拉取新模块、执行替换逻辑。这六步做完,终端才会打印出那行熟悉的 ready。之后,整个系统就进入"待命"状态——直到你保存下一次修改,触发第三节里描述的那个完整循环。
检查模块是否存在可接受边界、框架插件是否启用、入口模块是否被修改,以及导出形态是否破坏了框架的组件识别规则。
优先检查 WebSocket 代理配置、端口转发、HTTPS 与 WSS 是否匹配、反向代理是否支持 Upgrade,以及新模块 URL 是否返回了正确的 JavaScript。
观察修改的是组件实现、组件签名、store、路由还是入口文件。状态丢失通常不是 bug,而是运行时无法证明替换安全时的保守选择。
文件事件可能没有正确透传,需要启用 polling;同时应降低监听范围,并避免把依赖目录和生成目录加入 watch。
HMR 可以抽象成一条链路:
文件变化 → 增量编译 → 依赖图分析 → 开发服务器推送 → 浏览器下载新模块 → 执行 dispose/accept → 框架局部重渲染 → 必要时整页刷新。
不同工具的差异,主要来自模块系统、更新边界和状态管理方式:Webpack 更偏向打包器模块替换,Vite 更偏向原生 ESM 的按需更新,React Fast Refresh 和 Vue HMR 则进一步把“模块更新”提升为“组件级更新”。
理解了这几个层次,就能解释大多数热更新现象:为什么 CSS 修改几乎瞬间生效,为什么组件状态有时保留、有时重置,以及为什么某些修改最终必须刷新页面。
记住: HMR 不是让页面永远不刷新,而是在安全边界内,用最小代价把新代码接入正在运行的应用。
No comments yet. Be the first!