深入理解 Vite 虚拟模块:从原理到实战
从一个通用场景出发,拆解 Vite 虚拟模块的完整工作流,掌握构建时与运行时的数据桥接艺术。
什么是虚拟模块?
虚拟模块(Virtual Module)是 Vite 提供的一种运行时动态生成、磁盘上不存在的 ES Module。它允许插件在构建或开发期间,将服务端数据以标准 ES Module 的形式注入给浏览器端代码。
虚拟模块是 Node.js 与浏览器之间的数据桥梁。
为什么需要它?
在实际开发中,我们经常遇到这类需求:
- 用户在
my-app.config.ts中配置了siteName: 'My Awesome App' - 浏览器端的 React/Vue 组件需要读取这个配置来渲染标题
- 但
.config.ts文件在 Node.js 端,浏览器根本无法访问
传统的做法可能是:
- 写一个构建脚本生成临时文件 → 污染文件系统,容易忘记清理
- 通过 fetch 请求 API → 增加网络开销,还需要起一个服务
- 在 HTML 里塞一个全局变量
window.__CONFIG__→ 不优雅,丢失类型安全
而虚拟模块让这一切变得干净利落:
// 浏览器端代码 — 看起来就是普通 import
import { appConfig } from 'virtual:app-config';
console.log(appConfig.siteName); // 'My Awesome App'
工作原理
核心机制:两个钩子函数
Vite 插件通过实现两个钩子来创建虚拟模块:
1. resolveId — 声明"我认识这个 ID"
当 Vite 解析到某个 import 路径时,会依次调用每个插件的 resolveId。如果返回值非 null,说明这个插件认领了该模块。
const VIRTUAL_APP_CONFIG = 'virtual:app-config';
export function configPlugin(options) {
return {
name: 'config-plugin',
resolveId(id: string) {
// 当遇到 virtual:app-config 时,返回带 \0 前缀的 ID
if (id === VIRTUAL_APP_CONFIG) {
return `\0${VIRTUAL_APP_CONFIG}`; // \0 是虚拟模块的标识符
}
return null; // 返回 null 表示不处理
},
};
}
2. load — 动态生成模块内容
resolveId 返回后,Vite 会调用同一个插件的 load 钩子来获取模块内容。这里可以动态拼接 JavaScript 代码字符串:
async load(id: string) {
if (id === `\0${VIRTUAL_APP_CONFIG}`) {
// 将服务端数据序列化为 JS 代码
return `
export const appConfig = ${JSON.stringify(options.config, null, 2)};
export default appConfig;
`;
}
return null;
}
生成的结果等价于一个真实存在的内容:
// 这就是浏览器实际收到的"文件"内容
export const appConfig = {
siteName: "My Awesome App",
version: "1.2.0",
theme: "dark",
};
export default appConfig;
完整流程图
flowchart TB
subgraph BuildTime["开发 / 构建时"]
A["my-app.config.ts"]
A -->|"Node.js 读取配置"| B["{ siteName: 'MyApp'
version: '1.0' }"]
B -->|"plugin.load() 序列化"| C["\"export const appConfig =
{ siteName: 'MyApp', ... }\""]
end
subgraph RunTime["浏览器运行时"]
D["Header.vue / App.tsx"]
D -->|"import { appConfig } from
'virtual:app-config'"| E["appConfig.siteName → 'MyApp'
appConfig.version → '1.0'"]
E --> F["渲染
🎨 My App v1.0"]
end
BuildTime -- 虚拟模块桥接 --> RunTime
虚拟模块完整数据流:构建时配置 → 序列化 → 浏览器运行时消费
实战案例:从零搭建一个配置注入插件
下面我们通过一个完整的通用示例,演示如何创建和使用虚拟模块。假设我们要做一个支持多语言的 Web 应用,需要在构建时读取国际化文件并注入到前端。
场景描述
项目结构如下:
my-project/
├── i18n/
│ ├── zh-CN.json # 中文翻译
│ └── en-US.json # 英文翻译
├── vite.config.ts
├── src/
│ ├── App.vue
│ └── main.ts
└── package.json
第一步:编写 Vite 插件
// plugins/i18n-virtual-module.ts
import fs from 'node:path';
import type { Plugin } from 'vite';
const VIRTUAL_I18N = 'virtual:i18n';
interface I18nPluginOptions {
/** 语言文件目录 */
localesDir: string;
/** 默认语言 */
defaultLocale?: string;
}
/**
* 创建 i18n 虚拟模块插件
* 将语言文件在构建时打包为虚拟模块,运行时按需加载
*/
export function i18nVirtualModulePlugin(options: I18nPluginOptions): Plugin {
const resolvedPath = `\0${VIRTUAL_I18N}`;
let cachedContent: string | null = null;
return {
name: 'i18n-virtual',
resolveId(id) {
if (id === VIRTUAL_I18N) return resolvedPath;
return null;
},
async load(id) {
if (id !== resolvedPath) return null;
// 如果已有缓存直接返回(提升性能)
if (cachedContent) return cachedContent;
const localeFiles = await glob(`${options.localesDir}/*.json`);
const entries: string[] = [];
for (const file of localeFiles) {
const localeName = file.replace(/.*\/(\w+)\.json$/, '$1');
const content = JSON.parse(await fs.readFile(file, 'utf-8'));
entries.push(` '${localeName}': ${JSON.stringify(content)}`);
}
cachedContent = `
/**
* @generated by i18n-virtual-module-plugin
* 国际化语言包 - 构建时注入
*/
export const locales = {
${entries.join(',\n')}
};
export const defaultLocale = '${options.defaultLocale || 'zh-CN'}';
export function t(key: string, locale?: string) {
const lang = locale || defaultLocale;
const messages = locales[lang];
if (!messages) return key;
// 支持嵌套路径,如 "home.welcome"
return key.split('.').reduce((obj, k) => obj?.[k], messages) ?? key;
}
export default { locales, defaultLocale, t };
`;
return cachedContent;
},
};
}
这个插件做了几件事:
- 扫描指定目录下的所有
.json翻译文件 - 将每个语言包序列化为 JS 对象
- 额外导出一个
t()函数用于运行时翻译
第二步:注册插件
// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { i18nVirtualModulePlugin } from './plugins/i18n-virtual-module';
export default defineConfig({
plugins: [
vue(),
i18nVirtualModulePlugin({
localesDir: 'i18n',
defaultLocale: 'zh-CN',
}),
],
});
第三步:类型声明
// src/virtual.d.ts
declare module 'virtual:i18n' {
interface LocaleMessages {
[key: string]: string | LocaleMessages;
}
export const locales: Record<string, LocaleMessages>;
export const defaultLocale: string;
export function t(key: string, locale?: string): string;
export default { locales: typeof locales; defaultLocale: typeof defaultLocale; t: typeof t };
}
第四步:在组件中使用
<!-- src/components/Header.vue -->
<script setup lang="ts">
// @ts-expect-error 虚拟模块
import { t, defaultLocale, locales } from 'virtual:i18n';
import { ref } from 'vue';
const currentLocale = ref(defaultLocale);
function switchLocale(locale: string) {
currentLocale.value = locale;
}
</script>
<template>
<header>
<h1>{{ t('app.title') }}</h1>
<p>{{ t('app.subtitle') }}</p>
<div class="lang-switcher">
<button
v-for="(msgs, locale) in locales"
:key="locale"
:class="{ active: currentLocale === locale }"
@click="switchLocale(locale)"
>
{{ locale }}
</button>
</div>
</header>
</template>
第五步:效果
// i18n/zh-CN.json
{
"app": {
"title": "我的应用",
"subtitle": "欢迎使用"
}
}
// i18n/en-US.json
{
"app": {
"title": "My Application",
"subtitle": "Welcome aboard"
}
}
最终渲染结果:
│ 我的应用 │ ← 中文模式
│ 欢迎使用 │
│ [zh-CN] en-US │
└─────────────────────┘
↓ 点击 en-US
┌─────────────────────┐
│ My Application │ ← 英文模式
│ Welcome aboard │
│ zh-CN [en-US] │
└─────────────────────┘
更多实用场景
除了配置注入和 i18n,虚拟模块还有很多常见用途:
场景一:环境变量增强
将 Git 信息、构建时间等元数据注入前端:
// vite.config.ts
export default defineConfig({
plugins: [
{
name: 'build-meta',
resolveId(id) {
if (id === 'virtual:build-meta') return `\0virtual:build-meta`;
},
async load(id) {
if (id !== '\0virtual:build-meta') return null;
const { execSync } = require('child_process');
const commitHash = execSync('git rev-parse --short HEAD').toString().trim();
const buildTime = new Date().toISOString();
return `
export const buildMeta = {
commitHash: '${commitHash}',
buildTime: '${buildTime}',
version: '${process.env.npm_package_version}',
};
export default buildMeta;
`;
},
},
],
});
使用方式:
import { buildMeta } from 'virtual:build-meta';
console.log(`版本: ${buildMeta.version} (${buildMeta.commitHash})`);
场景二:SVG 图标集合
将 SVG 文件打包为一个图标映射表,避免运行时逐个请求:
// icons-virtual-plugin.ts
async function generateIconModule(iconsDir: string) {
const files = await glob(`${iconsDir}/*.svg`);
const entries = files.map((file) => {
const name = file.match(/([^/]+)\.svg$/)?.[1]!;
const svgContent = fs.readFileSync(file, 'utf-8');
return ` '${name}': ${JSON.stringify(svgContent)}`;
});
return `
export const icons = {
${entries.join(',\n')}
};
export function Icon({ name, size = 24 }) {
const svg = icons[name];
if (!svg) return null;
return <span dangerouslySetInnerHTML={{ __html: svg }} style={{ width: size, height: size }} />
}
`;
}
使用方式:
import { Icon } from 'virtual:icons';
function App() {
return (
<div>
<Icon name="arrow-left" />
<Icon name="search" size={32} />
</div>
);
}
场景三:API 类型自动生成
将后端 OpenAPI/Swagger 规范转为 TypeScript 类型,注入前端:
// api-types-plugin.ts
async function generateTypesFromSwagger(swaggerUrl: string) {
const res = await fetch(swaggerUrl);
const swagger = await res.json();
// 解析 schema 生成 TS 接口定义
const interfaces = Object.entries(swagger.components.schemas)
.map(([name, schema]) => generateTSInterface(name, schema))
.join('\n\n');
return `${interfaces}\n\nexport type ApiResponse<T> = { code: number; data: T; message: string; };`;
}
进阶技巧
1. 带缓存的懒加载
对于计算开销较大的虚拟模块,可以加入缓存机制:
let cache: { key: string; code: string } | null = null;
async load(id) {
if (id !== resolvedId) return null;
const cacheKey = JSON.stringify(sourceData);
// 数据未变化则复用缓存
if (cache && cache.key === cacheKey) {
return cache.code;
}
const code = generateCode(sourceData);
cache = { key: cacheKey, code };
return cache.code;
}
2. 多模块组合
一个插件可以同时管理多个虚拟模块:
const MODULES = {
config: 'virtual:my-plugin/config',
data: 'virtual:my-plugin/data',
helpers: 'virtual:my-plugin/helpers',
} as const;
export function myPlugin(data) {
return {
name: 'my-plugin',
resolveId(id) {
for (const mod of Object.values(MODULES)) {
if (id === mod) return `\0${mod}`;
}
return null;
},
load(id) {
if (id === `\0${MODULES.config}`)
return `export default ${JSON.stringify(data.config)};`;
if (id === `\0${MODULES.data}`)
return `export const items = ${JSON.stringify(data.items)};`;
if (id === `\0${MODULES.helpers}`)
return `export const format = (s) => s.toUpperCase();`;
return null;
},
};
}
3. Error Boundary 支持
在 load 中抛出错误会被 Vite 捕获并以友好的错误覆盖层展示给用户,非常适合做配置校验:
async load(id) {
if (id !== resolvedId) return null;
const config = readUserConfig();
if (!config.requiredField) {
throw new Error(
`[my-plugin] 配置缺少必填字段 requiredField。\n` +
`请检查 my-app.config.ts 文件。`
);
}
return `export default ${JSON.stringify(config)};`;
}
HMR(热更新)支持
虚拟模块的一个强大特性是支持热更新。当源数据变化时,主动失效虚拟模块缓存即可触发页面更新:
configureServer(server) {
// 监听配置文件变更
server.watcher.on('change', async (file) => {
if (file.endsWith('config.ts')) {
// 清除缓存,下次 load 时重新生成
cachedContent = null;
// 失效已缓存的模块
const module = server.moduleGraph.getModuleById(resolvedPath);
if (module) {
server.moduleGraph.invalidateModule(module);
}
// 通知客户端刷新
server.ws.send({ type: 'full-reload' });
}
});
}
与其他方案的对比
| 方案 | 类型安全 | HMR 支持 | 构建产物大小 | 复杂度 |
|---|---|---|---|---|
| 虚拟模块 | 可通过 .d.ts 实现 | 原生支持 | 仅包含使用到的部分 | 低 |
define 注入 |
无(全局变量替换) | 需手动刷新 | 较小(内联) | 最低 |
fetch API 请求 |
取决于接口文档 | 天然支持 | 运行时获取 | 中 |
| 构建脚本生成文件 | 有(生成 .ts) | 需 watch + 重启 | 取决于文件内容 | 高 |
window.__CONFIG__ |
无 | 需手动刷新 | 极小 | 最低 |
最佳实践清单
| 实践 | 说明 |
|---|---|
| 命名规范 | 统一使用 virtual: 前缀,如 virtual:plugin-name/data |
\0 前缀 |
resolveId 必须返回带 \0 的 ID,否则 Vite 会去磁盘查找 |
| 类型声明 | 提供 *.d.ts 声明文件,让消费端获得完整类型提示 |
@ts-expect-error |
在消费端 import 虚拟模块时添加,抑制 TS 报错 |
| 避免循环依赖 | 虚拟模块生成的代码不应再 import 可能触发自身的模块 |
| 缓存策略 | 对于耗时操作,在内存中缓存生成的字符串 |
| 错误信息 | 在 load 中 throw Error 会以友好方式展示给用户 |
| HMR 配合 | 数据源变化时调用 invalidateModule + ws.send 触发更新 |
总结
虚拟模块的本质是 "构建时代码生成" 的一种轻量级实现。它不需要写 Babel 插件,不需要生成临时污染文件系统的中间文件,只需要在 Vite 插件的 resolveId + load 钩子中做文章,就能将任意服务端数据安全、类型化地传递给前端运行时。
无论是注入配置、打包资源、生成类型,还是桥接后端数据——只要你想把"构建时知道的东西"交给"运行时使用",虚拟模块就是最自然的选择。