写自己的 Vite 插件:虚拟模块与环境变量注入

本节目标

虚拟模块是 Vite 插件的经典玩法:它不像普通文件那样存在于磁盘,而是「凭空」提供一个可被 import 的模块。常用于「把构建时信息(版本号、git hash)暴露给前端代码」。

// version-plugin.js
export default function versionPlugin(options = {}) {
  const virtualId = 'virtual:app-version'
  const resolvedId = '\0' + virtualId // 加 \0 前缀避免和真实文件冲突

  return {
    name: 'app-version-plugin',
    // ① 解析阶段:把 import 'virtual:app-version' 认领下来
    resolveId(id) {
      if (id === virtualId) return resolvedId
    },
    // ② 加载阶段:为该虚拟 id 返回「代码字符串」作为模块内容
    load(id) {
      if (id === resolvedId) {
        return `export const version = ${JSON.stringify(options.version || '0.0.0')}
export const builtAt = ${JSON.stringify(new Date().toISOString())}`
      }
    },
    // ③ 注入自定义环境变量:把 import.meta.env.VITE_BUILD_TIME 填进代码
    transform(code, id) {
      if (id.endsWith('.js') || id.endsWith('.ts')) {
        return code.replace(
          /import\.meta\.env\.VITE_BUILD_TIME/g,
          JSON.stringify(options.builtAt || new Date().toISOString())
        )
      }
    },
  }
}

用法:

// vite.config.js
import { defineConfig } from 'vite'
import versionPlugin from './version-plugin.js'

export default defineConfig({
  plugins: [
    versionPlugin({ version: '1.3.0' }),
  ],
})
// src/main.js —— 像普通模块一样 import 虚拟模块
import { version, builtAt } from 'virtual:app-version'
console.log('当前版本:', version, '构建于:', builtAt)

// 这段代码里的 import.meta.env.VITE_BUILD_TIME 会被插件替换成真实时间字符串
const t = import.meta.env.VITE_BUILD_TIME

跑 npm run dev,控制台会打出 当前版本: 1.3.0 和真实构建时间。核心套路:resolveId 把虚拟 id 认领 → load 返回它的「源码字符串」→ 浏览器 import 时拿到这份内容。而 transform 钩子则演示了「编译期替换代码中的标记」这一通用能力(注入 env、注入常量都靠它)。

名词解释

虚拟模块(Virtual Module):不对应磁盘文件、由插件「凭空生成」的模块,可被正常 import。常用于把构建时信息(版本、git hash)注入前端。

resolveId / load:Rollup 钩子对。resolveId 决定「这个 import 指到哪个内部 id」,load 决定「这个 id 的内容是什么」;配合实现虚拟模块。

\0 前缀:Vite/Rollup 约定用 \0 标记「内部虚拟模块 id」,防止它和真实文件路径冲突。

import.meta.env:Vite 注入的「环境变量对象」,import.meta.env.VITE_XXX 对应 VITE_XXX 环境变量,是前后端共享配置的标准通道。

课后练习

练习 1:虚拟模块为什么要在 id 前加 \0?

答案:\0 是 Rollup/Vite 约定的「内部模块」标记,普通文件路径不会以它开头,加前缀能确保虚拟 id 不会误匹配到磁盘上的真实文件,也不会被当作普通文件去读。

练习 2:transform 里用 code.replace(...) 注入常量,有什么风险?

答案:简单字符串替换可能误伤——比如字符串字面量里恰好出现同样的子串也会被替换。生产级插件应先判断「这是不是目标标识符的引用」再替换(或用更精确的 AST 改写)。本课示例为讲清原理用 replace,实战要更严谨。

总结

你写出了第一个「生产可用」的 Vite 插件:resolveId+load 造虚拟模块,transform 注入构建期变量。这俩模式覆盖了大部分 Vite 插件需求——「凭空造模块」和「改写代码」。我的建议:写插件先想清「是改已有文件(transform)还是造新模块(resolveId/load)」,再选钩子,思路立刻清晰。下一节进入优化:依赖预构建调优、代码分割与生产构建。