前端开发··1 阅读·预计 10 分钟

Vite HMR 热更新:从模块图到边界替换的底层机制

当你修改一个 Vue 组件保存后,浏览器几乎瞬间就完成了更新——没有全量刷新,没有白屏等待,状态还保留了。这就是 Vite 的 HMR(Hot Module Replacement),但它的"快"绝不是魔法,而是精心设计的模块依赖图和边界传播算法的结果。

一、HMR 的核心问题:改了一个文件,到底该替换多少?

HMR 的本质问题是一个图搜索问题:当模块 A 发生变化时,我们需要找到最小的替换范围,使得应用状态不丢失、UI 能正确更新。

暴力方案是全量刷新——但那就失去了 HMR 的意义。Vite 的做法是:沿着模块依赖图向上传播,找到最近的 HMR Boundary,只替换边界内的模块。

二、模块依赖图的构建

2.1 Vite 的模块图数据结构

Vite 在开发模式下维护了一个 ModuleGraph 对象,这是 HMR 的核心数据结构:

// Vite 源码简化版
class ModuleGraph {
  // URL → ModuleNode 的映射
  urlToModuleMap: Map<string, ModuleNode>;
  // 文件路径 → ModuleNode 的映射(一个文件可能对应多个 URL)
  fileToModulesMap: Map<string, Set<ModuleNode>>;
  // 模块ID → ModuleNode
  idToModuleMap: Map<string, ModuleNode>;
}

class ModuleNode {
  url: string;           // 请求 URL
  id: string | null;     // 模块 ID(解析后)
  file: string | null;   // 文件绝对路径
  importers: Set<ModuleNode>;   // 谁导入了我(反向依赖)
  importedModules: Set<ModuleNode>; // 我导入了谁(正向依赖)
  acceptedHmrDeps: Set<ModuleNode>; // 该模块声明的 HMR 接受依赖
  isSelfAccepting: boolean;       // 是否自接受
  hmrTimestamp: number;           // 上次 HMR 更新时间戳
  lastHMRTimestamp: number;       // 上次 HMR 边界更新时间戳
}

关键点:importers 是反向引用。这意味着我们可以从变更模块出发,沿反向边向上找到所有受影响的父模块。

2.2 依赖图的实时构建

模块图不是预先构建的,而是按需构建。当浏览器请求一个模块时,Vite 的 transform 中间件会:

  1. 解析模块中的 import 语句
  2. 为每个 import 创建 ModuleNode 并建立双向引用
  3. 将修改后的代码中的 import 重写为 /@id/ 格式,供后续请求使用
// 浏览器实际收到的代码
import { ref } from "/@id/vue"
import Foo from "/@id/src/Foo.vue"

这种按需构建意味着:Vite 的启动速度与项目规模无关。1000 个模块的项目,启动时只处理入口文件和其直接依赖,其余模块按需加载。

三、HMR 传播算法:找到边界

3.1 什么是 HMR Boundary

HMR Boundary 是模块依赖图中一个"热更新接收点"。当一个模块发生变化时,HMR 更新传播到这个点就停止,由这个点负责处理更新逻辑。

有三种类型的 Boundary:

  • Self-Accepting:模块自身处理更新(如 Vue SFC 的 <script setup> 组件)
  • Accepting Dependencies:模块声明接受某些依赖的更新(如父组件接受子组件更新)
  • None:没有 HMR 处理器,需要向上继续传播

3.2 传播算法详解

当一个文件变化时,Vite 执行以下步骤:

文件变更 → 查找关联 ModuleNode → 从该节点开始向上传播

具体算法(简化自 Vite 源码 handleHMRUpdate):

async function propagateHMR(node: ModuleNode, traversed: Set<ModuleNode>): Promise<HMRResult> {
  // 1. 如果模块是 Self-Accepting,直接返回
  if (node.isSelfAccepting) {
    return { type: 'accepted', path: node.url, timestamp: Date.now() };
  }

  // 2. 检查导入者中是否有接受此模块更新的
  for (const importer of node.importers) {
    if (importer.acceptedHmrDeps.has(node)) {
      // 找到 HMR Boundary!
      return { type: 'accepted', path: importer.url, timestamp: Date.now() };
    }
  }

  // 3. 没有 Boundary,继续向上传播
  for (const importer of node.importers) {
    if (!traversed.has(importer)) {
      traversed.add(importer);
      const result = await propagateHMR(importer, traversed);
      if (result.type === 'accepted') return result;
    }
  }

  // 4. 传播到入口仍未找到 Boundary → Full Reload
  return { type: 'full-reload' };
}

3.3 Vue SFC 如何成为 Self-Accepting

Vue 的 @vitejs/plugin-vue 在 transform 阶段注入了 HMR 接受逻辑:

// Vue SFC 编译后注入的 HMR 代码
if (import.meta.hot) {
  import.meta.hot.accept((newModule) => {
    // 1. 获取新组件定义
    // 2. 执行组件热替换逻辑
    // 3. 触发 Vue 运行时的 rerender 或 reload
  });
}

import.meta.hot.accept() 不带参数调用 → Self-Accepting,HMR 传播到此为止。

3.4 对比 Webpack HMR

维度Vite HMRWebpack HMR
模块图构建按需,惰性启动时全量构建
传播起点变更文件对应的 ModuleNode变更 chunk 中的所有模块
传播方式反向依赖图 BFS按父子关系冒泡
更新粒度单模块 ESMChunk(可能包含多个模块)
更新协议原生 ESM import + 动态 importJSONP + require 缓存替换
典型延迟<50ms200-500ms

Webpack 的 HMR 需要在 bundle 中为每个模块注册 accept 回调,且更新以 chunk 为单位传输。Vite 的 ESM 原生方案让更新粒度精确到单个模块文件。

四、HMR 客户端:浏览器侧的更新机制

4.1 WebSocket 通信

Vite Dev Server 启动时创建 WebSocket 连接,客户端注入的 HMR runtime 监听消息:

// Vite 客户端 HMR 消息类型
type HMRPayload =
  | { type: 'update'; updates: HMRUpdate[] }
  | { type: 'full-reload'; path?: string }
  | { type: 'custom'; event: string; data: any };

interface HMRUpdate {
  type: 'js-update' | 'css-update';
  path: string;
  acceptedPath: string;
  timestamp: number;
}

4.2 JS 模块的动态替换

收到 js-update 后,客户端使用动态 import() 加载新版本模块:

async function fetchUpdate(update: HMRUpdate) {
  // 关键:时间戳查询参数破坏缓存
  const mod = await import(
    update.acceptedPath + '?t=' + update.timestamp
  );
  // 执行 accept 回调
  for (const cb of hotModules[update.acceptedPath]) {
    cb(mod);
  }
}

这个 ?t=timestamp 是关键——浏览器会缓存 ESM 模块,只有改变 URL 才能强制重新请求。这也是为什么直接修改 node_modules 中的文件时,HMR 可能不生效(因为某些代理或 CDN 会剥离查询参数)。

4.3 CSS 热更新的特殊处理

CSS 更新不需要 JS 执行,Vite 做了特殊优化:

// CSS 更新:直接替换 link 标签的 href
if (update.type === 'css-update') {
  const link = document.querySelector(`link[href*="${update.path}"]`);
  link.href = update.path + '?t=' + update.timestamp;
  // 无需模块替换,无需组件重渲染
}

CSS HMR 的延迟通常 < 10ms,因为不涉及 JS 解析和执行。

五、HMR 失效的常见场景与排查

5.1 导致 Full Reload 的典型原因

  1. 导出接口变化:模块的 export 名字或数量改变,无法安全替换
  2. 非组件模块变更:工具函数、常量文件没有 HMR accept 逻辑
  3. 循环依赖:传播算法在循环引用中找不到 Boundary
  4. 插件未实现 HMR 处理:自定义 transform 插件未正确设置 module.isSelfAccepting

5.2 调试技巧

# 启动 Vite 时开启 HMR 日志
DEBUG=vite:hmr vite dev

或在浏览器控制台:

// 监听所有 HMR 事件
import.meta.hot.on('vite:beforeUpdate', (payload) => {
  console.log('HMR update:', payload);
});
import.meta.hot.on('vite:afterUpdate', (payload) => {
  console.log('HMR applied:', payload);
});

总结

  • Vite HMR 的速度来源于按需构建的模块图和精确的边界传播算法,只替换必要的模块而非整个 chunk
  • HMR Boundary 是核心概念——Self-Accepting 模块自身处理更新,Accepting Dependencies 模块声明接收子模块更新,没有 Boundary 则传播至入口触发 Full Reload
  • ESM 原生动态 import 是技术基础,配合时间戳查询参数破坏缓存,实现单模块级别的精确更新
  • CSS 热更新走独立路径,直接替换 link 标签,延迟 < 10ms
  • HMR 失效多因导出变化、循环依赖或缺少 accept 声明,理解传播算法后排查事半功倍
0 评论

评论区

登录 后参与评论