Skip to content

Vite 中的 HMR 原理

原理

Vite 中 HMR 的原理主要是通过浏览器的 websocket 来实现的,当我们修改了代码后,Vite 会通过 websocket 通知浏览器,浏览器收到通知后,会重新请求修改的代码,然后浏览器会重新渲染页面。

实现

Vite 中 HMR 的实现主要是通过以下几个步骤来实现的:

  1. 浏览器发起请求,获取最新的代码

  2. Vite 服务器收到请求,判断是否需要 HMR

  3. 如果需要 HMR,就会给浏览器发送一个 websocket 消息,告诉浏览器需要更新代码

  4. 浏览器收到 websocket 消息后,会重新请求最新的代码

  5. Vite 服务器收到请求,返回最新的代码

  6. 浏览器收到最新的代码后,会重新渲染页面

  7. 修改index.html文件,注入HMR需要的客户端文件 client.mjs

  8. 启动 Websocket 服务器,用于与 client.mjs 进行通信

  9. 浏览器发起请求,与WS建立连接,同时获取代码

  10. 根据各个文件类型的插件提供HMR的能力, 主要是

    1. 初始化文本范围的上下文对象并赋给 import.meta.hot

transformIndexHtml

在访问 SSR 入口 html 文件的时候,会创建一个 transformIndexHtml 的函数对 html 文件进行处理,在处理的过程中,针对 HMR 方面会添加一个 @vite/client 的脚本 script ,这样页面会加载对应的 js 文件

html
<script type="module" src="/@vite/client"></script>

下面在静态资源服务器中对这个特殊的 地址 进行拦截处理(配置了一个 alias 指向 node_modules 中的 @vite/client),为什么要这样处理呢?

因为在浏览器中访问的是本地的地址,但是在 Vite 中访问的是 node_modules 中的地址,所以需要对这个特殊的地址进行拦截处理,将其指向本地的地址。这样浏览器就可以加载到 vite 中的 client 模块了。

js
const clientAlias = [
  // 内置的 修改 /@vite/client 连接指向本地的 node_modules中的 dist/client/client.mjs
  {
    find: /^\/?@vite\/client/,
    replacement: path.posix.join(FS_PREFIX, normalizePath(CLIENT_ENTRY)),
  },
];

client.mjs

在 client.mjs 模块中,其主要做了以下事件:

  1. 会创建一个 websocket 连接,用来接收服务端的通知,当服务端有更新的时候,会通过 websocket 连接通知浏览器
js
function setupWebSocket(
  protocol: string,
  hostAndPath: string,
  onCloseWithoutOpen?: () => void
) {
  // 建立 websocket 连接
  const socket = new WebSocket(`${protocol}://${hostAndPath}`, "vite-hmr");
  let isOpened = false;

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

  // Listen for messages
  socket.addEventListener("message", async ({ data }) => {
    handleMessage(JSON.parse(data));
  });

  // ping server
  socket.addEventListener("close", async ({ wasClean }) => {
    if (wasClean) return;

    if (!isOpened && onCloseWithoutOpen) {
      onCloseWithoutOpen();
      return;
    }

    notifyListeners("vite:ws:disconnect", { webSocket: socket });

    await waitForSuccessfulPing(protocol, hostAndPath);
    location.reload();
  });

  return socket;
}

核心就是 "message" 类型的消息的处理, 其通过 payload.type 将消息类型分为以下几个种类

js
export type HMRPayload =
  // ws 连接上的第一个 payload
  | ConnectedPayload
  // 客户端主动发起的更新 payload
  | UpdatePayload
  // 服务端主动发起的重新加载 payload
  | FullReloadPayload
  // 自定义事件 payload
  | CustomPayload
  // 错误事件 payload
  | ErrorPayload
  // 修剪 payload
  | PrunePayload;
  • ConnectedPayload

WS 连接成功的消息,所以我们在浏览器中就可以看到一个 conneted 成功的 console 输出,同时其通过定时器向服务端发送心跳信息,防止 ws 断联

  • UpdatePayload

这是最核心的一个消息类型,用于接受服务端通知的 HMR 更新的消息,后面具体说明

  • FullReloadPayload

  • CustomPayload

  • ErrorPayload

  • PrunePayload

  1. 提供了一个 createHotContext 全局方法

这个方法主要用来创建一个热更新的上下文,其主要作用是用来接受服务端的通知,然后根据通知来更新页面

不同类型文件提供 HMR 能力

如对于 vue 文件,其主要通过 plugin-vue 去提供编译即热更新的能力,下面我们只看有关 HMR 方面的知识

plugin-vue

下面是一个比较简单 vue 文件在浏览器中获取到的数据,我们发现其跟 vue 的内容是非常不一样的

jsx
import { createHotContext as __vite__createHotContext } from "/@vite/client";
// 热更新
import.meta.hot = __vite__createHotContext("/src/App.vue");
// 热更新组件
import HelloWorld from "/src/components/HelloWorld.vue";

const _sfc_main = {
  __name: "App",
  setup(__props, { expose: __expose }) {
    __expose();

    const __returned__ = { HelloWorld };
    Object.defineProperty(__returned__, "__isScriptSetup", {
      enumerable: false,
      value: true,
    });
    return __returned__;
  },
};
import {
  createElementVNode as _createElementVNode,
  createVNode as _createVNode,
  Fragment as _Fragment,
  openBlock as _openBlock,
  createElementBlock as _createElementBlock,
  pushScopeId as _pushScopeId,
  popScopeId as _popScopeId,
} from "/node_modules/.vite/deps/vue.js?v=0f0a3b54";

const _withScopeId = (n) => (
  _pushScopeId("data-v-7a7a37b1"), (n = n()), _popScopeId(), n
);
const _hoisted_1 = /*#__PURE__*/ _withScopeId(() =>
  /*#__PURE__*/ _createElementVNode(
    "div",
    null,
    [
      /*#__PURE__*/ _createElementVNode(
        "a",
        {
          href: "https://vitejs.dev",
          target: "_blank",
        },
        [
          /*#__PURE__*/ _createElementVNode("img", {
            src: "/vite.svg",
            class: "logo",
            alt: "Vite logo",
          }),
        ]
      ),
      /*#__PURE__*/ _createElementVNode(
        "a",
        {
          href: "https://vuejs.org/",
          target: "_blank",
        },
        [
          /*#__PURE__*/ _createElementVNode("img", {
            src: "/src/assets/vue.svg",
            class: "logo vue",
            alt: "Vue logo",
          }),
        ]
      ),
    ],
    -1 /* HOISTED */
  )
);

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
  return (
    _openBlock(),
    _createElementBlock(
      _Fragment,
      null,
      [_hoisted_1, _createVNode($setup["HelloWorld"], { msg: "Vite + Vue" })],
      64 /* STABLE_FRAGMENT */
    )
  );
}

import "/src/App.vue?vue&type=style&index=0&scoped=7a7a37b1&lang.css";

_sfc_main.__hmrId = "7a7a37b1";
typeof __VUE_HMR_RUNTIME__ !== "undefined" &&
  __VUE_HMR_RUNTIME__.createRecord(_sfc_main.__hmrId, _sfc_main);
import.meta.hot.accept((mod) => {
  if (!mod) return;
  const { default: updated, _rerender_only } = mod;
  if (_rerender_only) {
    __VUE_HMR_RUNTIME__.rerender(updated.__hmrId, updated.render);
  } else {
    __VUE_HMR_RUNTIME__.reload(updated.__hmrId, updated);
  }
});
import _export_sfc from "/@id/__x00__plugin-vue:export-helper";
export default /*#__PURE__*/ _export_sfc(_sfc_main, [
  ["render", _sfc_render],
  ["__scopeId", "data-v-7a7a37b1"],
  [
    "__file",
    "/Users/jad/Desktop/code/personal/gitee/source-reading/Vite/Examples/template-vue/src/App.vue",
  ],
]);
//# sourceMappingURL=data:application/json;

但是其有一个核心就是调用上述 client.mjs 提供的 createHotContext方法,并将当前文件路径传入

js
import { createHotContext as __vite__createHotContext } from "/@vite/client";
// 热更新
import.meta.hot = __vite__createHotContext("/src/App.vue");`

其核心就是返回一个 ViteHotContext 类型的对象,提供以下几个核心的方法

accept

import.meta.hot.accept 方法,其主要作用是用来接受服务端的通知,然后根据通知来更新页面, 如我们上述的 helloWorld 组件,其就调用了这个方法

js
import.meta.hot.accept((mod) => {
  if (!mod) return;
  const { default: updated, _rerender_only } = mod;
  if (_rerender_only) {
    __VUE_HMR_RUNTIME__.rerender(updated.__hmrId, updated.render);
  } else {
    __VUE_HMR_RUNTIME__.reload(updated.__hmrId, updated);
  }
});

这样就调用了 client.mjs 的 accept 方法,并传入了当前回调函数,在 accept 方法中其主要是执行 acceptDeps([ownerPath], ([mod]) => deps?.(mod)),下面我们看一下 这个方法,其核心就是创建一个

js
{
  id: "/src/HelloWorld.vue", //文件路径 ownerPath
  callbacks: [
    {
      deps: ["/src/HelloWorld.vue"],
      fn: () => {},    // .HelloWorld.vue 文件中 accept 函数传入的回调函数
    },
  ],
};

对象,并缓存到 hotModulesMap 中,这样在服务器端发送 update 类型的 message 的时候就可以通过 const mod = hotModulesMap.get(path) 找到当前文件的回调函数

监听文件的修改

这一步主要是在服务端,其通过 chokidar 去监听文件的修改,并返回一个 warcher 对象

js
 // 初始化文件监听器,监听以下目录:
  // 1. 项目根目录
  // 2. 配置文件依赖
  // 3. 环境变量文件目录
  const watcher = chokidar.watch(
    [root, ...config.configFileDependencies, config.envDir],
    resolvedWatchOptions,
  ) as FSWatcher

通过 watcher 对象监听 change add、unlink 等事件,当文件修改了以后就触发 watcher 的 change 事件,执行 onHMRUpdate(file, false)方法

js
watcher.on("change", async (file) => {
  // 获取修改文件的绝对地址
  file = normalizePath(file);
  // invalidate module graph cache on file change
  moduleGraph.onFileChange(file);

  await onHMRUpdate(file, false);
});

watcher.on("add", onFileAddUnlink);
watcher.on("unlink", onFileAddUnlink);

onHMRUpdate

服务器端 HMR 处理的总调度中心,其将所有的本地文件分为以下几个类型

  1. 配置文件、环境文件

对于这一类文件的修改可能需要重新服务器 和 整个页面刷新, 所以执行的是 await server.restart()

  1. vite 缓存的文件 client.mjs 文件发生修改
js
ws.send({
  type: "full-reload", // 消息类型:全量刷新
  path: "*", // 刷新所有路径
});
  1. 正常的项目文件

对于正常的文件修改,如 Vue 文件,其生成的文件不单单就是一个文件, 一方面 其将 css 文件拆分出来,单独生成一个文件,另外一方面其也依赖很多其他的模块,所以在文件发生修改的时候,需要将其依赖的文件也进行修改, 其主要分为以下几个步骤

  • 根据文件的地址找到其对应的依赖模块信息 如 src/HelloWorld.vue 其 mods 就是 ['src/HelloWorld.vue', 'src/HelloWorld.vue?vue&type=style&index=0&scoped=e17ea971&lang.css']
  • 生成 HMR 需要上下文信息 hmrContext
  • 执行所有定义了 handleHotUpdate 的插件的钩子函数
js
// 从模块依赖图中获取所有引用了该文件的模块
// 一个文件可能对应多个模块(如Vue单文件组件会生成多个模块)
const mods = moduleGraph.getModulesByFile(file);

// 检查是否有插件需要执行自定义HMR处理
// 创建HMR上下文对象,包含变更信息和工具函数
const timestamp = Date.now(); // 当前时间戳,用于标记此次更新
const hmrContext: HmrContext = {
  file, // 变更文件路径
  timestamp, // 更新时间戳
  modules: mods ? [...mods] : [], // 受影响的模块列表
  read: () => readModifiedFile(file), // 读取变更后文件内容的函数
  server, // 服务器实例
};

// 依次执行所有插件的handleHotUpdate钩子
// 插件可以通过此钩子过滤/修改受影响的模块列表
for (const hook of config.getSortedPluginHooks("handleHotUpdate")) {
  const filteredModules = await hook(hmrContext);
  // 如果插件返回了新的模块列表,则更新上下文
  if (filteredModules) {
    hmrContext.modules = filteredModules;
  }
}

在这边有一个比较重要的点就是 执行所有定义了 handleHotUpdate 的插件的钩子函数, 这样就又回到 plugin-vue 这个插件,将新的文件的编译交给对应的插件进行处理

编译新文件

plugin-vue

Vite 插件中处理 Vue 文件热更新的核心函数

该函数负责处理 Vue 文件的热更新逻辑,包括脚本、模板和样式的变更检测、模块重新加载和通知浏览器刷新。

主要流程如下

  • 读取变更后的文件内容
  • createDescriptor(file, content, options, true)解析新的 Vue 文件描述符(SFC),将 vue 文件转换成 { template , script , scriptSteup , styles , customBlocks , cssVars } 等多个 module
  • 对比旧描述符与新描述符的差异
    • 判断脚本类型是否存在变更 ( script , scriptSetup)
    • 判断模板是否存在变更(template
    • 判断样式是否存在变更(styles
  • 将所有存在变更的 modules 返回给 vite 进行处理
js
export async function handleHotUpdate(
  { file, modules, read }: HmrContext,  // HMR上下文:包含变更文件路径、模块列表和读取文件内容的方法
  options: ResolvedOptions,             // 已解析的插件选项
  customElement: boolean,               // 是否为自定义元素模式
  typeDepModules?: ModuleNode[],        // 类型依赖模块(可选)
): Promise<ModuleNode[] | void> {
  // 1. 获取文件的旧描述符(缓存的SFC解析结果)
  const prevDescriptor = getDescriptor(file, options, false, true)
  if (!prevDescriptor) {                 // 如果没有旧描述符(文件未被请求过,如异步组件)
    return                               // 直接返回,不处理热更新
  }

  // 2. 读取更新后的文件内容并创建新描述符
  const content = await read()           // 读取更新后的文件内容
  const { descriptor } = createDescriptor(file, content, options, true)  // 解析新的SFC描述符

  // 3. 初始化状态变量
  let needRerender = false               // 是否需要重新渲染标记
  const affectedModules = new Set<ModuleNode | undefined>()  // 受影响的模块集合
  const mainModule = getMainModule(modules)  // 获取主模块(Vue文件本身)
  const templateModule = modules.find((m) => /type=template/.test(m.url))  // 查找模板模块

  // 4. 处理脚本变更
  resolveScript(descriptor, options, false, customElement)  // 解析新脚本(确保AST可用)
  const scriptChanged = hasScriptChanged(prevDescriptor, descriptor)  // 检查脚本是否变更
  if (scriptChanged) {                   // 如果脚本变更
    // 添加脚本模块或主模块到受影响列表
    affectedModules.add(getScriptModule(modules) || mainModule)
  }

  // 5. 处理模板变更
  if (!isEqualBlock(descriptor.template, prevDescriptor.template)) {  // 检查模板是否变更
    if (!scriptChanged) {                // 如果脚本未变更
      // 复用旧脚本的解析结果(避免重新解析)
      setResolvedScript(
        descriptor,
        getResolvedScript(prevDescriptor, false)!,
        false,
      )
    }
    affectedModules.add(templateModule)  // 添加模板模块到受影响列表
    needRerender = true                  // 需要重新渲染
  }

  // 6. 处理样式变更
  let didUpdateStyle = false             // 样式是否更新标记
  const prevStyles = prevDescriptor.styles || []  // 旧样式块列表
  const nextStyles = descriptor.styles || []      // 新样式块列表

  // 6.1 检查CSS变量注入是否变更
  if (prevDescriptor.cssVars.join('') !== descriptor.cssVars.join('')) {
    affectedModules.add(mainModule)      // 添加主模块到受影响列表
  }

  // 6.2 检查scoped状态是否变更
  if (prevStyles.some((s) => s.scoped) !== nextStyles.some((s) => s.scoped)) {
    affectedModules.add(templateModule)  // 添加模板模块(scoped变更影响模板)
    affectedModules.add(mainModule)      // 添加主模块
  }

  // 6.3 检查每个样式块是否变更
  for (let i = 0; i < nextStyles.length; i++) {
    const prev = prevStyles[i]
    const next = nextStyles[i]
    if (!prev || !isEqualBlock(prev, next)) {  // 样式块新增或内容变更
      didUpdateStyle = true
      // 查找对应的样式模块
      const mod = modules.find(
        (m) =>
          m.url.includes(`type=style&index=${i}`) &&
          m.url.endsWith(`.${next.lang || 'css'}`) &&
          !directRequestRE.test(m.url),
      )
      if (mod) {
        affectedModules.add(mod)          // 添加样式模块到受影响列表
        if (mod.url.includes('&inline')) {  // 如果是内联样式
          affectedModules.add(mainModule)  // 添加主模块
        }
      } else {
        affectedModules.add(mainModule)    // 未找到对应模块,强制主模块更新
      }
    }
  }

  // 6.4 检查样式块是否被删除
  if (prevStyles.length > nextStyles.length) {
    affectedModules.add(mainModule)      // 强制主模块更新
  }

  // 7. 处理自定义块变更
  const prevCustoms = prevDescriptor.customBlocks || []  // 旧自定义块列表
  const nextCustoms = descriptor.customBlocks || []      // 新自定义块列表

  if (prevCustoms.length !== nextCustoms.length) {  // 自定义块数量变更
    affectedModules.add(mainModule)      // 强制主模块更新
  } else {
    // 检查每个自定义块是否变更
    for (let i = 0; i < nextCustoms.length; i++) {
      const prev = prevCustoms[i]
      const next = nextCustoms[i]
      if (!prev || !isEqualBlock(prev, next)) {  // 自定义块内容变更
        const mod = modules.find((m) =>
          m.url.includes(`type=${prev.type}&index=${i}`),  // 查找对应模块
        )
        if (mod) {
          affectedModules.add(mod)        // 添加自定义块模块
        } else {
          affectedModules.add(mainModule)  // 未找到则更新主模块
        }
      }
    }
  }

  // 8. 确定更新类型并处理缓存
  const updateType = []
  if (needRerender) updateType.push(`template`)  // 收集模板更新类型
  if (didUpdateStyle) updateType.push(`style`)   // 收集样式更新类型

  if (updateType.length) {  // 如果有更新
    if (file.endsWith('.vue')) {
      invalidateDescriptor(file)  // 使旧描述符缓存失效(Vue文件)
    } else {
      cache.set(file, descriptor)  // 更新非Vue文件的描述符缓存(如VitePress的.md文件)
    }
    debug(`[vue:update(${updateType.join('&')})] ${file}`)  // 输出调试信息
  }

  // 9. 返回所有受影响的模块(去重并过滤空值)
  return [...affectedModules, ...(typeDepModules || [])].filter(
    Boolean,
  ) as ModuleNode[]
}

总结