热更新原理

从整体角度来看,vite 热更新主要分为三步

  1. 创建模块依赖图:服务启动时创建 ModuleGraph 实例,执行 transform 钩子时创建 ModuleNode 实例,记录模块间的依赖关系

  2. 服务端收集更新模块:服务启动时通过 chokidar 创建监听器,当文件发生变化时收集需要热更新的模块,将需要更新的模块信息通过 websocket 发送给客户端

  3. 客户端派发更新:服务器启动时会在 index.html 注入一段客户端代码,创建一个 websocket 服务监听服务端端发送的热更新信息,在收到服务端的信息后根据模块依赖关系进行模块热更新


  1. 创建模块依赖图

在 vite 中,主要通过 ModuleGraph 和 ModuleNode 来建立各模块依赖关系,ModuleGraph 记录模块及模块的所有依赖,ModuleNode 记录模块节点具体信息

模块依赖图在项目启动时通过 ModuleGraph 类创建一个实例

const moduleGraph: ModuleGraph = new ModuleGraph((url, ssr) =>
  container.resolveId(url, undefined, { ssr })
);

ModuleGraph 主要通过三个 Map 和一个 Set 来记录模块信息,包括

  • urlToModuleMap:原始请求 url 到模块节点的映射,如 /src/index.tsx(vite 中的每个模块 url 是唯一的)

  • idToModuleMap:模块 id 到模块节点的映射,id 是原始请求 url 经过 resolveId 钩子解析后的结果

  • fileToModulesMap:文件到模块节点的映射,由于单文件可能包含多个模块,如 .vue 文件,因此 Map 的 value 值为一个集合

  • safeModulesPath:记录被认为是“安全”的模块路径,安全路径不需要模块转换和处理

// 目录:packages/vite/src/node/server/moduleGraph.ts
export class ModuleGraph {
  urlToModuleMap = new Map<string, ModuleNode>()
  idToModuleMap = new Map<string, ModuleNode>()
  fileToModulesMap = new Map<string, Set<ModuleNode>>()
  safeModulesPath = new Set<string>()
}

ModuleGraph 三个 map 中存储的就是 ModuleNode 模块节点的信息,ModuleNode 中记录了三个和热更新相关的重要属性

  • importers:当前模块被哪些模块引用

  • clientImportedModules:当前模块依赖的其他模块

  • acceptedHmrDeps:其他模块对当前模块的依赖关系,发生热更新时,根据 acceptedHmrDeps 记录的信息通知其他模块信息热更新

export class ModuleNode {
  // 原始请求 url
  url: string
  // 文件绝对路径 + query
  id: string | null = null
  // 文件绝对路径
  file: string | null = null
  type: 'js' | 'css'
  info?: ModuleInfo
  // resolveId 钩子返回结构的元数据
  meta?: Record<string, any>
  // 重要:当前模块被哪些模块引用
  importers = new Set<ModuleNode>()
  // 重要:当前模块依赖的其他模块
  clientImportedModules = new Set<ModuleNode>()
  // 接收热更新的模块
  acceptedHmrDeps = new Set<ModuleNode>()
  acceptedHmrExports: Set<string> | null = null
  importedBindings: Map<string, Set<string>> | null = null
  // 是否为 接受自身模块更新
  isSelfAccepting?: boolean
  // 经过 transform 钩子编译后的结果
  transformResult: TransformResult | null = null
  // 上一次热更新时间戳
  lastHMRTimestamp = 0
  lastInvalidationTimestamp = 0

  constructor(url: string, setIsSelfAccepting = true) {
    this.url = url
    this.type = isDirectCSSRequest(url) ? 'css' : 'js'
    if (setIsSelfAccepting) {
      this.isSelfAccepting = false
    }
  }
}

服务端收集更新模块

在服务启动阶段,使用 chokidar 的 watch 方法创建文件监听器,监听文件的修改、新增、删除操作

const watcher = chokidar.watch(
[root, ...config.configFileDependencies, config.envDir],
resolvedWatchOptions,
) as FSWatcher

当文件修改时,有三个执行步骤

  • 获取到标准的文件路径

  • 通过 moduleGraph 实例的 onFileChange 方法移除文件缓存信息

  • 执行热更新方法 onHMRUpdate

// 监听文件修改操作
watcher.on("change", async (file) => {
  // 标准化文件路径
  file = normalizePath(file);
  // 移除文件缓存信息
  moduleGraph.onFileChange(file);
  // 执行热更新方法
  await onHMRUpdate(file, false);
});

对于文件的新增和删除,使用的同一个方法,执行步骤和文件修改类似,只是第二步的方法有所不同,但本质上都是使用 moduleGraph 的 onFileChange 方法移除文件缓存信息,再执行热更新方法 onHMRUpdate

// 监听文件新增和删除操作
const onFileAddUnlink = async (file: string) => {
  // 标准化文件路径
  file = normalizePath(file);
  // 处理新增和修改文件操作,本质也是移除文件缓存信息
  await handleFileAddUnlink(file, server);
  // 执行热更新方法
  await onHMRUpdate(file, true);
};

// 监听文件新增
watcher.on("add", onFileAddUnlink);
// 监听文件删除
watcher.on("unlink", onFileAddUnlink);
  1. 在服务启动阶段,会通过 chokidar 的 watch 方法方法创建一个文件监听器,当文件发生修改、新增和删除操作时,执行热更新操作

  2. 热更新操作前会调用 moduleGraph 实例的 onFileChange 方法,清理文件的缓存信息

  3. 通过 updateModules 执行收集需要热更新的模块,通过 websocket 向客户端发送需要热更新的模块

客户端派发更新

script 脚本 /@vite/client 会向客户端注入一段默认的代码,代码中执行的 setupWebSocket 方法会创建一个 websocket 服务用于监听服务端发送的热更新信息,接收到的信息会通过 handleMessage 方法处理

function setupWebSocket(
  protocol: string,
  hostAndPath: string,
  onCloseWithoutOpen?: () => void
) {
  const socket = new WebSocket(`${protocol}://${hostAndPath}`, "vite-hmr");
  let isOpened = false;

  // 开启事件
  socket.addEventListener(
    "open",
    () => {
      isOpened = true;
      notifyListeners("vite:ws:connect", { webSocket: socket });
    },
    { once: true }
  );

  socket.addEventListener("message", async ({ data }) => {
    // 接收并处理服务端的热更新信息
    handleMessage(JSON.parse(data));
  });

  return socket;
}

最后更新于

这有帮助吗?