Chapter 06

HMR 热模块替换深入

理解 Vite HMR 的工作机制,掌握 import.meta.hot API,必要时为自己的模块写自定义热更新逻辑。

6.1 HMR 是什么

Hot Module Replacement(热模块替换)指"代码文件改动后,不重新加载整个页面,只替换变化的那个模块"——这样表单输入、滚动位置、应用状态都能保留,开发体验飞跃。

Vite 的 HMR 链路:

  1. 你保存 ./Button.tsx
  2. Vite dev server 用 chokidar 监听到文件变化
  3. 服务器通过 WebSocket 发 update 消息:"/src/Button.tsx 变了"
  4. 浏览器的 HMR 客户端接收,向服务器请求新版本的 Button.tsx
  5. 新模块被注入当前运行时,调用 import.meta.hot.accept 回调

6.2 import.meta.hot API

Vite 在每个模块上挂一个 import.meta.hot 对象(生产构建时为 undefined):

if (import.meta.hot) {
  // 只在 dev 生效,生产自动 tree-shake
}

核心方法

accept() / accept(cb) / accept(deps, cb)
声明"我能接受自己/依赖的更新"——不调这个,修改会退化为整页刷新。
dispose(cb)
模块即将被替换前的清理回调,用来拆除副作用(定时器、事件监听、连接)。
invalidate(msg)
放弃 HMR,触发整页刷新(当模块发现无法热更时调用)。
data
跨热替换持久化的数据对象——旧模块往里写,新模块能读。
decline()
声明"这个模块不参与 HMR",改它就整页刷。

6.3 最常见用法:框架自动 HMR

大多数时候你不用手写——框架插件已处理:

所以业务代码基本不需要碰 import.meta.hot——只有写"框架无关的状态模块"或"自定义文件格式插件"时才会用到。

6.4 手写一个简单 HMR 示例

// src/counter.ts
let count = 0;

export function setup(el: HTMLButtonElement) {
  const render = () => (el.innerHTML = `count is ${count}`);
  el.addEventListener('click', () => { count++; render(); });
  render();
}

if (import.meta.hot) {
  import.meta.hot.accept((newModule) => {
    // newModule 是刚刚更新后的模块对象(若更新失败为 undefined)
    if (newModule) newModule.setup(document.querySelector('#counter')!);
  });
}

6.5 accept 的三种形态

① 自更新(self-accept)

import.meta.hot.accept((newModule) => {
  // 自己变了时执行
});

不传参只写 accept() 表示"我接受更新但不做任何处理"——用于纯样式/纯数据模块。

② 接受依赖更新

import.meta.hot.accept('./dep.ts', (newDep) => {
  // dep.ts 变了,newDep 是它的新导出
});

③ 接受多个依赖

import.meta.hot.accept(['./a.ts', './b.ts'], ([newA, newB]) => {
  // a 或 b 变了都会触发
});

6.6 dispose:清理副作用

模块被替换前,旧版本创建的定时器、事件监听、WebSocket 要清理,否则会泄漏:

const timer = setInterval(() => console.log('tick'), 1000);

if (import.meta.hot) {
  import.meta.hot.dispose(() => {
    clearInterval(timer);
  });
  import.meta.hot.accept();
}

6.7 hot.data:跨热替换存状态

想让"变量在热替换后还保留"?用 hot.data

const state = import.meta.hot?.data.state ?? { count: 0 };

render(state);

if (import.meta.hot) {
  import.meta.hot.dispose((data) => {
    data.state = state;   // 把当前状态写进 data
  });
  import.meta.hot.accept();
}

6.8 invalidate:放弃 HMR

有时候你的模块更新"合理条件下做不了"(例如类层次变了),用 invalidate 告诉 Vite 退回整页刷新:

import.meta.hot.accept((newModule) => {
  if (!newModule || !newModule.isCompatible()) {
    import.meta.hot!.invalidate('API 不兼容');
  }
});

6.9 HMR 失效的常见原因

模块没有 self-accept,也没有任何上级 accept 它
Vite 会往上查找"谁接受了这个模块的更新"。如果一直找到顶都没有,只能整页刷新。React 组件默认有 Fast Refresh,Vue SFC 默认有 HMR block,一般不会发生。
使用了默认导出但没给组件命名(React)
React Fast Refresh 靠组件名识别;export default () => ... 匿名组件不会热更——改成具名函数。
副作用放在了顶层
模块加载时执行的 setupStore() 每次都会重跑,可能重置状态。把它们裹进 if (!import.meta.hot || !import.meta.hot.data.inited)
文件路径或扩展名大小写不一致
macOS 大小写不敏感但 Vite 是敏感的,./Button./button 会建两套模块图。

6.10 服务端触发 HMR:hot.send

插件可以向客户端发送自定义 HMR 事件:

// 插件里
server.ws.send({ type: 'custom', event: 'config-change', data: newConfig });
// 客户端监听
import.meta.hot?.on('config-change', (data) => {
  applyConfig(data);
});

6.11 小结