自定义插件开发
学习如何从零开始编写自己的 Vite 插件,扩展构建能力。
基本信息
- 简介: Vite 插件开发指南,基于 Rollup 插件接口
- 官方文档: https://vitejs.dev/guide/api-plugin.html
- Rollup 插件文档: https://rollupjs.org/plugin-development/
插件基础
什么是 Vite 插件
Vite 插件是一个对象,包含一组钩子函数,用于在构建过程的不同阶段执行自定义逻辑。Vite 插件扩展了 Rollup 插件接口,并添加了一些 Vite 特有的钩子。
插件结构
typescript
import type { Plugin } from 'vite'
export default function myPlugin(): Plugin {
return {
name: 'vite-plugin-my-plugin', // 必需,插件名称
// Vite 特有钩子
config(config, env) {
// 修改 Vite 配置
},
configResolved(resolvedConfig) {
// 配置解析完成后调用
},
configureServer(server) {
// 配置开发服务器
},
handleHotUpdate(ctx) {
// 处理热更新
},
// Rollup 通用钩子
buildStart() {
// 构建开始
},
resolveId(id) {
// 解析模块 ID
},
load(id) {
// 加载模块内容
},
transform(code, id) {
// 转换模块代码
},
buildEnd() {
// 构建结束
}
}
}快速入门
示例 1:添加自定义虚拟模块
创建一个插件,提供虚拟模块供项目导入:
typescript
// plugins/virtual-module.ts
import type { Plugin } from 'vite'
export default function virtualModulePlugin(): Plugin {
const virtualModuleId = 'virtual:my-module'
// \0 是 Rollup 约定的前缀,用于标识虚拟模块(不对应实际文件)
// 告诉 Rollup 不要尝试从文件系统加载这个模块
const resolvedVirtualModuleId = '\0' + virtualModuleId
return {
name: 'vite-plugin-virtual-module',
resolveId(id) {
if (id === virtualModuleId) {
return resolvedVirtualModuleId
}
},
load(id) {
if (id === resolvedVirtualModuleId) {
return `export const msg = "Hello from virtual module!"`
}
}
}
}使用插件:
typescript
// vite.config.ts
import { defineConfig } from 'vite'
import virtualModule from './plugins/virtual-module'
export default defineConfig({
plugins: [virtualModule()]
})在代码中导入:
typescript
// src/main.ts
import { msg } from 'virtual:my-module'
console.log(msg) // "Hello from virtual module!"示例 2:自动注入代码
创建一个插件,在每个 Vue 文件中自动注入代码:
typescript
// plugins/auto-inject.ts
import type { Plugin } from 'vite'
export default function autoInjectPlugin(): Plugin {
return {
name: 'vite-plugin-auto-inject',
transform(code, id) {
// 只处理 .vue 文件
if (id.endsWith('.vue')) {
// 在 script setup 后注入代码
const injectedCode = `
// 自动注入的代码
console.log('Component loaded:', '${id}')
`
// 查找 <script setup> 标签
const scriptSetupRegex = /<script\s+setup[^>]*>/
if (scriptSetupRegex.test(code)) {
code = code.replace(scriptSetupRegex, (match) => {
return match + injectedCode
})
}
return {
code,
map: null // 如果需要 source map,这里返回 map 对象
}
}
}
}
}示例 3:环境变量注入
创建一个插件,注入构建时间等环境信息:
typescript
// plugins/build-info.ts
import type { Plugin } from 'vite'
interface BuildInfoOptions {
prefix?: string
}
export default function buildInfoPlugin(options: BuildInfoOptions = {}): Plugin {
const { prefix = '__BUILD_' } = options
return {
name: 'vite-plugin-build-info',
config() {
const buildTime = new Date().toISOString()
const buildVersion = process.env.npm_package_version || '1.0.0'
return {
define: {
[`${prefix}TIME`]: JSON.stringify(buildTime),
[`${prefix}VERSION`]: JSON.stringify(buildVersion)
}
}
}
}
}使用:
typescript
// vite.config.ts
import buildInfo from './plugins/build-info'
export default defineConfig({
plugins: [buildInfo({ prefix: '__APP_' })]
})typescript
// 在代码中使用
declare const __APP_TIME: string
declare const __APP_VERSION: string
console.log('Build Time:', __APP_TIME)
console.log('Version:', __APP_VERSION)常用钩子详解
Vite 特有钩子
config
修改 Vite 配置,在配置解析前调用:
typescript
config(config, { command, mode }) {
if (command === 'serve') {
// 开发环境配置
return {
server: {
port: 3000
}
}
} else {
// 生产环境配置
return {
build: {
minify: 'terser'
}
}
}
}configResolved
配置解析完成后调用,可以获取最终配置:
typescript
configResolved(resolvedConfig) {
// 保存配置供其他钩子使用
this.config = resolvedConfig
console.log('Final config:', resolvedConfig)
}configureServer
配置开发服务器,添加自定义中间件:
typescript
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (req.url === '/api/custom') {
res.end('Custom API response')
return
}
next()
})
}transformIndexHtml
转换 index.html:
typescript
transformIndexHtml(html) {
return html.replace(
/<title>(.*?)<\/title>/,
'<title>My Custom Title</title>'
)
}Rollup 通用钩子
resolveId
解析模块导入路径:
typescript
resolveId(source, importer) {
if (source === 'my-lib') {
// 返回自定义路径
return '/path/to/my-lib.js'
}
return null // 让其他插件或默认解析器处理
}load
加载模块内容:
typescript
load(id) {
if (id.endsWith('.custom')) {
// 读取并返回文件内容
return fs.readFileSync(id, 'utf-8')
}
}transform
转换模块代码(最常用):
typescript
transform(code, id) {
if (id.endsWith('.vue')) {
// 转换代码
const transformedCode = code.replace(/old/g, 'new')
return {
code: transformedCode,
map: null // source map
}
}
}实战案例
案例 1:Markdown 导入插件
支持直接导入 .md 文件为 Vue 组件:
点击查看完整代码
typescript
// plugins/markdown.ts
import type { Plugin } from 'vite'
import { marked } from 'marked'
export default function markdownPlugin(): Plugin {
return {
name: 'vite-plugin-markdown',
// 声明处理的文件类型
async transform(code, id) {
if (!id.endsWith('.md')) return
// 将 Markdown 转换为 HTML
const html = await marked(code)
// 生成 Vue 组件代码
const component = `
<template>
<div class="markdown-body" v-html="html"></div>
</template>
<script setup>
const html = ${JSON.stringify(html)}
</script>
<style scoped>
.markdown-body {
padding: 20px;
line-height: 1.6;
}
.markdown-body h1 {
font-size: 2em;
margin-bottom: 0.5em;
border-bottom: 1px solid #eee;
}
.markdown-body h2 {
font-size: 1.5em;
margin-top: 1em;
}
.markdown-body code {
background: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
}
.markdown-body pre {
background: #f5f5f5;
padding: 15px;
border-radius: 5px;
overflow-x: auto;
}
</style>
`
return {
code: component,
map: null
}
}
}
}使用示例:
typescript
// vite.config.ts
import markdown from './plugins/markdown'
export default defineConfig({
plugins: [vue(), markdown()]
})vue
<script setup lang="ts">
import Readme from './README.md'
</script>
<template>
<Readme />
</template>案例 2:自动生成路由插件
扫描 pages 目录自动生成路由配置:
点击查看完整代码
typescript
// plugins/auto-routes.ts
import type { Plugin } from 'vite'
import fs from 'fs'
import path from 'path'
interface RouteConfig {
path: string
component: string
name: string
}
export default function autoRoutesPlugin(): Plugin {
const virtualModuleId = 'virtual:generated-routes'
const resolvedVirtualModuleId = '\0' + virtualModuleId
// 扫描 pages 目录生成路由
function generateRoutes(pagesDir: string): RouteConfig[] {
const routes: RouteConfig[] = []
function scanDir(dir: string, basePath = '') {
const files = fs.readdirSync(dir)
files.forEach(file => {
const filePath = path.join(dir, file)
const stat = fs.statSync(filePath)
if (stat.isDirectory()) {
scanDir(filePath, `${basePath}/${file}`)
} else if (file.endsWith('.vue')) {
const name = file.replace('.vue', '')
const routePath = name === 'index'
? basePath || '/'
: `${basePath}/${name}`
routes.push({
path: routePath,
component: filePath,
name: routePath.replace(/\//g, '-').slice(1) || 'home'
})
}
})
}
scanDir(pagesDir)
return routes
}
return {
name: 'vite-plugin-auto-routes',
resolveId(id) {
if (id === virtualModuleId) {
return resolvedVirtualModuleId
}
},
load(id) {
if (id === resolvedVirtualModuleId) {
const pagesDir = path.resolve(process.cwd(), 'src/pages')
const routes = generateRoutes(pagesDir)
// 生成路由配置代码
const code = `
export default [
${routes.map(route => `
{
path: '${route.path}',
name: '${route.name}',
component: () => import('${route.component}')
}`).join(',\n ')}
]
`
return code
}
},
// 监听文件变化,热更新路由
handleHotUpdate({ file, server }) {
if (file.includes('src/pages') && file.endsWith('.vue')) {
const module = server.moduleGraph.getModuleById(resolvedVirtualModuleId)
if (module) {
server.moduleGraph.invalidateModule(module)
server.ws.send({
type: 'full-reload'
})
}
}
}
}
}使用示例:
typescript
// vite.config.ts
import autoRoutes from './plugins/auto-routes'
export default defineConfig({
plugins: [vue(), autoRoutes()]
})typescript
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import routes from 'virtual:generated-routes'
const router = createRouter({
history: createWebHistory(),
routes
})
export default router案例 3:移除 console 插件
生产环境自动移除所有 console 语句:
typescript
// plugins/remove-console.ts
import type { Plugin } from 'vite'
import MagicString from 'magic-string'
export default function removeConsolePlugin(): Plugin {
return {
name: 'vite-plugin-remove-console',
transform(code, id) {
// 只在生产环境处理
if (process.env.NODE_ENV !== 'production') return
// 只处理 .js, .ts, .vue 文件
if (!/\.(vue|[jt]sx?)$/.test(id)) return
// 检查是否包含 console
if (!code.includes('console.')) return
const s = new MagicString(code)
// 移除 console.log, console.warn 等
const consoleRegex = /console\.(log|warn|error|info|debug)\([^)]*\);?/g
let match
while ((match = consoleRegex.exec(code))) {
s.remove(match.index, match.index + match[0].length)
}
return {
code: s.toString(),
map: s.generateMap({ hires: true })
}
}
}
}插件开发最佳实践
1. 命名规范
typescript
// ✅ 推荐:使用 vite-plugin- 前缀
export default function vitePluginMyPlugin(): Plugin {
return {
name: 'vite-plugin-my-plugin'
}
}
// ❌ 不推荐
export default function myPlugin(): Plugin {
return {
name: 'my-plugin'
}
}2. 提供 TypeScript 类型
typescript
import type { Plugin } from 'vite'
export interface MyPluginOptions {
include?: string | string[]
exclude?: string | string[]
customOption?: boolean
}
export default function myPlugin(options: MyPluginOptions = {}): Plugin {
const {
include = ['**/*.vue'],
exclude = ['node_modules/**'],
customOption = false
} = options
return {
name: 'vite-plugin-my-plugin',
// ...
}
}3. 错误处理
typescript
transform(code, id) {
try {
// 转换逻辑
const result = transformCode(code)
return result
} catch (error) {
// 提供友好的错误信息
this.error({
message: `Failed to transform ${id}`,
cause: error
})
}
}4. 性能优化
typescript
// ✅ 使用过滤器减少处理
const filter = createFilter(include, exclude)
transform(code, id) {
if (!filter(id)) return null
// 只处理匹配的文件
return transformCode(code)
}5. 支持热更新
typescript
handleHotUpdate({ file, server }) {
if (file.endsWith('.custom')) {
// 使相关模块失效
const modules = server.moduleGraph.getModulesByFile(file)
return modules ? [...modules] : []
}
}6. 添加调试日志
typescript
import debug from 'debug'
const log = debug('vite-plugin-my-plugin')
export default function myPlugin(): Plugin {
return {
name: 'vite-plugin-my-plugin',
transform(code, id) {
log('Transforming:', id)
// ...
}
}
}常用工具库
文件过滤
typescript
import { createFilter } from '@rollup/pluginutils'
const filter = createFilter(
['**/*.vue', '**/*.ts'], // include
['node_modules/**'] // exclude
)
if (filter(id)) {
// 处理文件
}代码转换
typescript
import MagicString from 'magic-string'
const s = new MagicString(code)
s.replace('old', 'new')
s.prepend('// header\n')
s.append('\n// footer')
return {
code: s.toString(),
map: s.generateMap({ hires: true })
}路径处理
typescript
import { normalizePath } from 'vite'
import path from 'path'
const normalizedPath = normalizePath(path.resolve(__dirname, './file.js'))发布插件
1. 项目结构
vite-plugin-my-plugin/
├── src/
│ └── index.ts # 插件源码
├── dist/
│ ├── index.js # 编译后的 CJS
│ ├── index.mjs # 编译后的 ESM
│ └── index.d.ts # 类型声明
├── package.json
├── tsconfig.json
└── README.md2. package.json 配置
json
{
"name": "vite-plugin-my-plugin",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts",
"dev": "tsup src/index.ts --format cjs,esm --dts --watch"
},
"keywords": [
"vite",
"vite-plugin"
],
"peerDependencies": {
"vite": "^5.0.0"
},
"devDependencies": {
"tsup": "^8.0.0",
"typescript": "^5.0.0",
"vite": "^5.0.0"
}
}3. 构建配置
使用 tsup 构建:
typescript
// tsup.config.ts
import { defineConfig } from 'tsup'
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true,
clean: true,
shims: true
})4. 发布到 npm
bash
# 构建
pnpm build
# 发布
npm publish调试技巧
1. 使用 debug 模块
bash
# 启动时开启调试
DEBUG=vite-plugin-my-plugin pnpm dev2. 查看插件执行顺序
typescript
export default function myPlugin(): Plugin {
return {
name: 'vite-plugin-my-plugin',
enforce: 'pre', // 'pre' | 'post' | undefined
buildStart() {
console.log('Plugin order:', this.meta.watchMode)
}
}
}3. 检查模块图
typescript
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (req.url === '/__inspect') {
const modules = [...server.moduleGraph.idToModuleMap.entries()]
res.end(JSON.stringify(modules, null, 2))
return
}
next()
})
}学习资源
官方文档
优秀插件源码
工具库
- @rollup/pluginutils
- magic-string
- unplugin - 跨构建工具插件框架
总结
编写 Vite 插件的关键点:
- ✅ 理解插件钩子的执行时机和顺序
- ✅ 合理使用
transform、load、resolveId等核心钩子 - ✅ 提供清晰的类型定义和配置选项
- ✅ 注意性能优化,避免不必要的文件处理
- ✅ 支持热更新,提升开发体验
- ✅ 编写完善的文档和示例
通过自定义插件,你可以:
- 🎯 扩展 Vite 的构建能力
- 🎯 集成自定义工具和流程
- 🎯 优化项目构建性能
- 🎯 提升团队开发效率