Vite 插件开发实战:钩子体系、transform 与虚拟模块

从零编写 Vite 插件:理解 Vite 插件与 Rollup 插件的钩子体系、开发期专属钩子(configureServer)、transform 与 load 实现源码转换、虚拟模块的实现原理、插件应用顺序,以及三个完整的实战插件示例。

引言

当 Vite 的配置选项无法满足团队的自定义构建需求时,插件(Plugin) 是唯一的正解。无论是注入构建信息、自动生成路由、解析自定义文件格式,还是拦截与改写模块源码,插件的钩子体系都提供了标准化的接入点。理解插件机制,是「会用 Vite」到「掌控 Vite」的分水岭。

本文从 Vite 插件的双引擎身份讲起——一个插件既是 Rollup 插件(生产构建)又是 Vite 插件(开发服务器),系统覆盖钩子体系、transform/load 的源码转换、虚拟模块、插件顺序与 apply 作用域,最后用三个可直接运行的实战插件示例串联全部概念。

前置:https://plumephp.com/vite-scaffold-engineering/ 与 https://plumephp.com/vite-config-guide/。理解模块图可参考 https://plumephp.com/frontend-vite-deep-dive/。


目录


1. 插件是什么:双引擎身份

1.1 一个插件,两种运行时

开发模式(vite dev):
  Vite 服务器 + esbuild 转译 + 部分 Rollup 钩子(load/transform/resolveId)

生产构建(vite build):
  完整 Rollup 管线(esbuild 仅做 minify)

关键结论:插件作者面对的是同一套钩子 API,但必须清楚每个钩子在哪个阶段、哪个引擎下运行。

1.2 最小插件长什么样

import type { Plugin } from 'vite'

export function myPlugin(): Plugin {
  return {
    name: 'my-plugin',           // 必须:用于报错与日志
    // 可选钩子
    buildStart() {
      this.log('构建开始')
    },
  }
}

1.3 在 vite.config 中启用

import { defineConfig } from 'vite'
import { myPlugin } from './plugins/my-plugin'

export default defineConfig({
  plugins: [myPlugin()],
})

2. 钩子体系全景

2.1 按生命周期分组

类别钩子用途
解析resolveId决定模块 ID 如何解析
加载load读取/生成模块内容
转换transform修改模块源码
服务端configureServer修改 dev server(中间件/钩子)
构建产物generateBundle / writeBundle操作最终输出
生命周期buildStart / buildEnd构建开始/结束

2.2 开发期 vs 构建期

钩子devbuild
resolveId✅✅
load✅✅
transform✅✅
configureServer✅❌
configurePreviewServer✅✅(preview)
generateBundle❌✅
closeBundle❌✅

3. transform 与 load:源码转换

3.1 load:提供模块内容

export default {
  name: 'virtual-utils',
  load(id) {
    // 只处理虚拟模块
    if (id === 'virtual:utils') {
      return `export const now = () => Date.now()`
    }
  },
}

3.2 transform:改写源码

export default {
  name: 'add-version',
  transform(code, id) {
    // 只处理 src 下的 .ts 文件
    if (!id.includes('/src/') || !id.endsWith('.ts')) return

    return code.replace(
      /__APP_VERSION__/g,
      `'${process.env.npm_package_version}'`,
    )
  },
}

3.3 transform 的返回格式

transform(code, id) {
  if (!id.endsWith('.md')) return

  return {
    code: compiledCode,
    map: null, // 需要时提供 sourcemap
  }
}

不处理时返回 undefined(Vite 继续走后续插件);返回 null 明确表示不处理。


4. 虚拟模块:凭空创造的模块

4.1 为什么需要虚拟模块

有些数据(配置、文件列表、构建时信息)在运行时并不存在于文件系统,直接用普通 import 会失败。虚拟模块让这些内容以模块形式呈现:

import config from 'virtual:app-config'   // 并非真实文件

4.2 实现虚拟模块

export default {
  name: 'virtual-config',
  resolveId(id) {
    if (id === 'virtual:app-config') {
      return '\0virtual:app-config'  // \0 前缀标记,防止被当作真实路径
    }
  },
  load(id) {
    if (id === '\0virtual:app-config') {
      return `export default { name: '${process.env.APP_NAME}', version: '1.0.0' }`
    }
  },
}

4.3 关键约定

  • \0 前缀:让其他插件/工具不会把它误认为磁盘文件。
  • resolveId 返回的 ID 必须与 load 匹配。
  • 虚拟模块可包含 HMR 边界,配合 import.meta.hot 实现热更新。

5. configureServer:开发期专属能力

5.1 中间件注入

import { defineConfig, type Plugin } from 'vite'

function serverLogger(): Plugin {
  return {
    name: 'server-logger',
    configureServer(server) {
      // 返回中间件函数,附加在内部中间件之前
      return (req, res, next) => {
        console.log(`[req] ${req.method} ${req.url}`)
        next()
      }
    },
  }
}

5.2 访问 server 实例

configureServer(server) {
  // 在模块加载前执行
  server.middlewares.use((req, res, next) => {
    // ...
    next()
  })

  // 在 Vite 启动后执行
  server.httpServer?.once('listening', () => {
    console.log('dev server ready')
  })
}

5.3 执行时机

configureServer 在内部中间件安装前被调用,因此返回的中间件会先于静态文件与转换中间件执行——适合做访问控制、日志、mock。


6. 插件顺序与 apply 作用域

6.1 执行顺序(同类钩子)

先注册的插件先执行(数组顺序)
别名 alias 插件默认放最前
Vite 核心插件与用户插件分层

6.2 控制执行顺序

export function importantPlugin(): Plugin {
  return {
    name: 'important',
    enforce: 'pre',   // pre / normal(默认) / post
    transform(code, id) {
      // pre 阶段先于其他用户插件执行
    },
  }
}
enforce顺序
'pre'最早(在别名解析后)
'normal'(默认)中间
'post'最后(在 Vite 内置转换前)

6.3 apply:限定运行环境

function ssrOnly(): Plugin {
  return {
    name: 'ssr-only',
    apply: 'build',        // 只在 build 时生效
    // apply: (config, { command }) => command === 'build'
    // apply: 'serve'       // 只在 dev 时生效
  }
}

7. 实战插件一:注入构建信息

把构建时间与 git commit 注入到 import.meta.env 或全局常量。

import { execSync } from 'node:child_process'
import type { Plugin } from 'vite'

export function buildInfo(): Plugin {
  return {
    name: 'build-info',
    config() {
      const time = new Date().toISOString()
      let commit = ''
      try {
        commit = execSync('git rev-parse --short HEAD').toString().trim()
      } catch { /* 非 git 仓库 */ }

      return {
        define: {
          __BUILD_TIME__: JSON.stringify(time),
          __COMMIT_HASH__: JSON.stringify(commit),
        },
      }
    },
  }
}

在代码中使用:

console.log(`构建时间: ${__BUILD_TIME__}`)
console.log(`commit: ${__COMMIT_HASH__}`)

8. 实战插件二:解析自定义扩展名

让 Vite 直接加载 .graphql 文件为请求字符串:

import { readFileSync } from 'node:fs'
import type { Plugin } from 'vite'

export function graphqlLoader(): Plugin {
  return {
    name: 'graphql-loader',
    // 只拦截 .graphql 结尾的模块
    resolveId(source, importer) {
      if (source.endsWith('.graphql')) {
        // 交给 Node 解析真实路径
        return null
      }
    },
    load(id) {
      if (id.endsWith('.graphql')) {
        const content = readFileSync(id, 'utf-8')
        return `export default ${JSON.stringify(content)}`
      }
    },
    // 开发期需要 watch 源文件变化
    handleHotUpdate(ctx) {
      if (ctx.file.endsWith('.graphql')) {
        const mod = ctx.modules.find(m => m.file?.endsWith('.graphql'))
        if (mod) mod.importers.forEach(i => i.hot.accept())
      }
    },
  }
}

9. 实战插件三:虚拟模块暴露配置

将 vite 配置中的自定义选项暴露为模块,供应用内使用:

import type { Plugin, ResolvedConfig } from 'vite'

interface Options {
  appName: string
  debug?: boolean
}

export function exposeConfig(opts: Options): Plugin {
  let config: ResolvedConfig
  const virtualId = 'virtual:app-config'
  const resolvedVirtual = `\0${virtualId}`

  return {
    name: 'expose-config',
    configResolved(resolved) {
      config = resolved
    },
    resolveId(id) {
      if (id === virtualId) return resolvedVirtual
    },
    load(id) {
      if (id === resolvedVirtual) {
        return `
export const appConfig = ${JSON.stringify(opts)}
export const mode = '${config.mode}'
export default appConfig
`
      }
    },
  }
}
// 使用
import appConfig, { mode } from 'virtual:app-config'
console.log(appConfig, mode)

10. 调试与发布建议

10.1 用 this.debug 与插件顺序日志

function debugOrder(): Plugin {
  return {
    name: 'debug-order',
    buildStart() {
      this.debug?.('插件启动')
    },
    transform(code, id) {
      console.log(`[transform] ${id}`)
      return undefined
    },
  }
}

10.2 发布前的 Checklist

项说明
name 唯一且语义化出错日志可读
TypeScript 类型声明declare module 暴露 hooks
处理 \0 前缀虚拟模块安全
同时考虑 dev/build用 apply 区分
写单元测试用 vite 的 dev/build 在 CI 中验证

11. 总结

11.1 核心模型

解析(resolveId) -> 加载(load) -> 转换(transform) -> 产出
                └── 虚拟模块让「不存在」的文件可被 import
                └── configureServer 注入开发期中间件

11.2 关键要点

  1. 插件是 Rollup 插件 + Vite 插件的统一封装,钩子按生命周期分组。
  2. transform/load 是源码转换的主力,返回 undefined 表示不处理。
  3. 虚拟模块用 \0 前缀防误解析,适合暴露构建期数据。
  4. enforce/apply 控制顺序与环境,是大型插件库的必修课。

11.3 自检清单

检查项是否掌握
能说明 dev 与 build 的钩子差异☐
能写 transform 改写模块源码☐
能实现并导入虚拟模块☐
能配置 apply 限定运行环境☐
能独立发布一个 Vite 插件☐

延伸阅读

  • https://plumephp.com/vite-config-guide/ — 插件在配置中的完整用法
  • https://plumephp.com/vite-build-optimization/ — 用插件做构建优化
  • https://plumephp.com/frontend-vite-deep-dive/ — 模块图与 HMR 底层原理
  • Vite 插件 API 文档 — 官方完整钩子索引
  • Rollup 插件开发文档 — 底层打包钩子参考

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「vite」更多文章

  1. Vite 静态资源与媒体资产处理:图片、字体、SVG 与 Worker
  2. Vite 环境变量与生产构建最佳实践:import.meta.env、构建模式与产物优化
  3. Vite 浏览器兼容与 Legacy 构建:build.target、Polyfill 与兼容插件