第五章:Vite 插件开发指南
5.1 插件系统概述
Vite 的插件系统兼容 Rollup 插件 API,并扩展了 Vite 特有的钩子。
插件的作用
- 转换代码(TypeScript、JSX)
- 处理特殊文件(Vue、Svelte)
- 注入代码
- 修改构建输出
- 集成第三方工具
5.2 插件基础结构
最简单的插件
javascript
// my-plugin.js
export default function myPlugin() {
return {
name: 'my-plugin', // 必需,用于错误和警告
// 钩子函数
transform(code, id) {
// 转换代码
return code
}
}
}
在项目中引入
javascript
// vite.config.js
import myPlugin from './my-plugin.js'
export default {
plugins: [myPlugin()]
}
5.3 插件钩子
通用钩子(Rollup 兼容)
buildStart
javascript
buildStart(options) {
console.log('构建开始')
}
resolveId
javascript
resolveId(source, importer) {
if (source === 'virtual-module') {
return '\0virtual-module' // 返回虚拟模块 ID
}
return null // 交给下一个插件
}
load
javascript
load(id) {
if (id === '\0virtual-module') {
return 'export default "Hello from virtual module"'
}
return null
}
transform
javascript
transform(code, id) {
if (id.endsWith('.custom')) {
// 转换自定义文件
return {
code: `export default ${JSON.stringify(code)}`,
map: null // source map
}
}
}
generateBundle
javascript
generateBundle(options, bundle) {
// 修改或添加输出文件
bundle['custom.js'] = {
code: 'console.log("custom");',
type: 'asset'
}
}
Vite 特有钩子
config
javascript
config(config, env) {
// 修改配置
return {
resolve: {
alias: {
'@': '/src'
}
}
}
}
configResolved
javascript
configResolved(config) {
// 保存最终配置
this.config = config
}
configureServer
javascript
configureServer(server) {
// 添加自定义中间件
server.middlewares.use((req, res, next) => {
console.log(`${req.method} ${req.url}`)
next()
})
// 监听 WebSocket
server.ws.on('connection', () => {
console.log('客户端连接')
})
}
transformIndexHtml
javascript
transformIndexHtml(html) {
// 修改 index.html
return html.replace(
'<head>',
'<head><link rel="preconnect" href="https://fonts.googleapis.com">'
)
}
handleHotUpdate
javascript
handleHotUpdate({ file, server, modules }) {
// 自定义 HMR 行为
if (file.endsWith('.custom')) {
// 重新加载整个页面
server.ws.send({
type: 'full-reload'
})
return []
}
return modules
}
5.4 实战示例
示例 1:Markdown 文件转组件
这是一个完整的插件开发案例,演示如何将 Markdown 文件转换为 Vue 组件。
javascript
// vite-plugin-md-to-vue.js
import { readFileSync } from 'fs'
import { parse } from 'marked' // 安装: npm install marked
// 生成 Vue 组件代码
function generateVueComponent(content, meta = {}) {
const html = parse(content)
return `
<template>
<div class="markdown-content">
<h1 v-if="title">{{ title }}</h1>
<div v-html="renderedHtml"></div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const title = ${JSON.stringify(meta.title || '')}
const renderedHtml = ref(${JSON.stringify(html)})
</script>
<style scoped>
.markdown-content {
max-width: 800px;
margin: 0 auto;
}
.markdown-content :deep(h1) {
font-size: 2rem;
margin-bottom: 1rem;
}
.markdown-content :deep(p) {
line-height: 1.6;
margin-bottom: 1rem;
}
</style>
`.trim()
}
// 解析 Markdown 元数据
function parseFrontmatter(content) {
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/;
const match = content.match(frontmatterRegex);
if (!match) return { content, meta: {} };
const meta = {};
match[1].split('\n').forEach(line => {
const [key, ...values] = line.split(':');
if (key && values.length) {
meta[key.trim()] = values.join(':').trim();
}
});
return {
content: content.replace(frontmatterRegex, ''),
meta
};
}
export default function mdToVuePlugin(options = {}) {
const { include = /\.md$/, exclude } = options;
return {
name: 'vite-plugin-md-to-vue',
transform(code, id) {
// 检查是否需要处理
if (!include.test(id)) return;
if (exclude && exclude.test(id)) return;
try {
// 解析 frontmatter
const { content, meta } = parseFrontmatter(code);
// 生成 Vue 组件
const componentCode = generateVueComponent(content, meta);
return {
code: componentCode,
map: null
};
} catch (error) {
this.error(`Markdown 转换失败: ${error.message}`);
}
}
};
}
使用插件:
javascript
// vite.config.js
import mdToVue from './vite-plugin-md-to-vue.js'
export default {
plugins: [
mdToVue({
include: /\.md$/,
// exclude: /README\.md$/ // 排除某些文件
})
]
}
// 现在可以直接导入 Markdown 文件
import AboutPage from './pages/about.md'
示例 2:自动导入组件
这个插件实现类似 unplugin-vue-components 的功能,自动按需导入组件。
javascript
// vite-plugin-auto-import.js
import { readdirSync, statSync } from 'fs'
import { resolve, relative } from 'path'
// 扫描组件目录
function scanComponents(componentsDir) {
const components = {}
function scan(dir, prefix = '') {
const files = readdirSync(dir)
files.forEach(file => {
const fullPath = resolve(dir, file)
const stat = statSync(fullPath)
if (stat.isDirectory()) {
scan(fullPath, `${prefix}${file}/`)
} else if (file.endsWith('.vue')) {
const name = prefix + file.replace('.vue', '')
components[name] = fullPath
}
})
}
scan(componentsDir)
return components
}
export default function autoImportComponents(options = {}) {
const { dirs = ['src/components'], prefix = '' } = options
let components = {}
let viteConfig
return {
name: 'vite-plugin-auto-import',
configResolved(config) {
viteConfig = config
// 扫描组件
dirs.forEach(dir => {
const fullPath = resolve(viteConfig.root, dir)
components = { ...components, ...scanComponents(fullPath) }
})
},
transform(code, id) {
// 只处理 Vue 文件
if (!id.endsWith('.vue')) return
let imports = []
let transformedCode = code
// 查找未导入的组件使用
const componentRegex = /<([A-Z][a-zA-Z0-9]*)/g
let match
while ((match = componentRegex.exec(code)) !== null) {
const componentName = match[1]
// 检查是否已导入
const isImported = code.includes(`import ${componentName}`) ||
code.includes(`import { ${componentName} }`)
// 检查组件是否存在
if (!isImported && components[componentName]) {
const importPath = relative(resolve(id, '..'), components[componentName])
imports.push(`import ${componentName} from '${importPath}'`)
}
}
if (imports.length > 0) {
transformedCode = imports.join('\n') + '\n' + code
}
return { code: transformedCode, map: null }
}
}
}
示例 3:开发时 Mock API
javascript
// vite-plugin-mock.js
import { createServer } from 'http'
export default function mockPlugin(mocks = {}) {
return {
name: 'vite-plugin-mock',
configureServer(server) {
// 创建中间件拦截 API 请求
server.middlewares.use(async (req, res, next) => {
const url = req.url
// 查找匹配的 Mock
for (const [pattern, handler] of Object.entries(mocks)) {
if (url.match(new RegExp(pattern))) {
const response = await handler(req, res)
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(response))
return
}
}
next()
})
// 热更新 Mock 配置
server.ws.on('mock-update', () => {
console.log('Mock 配置已更新')
// 通知客户端刷新
server.ws.send({ type: 'full-reload' })
})
}
}
}
// 使用示例
// vite.config.js
import mockPlugin from './vite-plugin-mock.js'
export default {
plugins: [
mockPlugin({
'^/api/users$': async (req) => {
return {
code: 0,
data: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]
}
},
'^/api/user/(\\d+)$': async (req) => {
const id = req.url.match(/^\/api\/user\/(\d+)$/)[1]
return {
code: 0,
data: { id: parseInt(id), name: `User ${id}` }
}
}
})
]
}
示例 4:构建产物分析
javascript
// vite-plugin-bundle-report.js
import { writeFileSync, mkdirSync } from 'fs'
import { resolve } from 'path'
export default function bundleReport(options = {}) {
const { filename = 'bundle-report.html', open = true } = options
let bundleStats = {}
return {
name: 'vite-plugin-bundle-report',
generateBundle(outputOptions, bundle) {
// 收集构建统计
Object.keys(bundle).forEach(key => {
const chunk = bundle[key]
if (chunk.type === 'chunk') {
bundleStats[key] = {
size: chunk.code.length,
modules: chunk.modules ? Object.keys(chunk.modules).length : 0,
imports: chunk.imports || []
}
}
})
},
buildEnd() {
// 生成报告 HTML
const html = `
<!DOCTYPE html>
<html>
<head>
<title>Bundle Report</title>
<style>
body { font-family: monospace; padding: 20px; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
.size { font-weight: bold; }
</style>
</head>
<body>
<h1>Bundle Report</h1>
<table>
<tr><th>File</th><th>Size (bytes)</th><th>Modules</th><th>Imports</th></tr>
${Object.entries(bundleStats).map(([name, stats]) => `
<tr>
<td>${name}</td>
<td class="size">${stats.size.toLocaleString()}</td>
<td>${stats.modules}</td>
<td>${stats.imports.join(', ')}</td>
</tr>
`).join('')}
</table>
<p>Total size: ${Object.values(bundleStats).reduce((sum, s) => sum + s.size, 0).toLocaleString()} bytes</p>
</body>
</html>
`
const outputPath = resolve(process.cwd(), filename)
writeFileSync(outputPath, html)
console.log(`Bundle report generated: ${outputPath}`)
if (open) {
const { exec } = require('child_process')
exec(`open ${outputPath}`)
}
}
}
}
5.5 插件调试技巧
使用 inspect 插件
bash
npm install -D vite-plugin-inspect
javascript
import Inspect from 'vite-plugin-inspect'
export default {
plugins: [
Inspect() // 访问 http://localhost:5173/__inspect/
]
}
日志调试
javascript
import { createLogger } from 'vite'
const logger = createLogger()
export default function debugPlugin() {
return {
name: 'debug-plugin',
transform(code, id) {
logger.info(`处理文件: ${id}`)
// 条件日志
if (process.env.DEBUG === 'plugin') {
console.log('代码预览:', code.slice(0, 100))
}
return code
}
}
}
使用 Node.js 调试器
bash
# 启动调试
node --inspect-brk node_modules/vite/bin/vite.js
# 在 Chrome 中打开 chrome://inspect
断点调试
javascript
// 在插件中添加 debugger
transform(code, id) {
debugger // 代码执行会在此处暂停
// 检查 id 和 code
return code
}
5.6 Rollup 钩子与 Vite 钩子映射
| Rollup 钩子 | Vite 对应 | 说明 |
|---|---|---|
buildStart |
✅ 完全支持 | 构建开始 |
resolveId |
✅ 完全支持 | 解析模块 ID |
load |
✅ 完全支持 | 加载模块 |
transform |
✅ 完全支持 | 转换模块内容 |
moduleParsed |
⚠️ 部分支持 | 模块解析完成 |
buildEnd |
✅ 完全支持 | 构建结束 |
generateBundle |
✅ 完全支持 | 生成产物 |
writeBundle |
✅ 完全支持 | 写入产物 |
closeBundle |
✅ 完全支持 | 关闭构建 |
config |
🔄 Vite 特有 | 修改配置 |
configResolved |
🔄 Vite 特有 | 配置已解析 |
configureServer |
🔄 Vite 特有 | 配置开发服务器 |
transformIndexHtml |
🔄 Vite 特有 | 转换 index.html |
handleHotUpdate |
🔄 Vite 特有 | 处理热更新 |
钩子执行顺序
config
configResolved
configureServer
buildStart (开发: 启动服务器)
resolveId
load
transform
moduleParsed
buildEnd (开发: 每次文件变化)
closeBundle (开发: 服务器关闭)
5.7 插件开发最佳实践
1. 命名规范
javascript
// 官方插件命名
@vitejs/plugin-vue
// 社区插件命名
vite-plugin-[name]
// 作用域插件
@scope/vite-plugin-[name]
2. 错误处理
javascript
transform(code, id) {
try {
// 转换逻辑
} catch (error) {
// 使用 this.error 抛出 Vite 可识别的错误
this.error(`转换失败: ${error.message}`)
// 或返回错误对象
return { code: '', map: null, error: error.message }
}
}
3. 性能优化
javascript
// 使用缓存
const cache = new Map()
transform(code, id) {
// 快速过滤,避免不必要的处理
if (!id.endsWith('.custom')) return
const cacheKey = `${id}:${code.length}`
if (cache.has(cacheKey)) return cache.get(cacheKey)
const result = expensiveTransform(code)
cache.set(cacheKey, result)
return result
}
4. 类型定义
typescript
// plugin.d.ts
import { Plugin } from 'vite'
export interface MyPluginOptions {
/** 是否启用 */
enable?: boolean
/** 包含的文件模式 */
include?: RegExp
}
export default function myPlugin(options?: MyPluginOptions): Plugin
5. 虚拟模块处理
javascript
// 虚拟模块需要加 \0 前缀避免被其他插件处理
const virtualModuleId = 'virtual:my-module'
const resolvedVirtualModuleId = '\0' + virtualModuleId
resolveId(id) {
if (id === virtualModuleId) {
return resolvedVirtualModuleId
}
}
load(id) {
if (id === resolvedVirtualModuleId) {
return `export const version = '${pkg.version}'`
}
}
6. 热更新支持
javascript
handleHotUpdate({ file, server, modules }) {
// 自定义 HMR 逻辑
if (file.endsWith('.custom')) {
// 通知客户端重新加载
server.ws.send({
type: 'full-reload',
path: '*'
})
return []
}
return modules
}
5.8 插件发布
package.json 配置
json
{
"name": "vite-plugin-my-plugin",
"version": "1.0.0",
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
},
"files": ["dist"],
"peerDependencies": {
"vite": "^4.0.0 || ^5.0.0"
},
"keywords": ["vite", "vite-plugin"],
"repository": {
"type": "git",
"url": "git+https://github.com/username/vite-plugin-my-plugin.git"
}
}
构建配置
javascript
// build.config.js
import { defineBuildConfig } from 'unbuild'
export default defineBuildConfig({
entries: ['src/index'],
declaration: true,
clean: true,
rollup: {
emitCJS: true
}
})
本章小结
Vite 插件系统提供了强大的扩展能力:
- 兼容 Rollup 插件,生态丰富
- Vite 特有钩子增强开发体验
- 可以处理文件、注入代码、修改配置
- 通过插件实现各种自定义需求
掌握了插件开发,就能为项目定制专属工具链。