引言
HMR(Hot Module Replacement,热模块替换)是 Vite 开发体验的灵魂——改一行代码,浏览器不刷新就更新。但它并非魔法:底层是「模块图 + WebSocket + 边界 accept」。本文从原理讲透 HMR:先拆解一次热更新的完整链路(改文件 → 依赖图计算 → 增量更新 → 边界执行),再讲框架如何自动 accept、手写 import.meta.hot 自定义热更新、插件侧实现 HMR,最后给出 HMR 失效排查与性能优化,让你从「用 HMR」进阶到「掌控 HMR」。
前置:/vite-config-guide/(dev server 配置)、/vite-plugin-development/(插件钩子)。WebSocket 原理见 [[network]]。
目录
- 1. HMR 是什么:一次热更新的全链路
- 2. 模块图与依赖链:HMR 的基础
- 3. 热更新边界:accept 机制
- 4. 框架的自动 accept:Vue/React 插件如何工作
- 5. 自定义 HMR:import.meta.hot API 实战
- 6. 插件实现 HMR:自定义模块热更新
- 7. HMR 失效排查:为什么改了不生效
- 8. HMR 性能优化与大规模项目
- 9. HMR 与生产构建的关系
- 10. 速查表
- 延伸阅读
1. HMR 是什么:一次热更新的全链路
改一个组件,发生什么?
1. 你保存文件 → 文件系统变更
2. Vite 开发服务器检测到变更 → 定位受影响的模块
3. 只重新转换「该模块」→ 生成新模块内容(esbuild 增量)
4. 通过 WebSocket 推送更新包到浏览器
5. 浏览器按模块边界 accept → 执行更新回调(替换组件实例/样式)
6. 其他模块依赖关系经 HMR 图传播,必要时级联更新
HMR vs 全量刷新:
| 维度 | HMR | 全量刷新(full reload) |
|---|---|---|
| 状态 | 组件状态/路由/滚动保留 | 全部丢失 |
| 速度 | 毫秒级增量 | 重新加载全部模块 |
| 适用 | 组件/样式/局部逻辑 | 模块边界外/破坏性变更 |
| 体验 | 顺滑 | 跳变 |
心智:HMR = 「模块级的热替换」,比浏览器刷新更精准——保留运行时状态,只在模块边界内做外科手术。
2. 模块图与依赖链:HMR 的基础
Vite 开发期把项目建模为「模块图」(module graph)——节点是模块,边是 import 关系:
main.ts
└─ import App.vue
├─ import './style.css'
└─ import { useStore } from './store'
└─ import { db } from './db'
当 App.vue 改变:Vite 以该模块为中心,沿反向依赖找「接受者」(acceptor)。
反向依赖:谁 import 了 App.vue?→ main.ts(未 accept)
正向边界:App.vue 自身 accept?→ 是(Vue 插件注入)→ 热更新成功
模块图对 HMR 的意义:
| 概念 | 说明 |
|---|---|
| 模块节点 | 每个文件一个节点 |
| 反向依赖 | 谁引用了它(决定传播范围) |
| 更新边界 | 最内层 accept 的模块 |
| 失效路径 | 无 accept 则逐级向上直到触发 reload |
记忆:HMR 是「沿模块图传播直到找到一个 accept 边界」——找不到就回退全量刷新。
3. 热更新边界:accept 机制
核心 API:import.meta.hot.accept——声明「我能接受自身更新」:
// demo.js
export const count = 0
if (import.meta.hot) {
import.meta.hot.accept() // 接受自身热更新
}
接受依赖更新(依赖变了也热更,但保留本模块状态):
import { heavyFn } from './heavy.js'
export function render() { return heavyFn() }
if (import.meta.hot) {
// 只接受 heavy.js 的更新,本模块不重跑
import.meta.hot.accept('./heavy.js', (newMod) => {
console.log('heavy.js 更新,跳过重渲染')
})
}
边界类型:
| 类型 | 写法 | 行为 |
|---|---|---|
| 接受自身 | accept() | 自身更新时热更 |
| 接受依赖 | accept(dep, cb) | 依赖更新触发回调 |
| 接受一批 | accept(['a','b'], cb) | 批量 |
| 拒绝热更 | import.meta.hot.decline() | 强制走 reload |
记忆:accept 就是「我认领更新」的声明——谁 accept,更新就在谁那儿停下并执行回调。
4. 框架的自动 accept:Vue/React 插件如何工作
你几乎不用手写 accept——因为框架插件自动注入了:
// @vitejs/plugin-vue 内部(简化示意)
export default {
transform(code, id) {
// 把 .vue 的 script 部分重写,注入 HMR 边界
if (id.endsWith('.vue')) {
return code + `
import { createHotContext as __vite__createHotContext } from "/@vite/client"
import.meta.hot = __vite__createHotContext("${id}")
// 组件级 HMR:只重渲染当前组件,保留兄弟组件状态
import.meta.hot.accept(({ default: updated }) => {
__VUE_HMR_RUNTIME__.reload(component, updated)
})
`
}
},
}
Vue 插件 HMR 的「颗粒度」:
| 变更 | HMR 行为 |
|---|---|
<template> | 只重渲染该组件(快) |
<script> setup 状态 | 重渲染组件(状态重置) |
<style> | CSS 热替换(最快,不重渲染) |
| script 非 setup | 可能整组件重挂 |
React 插件(@vitejs/plugin-react):利用 react-refresh,保留 Hook 状态只重渲染组件函数。
心智:框架插件把 HMR 边界「注到组件粒度」——所以改模板是秒级且不丢状态。
5. 自定义 HMR:import.meta.hot API 实战
完整 API 一览:
if (import.meta.hot) {
// 接受自身更新,可拿到新模块
import.meta.hot.accept((newMod) => {
// 用新模块替换旧实例
})
// 监听模块被弃用(自身将被替换)
import.meta.hot.dispose(() => {
// 清理副作用:定时器/事件/全局注册
clearInterval(timer)
})
// 模块被移除时
import.meta.hot.prune(() => {
cleanup()
})
// 触发自定义更新(由插件 handleHotUpdate 响应)
import.meta.hot.send('my:custom-event', { payload: 1 })
// 模块被替换前
import.meta.hot.invalidate() // 使失效,向上传播
}
实战:一个带副作用的计数器模块:
// src/counter.js
let count = 0
const timer = setInterval(() => {
count++
console.log('count:', count)
}, 1000)
export function getCount() { return count }
if (import.meta.hot) {
import.meta.hot.dispose(() => clearInterval(timer)) // 换新前清理旧定时器
import.meta.hot.accept() // 接受更新
}
铁律:热更替换模块时,旧模块的副作用必须 dispose——否则定时器/监听器泄漏叠加,越热更越卡。
6. 插件实现 HMR:自定义模块热更新
插件通过 handleHotUpdate 钩子自定义 HMR 行为:
// vite.config.ts —— 自定义文件类型的热更新
export default {
plugins: [
{
name: 'my-json-hmr',
handleHotUpdate(ctx) {
// ctx.file 是变更的文件,ctx.modules 是受影响的模块
if (ctx.file.endsWith('.json')) {
// 定制更新内容:只通知客户端,不重载
ctx.server.ws.send({
type: 'custom',
event: 'my-json-updated',
data: { file: ctx.file },
})
// 返回空数组 → 阻止默认 HMR 传播
return []
}
},
},
],
}
客户端监听自定义事件:
import.meta.hot?.on('my-json-updated', (data) => {
console.log('JSON 更新:', data.file)
// 自己决定如何刷新 UI
})
插件实现虚拟模块 HMR 的完整模式(参考 /vite-plugin-development/):
export default {
name: 'virtual-config-hmr',
resolveId(id) {
if (id === 'virtual:config') return '\0virtual:config'
},
load(id) {
if (id === '\0virtual:config') return `export default ${JSON.stringify(readConfig())}`
},
handleHotUpdate(ctx) {
if (ctx.file === CONFIG_PATH) {
// 失效虚拟模块并推送更新
const mod = ctx.server.moduleGraph.getModuleById('\0virtual:config')
if (mod) ctx.server.reloadModule(mod)
}
},
}
记忆:插件的 handleHotUpdate 是 HMR 的「总开关」——想定制虚拟模块/特殊文件的更新,就在这里拦截。
7. HMR 失效排查:为什么改了不生效
最常见的「改了不热更」场景与解法:
| 现象 | 原因 | 解法 |
|---|---|---|
| 改组件整页刷新 | 该模块无 accept(插件未注入) | 检查是否绕过了框架插件 |
| 状态被重置 | accept 边界过大(整模块重跑) | 缩小 accept 到组件粒度 |
| 改了不更新 | 缓存失效 / 未触达 | rm -rf node_modules/.vite |
| WebSocket 断连 | 代理/HTTPS 未配 | server.hmr 配置 host/端口 |
修改 .env 不生效 | env 是构建期注入 | 重启 dev server |
| Monorepo 链接包不更新 | 未预构建/未监听到 | 配 server.watch + optimizeDeps.include |
诊断三板斧:
1. 看终端:Vite 打印「hmr update」还是「full reload」
2. 看浏览器 console:有无 hmr 报错/边界警告
3. 手动触发:改后再加一行 console,确认模块是否重跑
WebSocket 配置(代理/防火墙场景):
export default {
server: {
hmr: {
host: 'localhost',
protocol: 'ws',
port: 5173,
// 或通过 overlay 配置代理: server.hmr.clientPort
},
},
}
记忆:HMR 失效 90% 是「边界没 accept」或「状态被不必要重置」——先看终端打的是 update 还是 reload。
8. HMR 性能优化与大规模项目
大规模项目 HMR 变慢的核心原因:模块图太大 + 依赖重新预构建:
| 优化手段 | 做法 | 效果 |
|---|---|---|
| 拆入口 | 路由级懒加载,缩小热更范围 | 减少每次传播的模块 |
| 控制 accept 粒度 | 组件级 accept,别全局状态热更 | 减少重渲染 |
| 依赖预构建 | optimizeDeps.include 排除大依赖 | 避免反复 esbuild |
| 缓存 | node_modules/.vite 保留 | 秒级重启 |
| 减少 web worker 重载 | 独立 worker 模块 | 避免主线程重跑 |
| Server Side dev | 移复杂逻辑到服务端 | 前端轻量化 |
测量 HMR 耗时:
# dev 时终端开启 debug 日志
DEBUG=vite:hmr npm run dev
# 观察每次 hmr 的传播模块数与耗时
多 App / 独立模块联邦:用 server.hmr 支持多个 dev server 共享热更(微前端场景)。
记忆:HMR 性能 = 模块图规模 × accept 粒度——懒加载减小范围、组件级 accept 减少重渲染,是两大杠杆。
9. HMR 与生产构建的关系
HMR 只在开发期存在,生产构建完全移除:
开发:模块图 + WebSocket + import.meta.hot → 增量热更
生产:Rollup 打包 → 静态资源 → 无 HMR(靠页面加载)
所以:
| 维度 | 开发 | 生产 |
|---|---|---|
| 模块形态 | 原生 ESM 源码 | 打包产物 |
| 更新机制 | HMR | 无(重新加载) |
| import.meta.hot | 存在 | undefined(需判空) |
| 代码体积 | 含 HMR 代码 | 摇树移除 |
安全写法:所有 HMR 代码必须包在 if (import.meta.hot) 内——否则生产构建报错或产出脏代码。
记忆:HMR 是纯开发期能力,生产构建会摇树掉所有
import.meta.hot分支——判空是写 HMR 代码的底线。
10. 速查表
| 需求 | 做法 |
|---|---|
| 接受自身更新 | import.meta.hot.accept() |
| 接受依赖更新 | accept('./dep.js', cb) |
| 清理副作用 | dispose(() => ...) |
| 模块移除清理 | prune(() => ...) |
| 插件定制 HMR | handleHotUpdate(ctx) |
| 推送自定义事件 | ws.send({ type: 'custom' }) |
| 客户端监听 | import.meta.hot.on(...) |
| 失效传播 | invalidate() |
| 拒绝热更 | decline() |
| 排查失效 | 看终端 update/reload + 清 .vite 缓存 |
一句话记忆:HMR = 改文件 → 模块图增量 → WebSocket 推送 → accept 边界执行回调;框架插件注入组件级 accept 免手动,副作用必须 dispose;失效先看 update/reload,瓶颈靠懒加载与粒度。
延伸阅读
- /vite-plugin-development/ — 插件钩子与 handleHotUpdate
- /vite-config-guide/ — server.hmr 与 dev server 配置
- /vite-build-optimization/ — 生产构建与 HMR 移除
- [[network]] — WebSocket 协议基础
- [[frontend]] — 前端工程化全景
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。