深入理解 Vite 虚拟模块:从原理到实战

从一个通用场景出发,拆解 Vite 虚拟模块的完整工作流,掌握构建时与运行时的数据桥接艺术。

什么是虚拟模块?

虚拟模块(Virtual Module)是 Vite 提供的一种运行时动态生成、磁盘上不存在的 ES Module。它允许插件在构建或开发期间,将服务端数据以标准 ES Module 的形式注入给浏览器端代码。

虚拟模块是 Node.js 与浏览器之间的数据桥梁。

为什么需要它?

在实际开发中,我们经常遇到这类需求:

传统的做法可能是:

  1. 写一个构建脚本生成临时文件 → 污染文件系统,容易忘记清理
  2. 通过 fetch 请求 API → 增加网络开销,还需要起一个服务
  3. 在 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 表示不处理
    },
  };
}
\0(空字符)前缀的作用:告诉 Vite "这是一个虚拟模块,不要去磁盘上找文件"。这是 Vite 的内部约定,防止与真实文件路径冲突。

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;
    },
  };
}

这个插件做了几件事:

  1. 扫描指定目录下的所有 .json 翻译文件
  2. 将每个语言包序列化为 JS 对象
  3. 额外导出一个 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 可能触发自身的模块
缓存策略 对于耗时操作,在内存中缓存生成的字符串
错误信息 loadthrow Error 会以友好方式展示给用户
HMR 配合 数据源变化时调用 invalidateModule + ws.send 触发更新

总结

虚拟模块的本质是 "构建时代码生成" 的一种轻量级实现。它不需要写 Babel 插件,不需要生成临时污染文件系统的中间文件,只需要在 Vite 插件的 resolveId + load 钩子中做文章,就能将任意服务端数据安全、类型化地传递给前端运行时。

无论是注入配置、打包资源、生成类型,还是桥接后端数据——只要你想把"构建时知道的东西"交给"运行时使用",虚拟模块就是最自然的选择。