Appearance
Vite 中的 HMR 原理
原理
Vite 中 HMR 的原理主要是通过浏览器的 websocket 来实现的,当我们修改了代码后,Vite 会通过 websocket 通知浏览器,浏览器收到通知后,会重新请求修改的代码,然后浏览器会重新渲染页面。
实现
Vite 中 HMR 的实现主要是通过以下几个步骤来实现的:
浏览器发起请求,获取最新的代码
Vite 服务器收到请求,判断是否需要 HMR
如果需要 HMR,就会给浏览器发送一个 websocket 消息,告诉浏览器需要更新代码
浏览器收到 websocket 消息后,会重新请求最新的代码
Vite 服务器收到请求,返回最新的代码
浏览器收到最新的代码后,会重新渲染页面
修改index.html文件,注入HMR需要的客户端文件 client.mjs
启动 Websocket 服务器,用于与 client.mjs 进行通信
浏览器发起请求,与WS建立连接,同时获取代码
根据各个文件类型的插件提供HMR的能力, 主要是
- 初始化文本范围的上下文对象并赋给 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 模块中,其主要做了以下事件:
- 会创建一个 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
- 提供了一个
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 处理的总调度中心,其将所有的本地文件分为以下几个类型
- 配置文件、环境文件
对于这一类文件的修改可能需要重新服务器 和 整个页面刷新, 所以执行的是 await server.restart()
- vite 缓存的文件 client.mjs 文件发生修改
js
ws.send({
type: "full-reload", // 消息类型:全量刷新
path: "*", // 刷新所有路径
});- 正常的项目文件
对于正常的文件修改,如 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[]
}