08 | 手把手:5 个Vite实战插件从入门到精通
Vite 深度系列 · 第二阶段 · 技术实现深挖
预计阅读:12 分钟 | 难度:⭐⭐⭐⭐
核心问题:如何从零写出生产级 Vite 插件?
插件总览
vite-plugin-banner | |||
vite-plugin-md2vue | |||
vite-plugin-auto-import | |||
vite-plugin-proxy-mock | |||
vite-plugin-i18n-extract |
插件 1:vite-plugin-banner(⭐)
目标:在构建产物头部注入版权注释。
// vite-plugin-banner.js
export default function vitePluginBanner(options = {}) {
const {
content = `/*! Built at ${new Date().toISOString()} */`,
enforce = 'post'
} = options
return {
name: 'vite-plugin-banner',
enforce, // post 确保在所有 transform 之后执行
// Vite 8 hookFilter:只在构建时对 JS 产物生效
transform: {
filter: { id: /\.[jt]sx?$/ },
handler(code, id) {
// 只在构建模式生效
if (this.environment?.command !== 'build') return null
return {
code: `${content}\n${code}`,
map: null // 不影响 sourcemap
}
}
},
// 更好的方式:使用 generateBundle 只修改最终产物
generateBundle(options, bundle) {
for (const [fileName, chunk] of Object.entries(bundle)) {
if (chunk.type === 'chunk' && chunk.isEntry) {
chunk.code = `${content}\n${chunk.code}`
}
}
}
}
}使用:
// vite.config.js
import banner from 'vite-plugin-banner'
export default defineConfig({
plugins: [
banner({ content: '/*! (c) 2026 MyCompany */' })
]
})学到什么:
• enforce: 'post'确保在最后执行• generateBundle比transform更适合修改最终产物• Vite 8 的 hookFilter减少不必要的调用
插件 2:vite-plugin-md2vue(⭐⭐)
目标:将 Markdown 文件作为 Vue 组件导入。
// vite-plugin-md2vue.js
import { marked } from 'marked'
export default function vitePluginMd2Vue() {
return {
name: 'vite-plugin-md2vue',
// 拦截 .md 文件的解析
resolveId(id) {
if (id.endsWith('.md')) return '\0' + id // 虚拟模块标记
},
// 加载并转换 Markdown
async load(id) {
if (!id.startsWith('\0') || !id.endsWith('.md')) return null
const filePath = id.slice(1) // 去掉 \0 前缀
const fs = await import('fs')
const raw = fs.readFileSync(filePath, 'utf-8')
// Markdown → HTML
const html = marked(raw)
// HTML → Vue SFC
const vueCode = `
<template>
<div class="markdown-body">${html}</div>
</template>
<script>
export default { name: 'MarkdownDoc' }
</script>
<style>
.markdown-body { line-height: 1.7; max-width: 800px; margin: auto; }
</style>
`
return vueCode
},
// HMR 支持
handleHotUpdate({ file, server }) {
if (file.endsWith('.md')) {
server.ws.send({ type: 'full-reload' })
return [] // 阻止默认 HMR,强制全量刷新
}
}
}
}使用:
<script setup>
// 直接导入 Markdown 文件作为 Vue 组件
import ReadmeDoc from './README.md'
</script>
<template>
<ReadmeDoc />
</template>学到什么:
• 虚拟模块的 \0前缀约定• resolveId→load的配合模式• handleHotUpdate实现 Markdown 热更新
插件 3:vite-plugin-auto-import(⭐⭐⭐)
目标:自动导入常用 API,无需手动 import。
// vite-plugin-auto-import.js
import MagicString from 'magic-string'
export default function vitePluginAutoImport(options = {}) {
const {
imports = {
'vue': ['ref', 'reactive', 'computed', 'watch', 'onMounted'],
'vue-router': ['useRouter', 'useRoute']
}
} = options
// 扫描结果缓存
const cache = new Map()
return {
name: 'vite-plugin-auto-import',
enforce: 'post',
transform: {
filter: { id: /\.[vue|tsx|jsx]$/ },
handler(code, id) {
const s = new MagicString(code)
const usedImports = new Map() // module → Set<names>
// 扫描代码中使用的 API
for (const [module, names] of Object.entries(imports)) {
for (const name of names) {
// 简单正则匹配(生产级应使用 AST)
const regex = new RegExp(`\\b${name}\\b`, 'g')
if (regex.test(code)) {
// 检查是否已有 import 声明
const hasImport = new RegExp(
`import\\s+.*\\b${name}\\b.*from\\s+['"]${module}['"]`
).test(code)
if (!hasImport) {
if (!usedImports.has(module)) usedImports.set(module, new Set())
usedImports.get(module).add(name)
}
}
}
}
// 在文件顶部注入 import 语句
if (usedImports.size > 0) {
const importLines = []
for (const [module, names] of usedImports) {
importLines.push(
`import { ${[...names].join(', ')} } from '${module}'`
)
}
s.prepend(importLines.join('\n') + '\n')
cache.set(id, importLines)
}
return {
code: s.toString(),
map: s.generateMap({ hires: true })
}
}
}
}
}使用:
<script setup>
// 无需 import ref, computed, onMounted
const count = ref(0)
const double = computed(() => count.value * 2)
onMounted(() => console.log('mounted'))
</script>学到什么:
• MagicString精确修改源码(保留 sourcemap)• 扫描 + 注入的插件模式 • enforce: 'post'确保在其他插件转换之后执行
插件 4:vite-plugin-proxy-mock(⭐⭐⭐)
目标:开发时自动 Mock API 请求,零配置。
// vite-plugin-proxy-mock.js
import fs from 'fs'
import path from 'path'
import url from 'url'
export default function vitePluginProxyMock(options = {}) {
const {
mockDir = 'mock',
prefix = '/api'
} = options
return {
name: 'vite-plugin-proxy-mock',
enforce: 'pre',
apply: 'serve', // 只在开发模式生效
configureServer(server) {
// 读取 mock 文件
function loadMocks() {
const mockPath = path.resolve(process.cwd(), mockDir)
if (!fs.existsSync(mockPath)) return new Map()
const mocks = new Map()
const files = fs.readdirSync(mockPath)
for (const file of files) {
if (!file.endsWith('.js') && !file.endsWith('.json')) continue
const filePath = path.join(mockPath, file)
// 清除缓存以支持热更新
delete require.cache[require.resolve(filePath)]
const data = require(filePath)
for (const [key, value] of Object.entries(data)) {
mocks.set(key, value)
}
}
return mocks
}
// 注册中间件
server.middlewares.use((req, res, next) => {
if (!req.url?.startsWith(prefix)) return next()
const parsedUrl = url.parse(req.url)
const route = `${req.method} ${parsedUrl.pathname}`
const mocks = loadMocks()
const handler = mocks.get(route)
if (!handler) return next()
// 支持函数式 handler
const result = typeof handler === 'function'
? handler(req)
: handler
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(result))
})
// 监听 mock 文件变化
const mockPath = path.resolve(process.cwd(), mockDir)
server.watcher.add(mockPath)
server.watcher.on('change', (file) => {
if (file.startsWith(mockPath)) {
console.log('[mock] 文件变化,重新加载:', file)
}
})
}
}
}Mock 文件(mock/user.js):
module.exports = {
'GET /api/user': { name: 'Zhang San', age: 28 },
'POST /api/login': (req) => ({ token: 'fake-jwt-token', success: true }),
'GET /api/list': {
items: Array.from({ length: 10 }, (_, i) => ({ id: i, title: `Item ${i}` })),
total: 10
}
}学到什么:
• configureServer访问 Vite Dev Server 实例• server.middlewares添加 Express 风格中间件• server.watcher监听文件变化实现 Mock 热更新
插件 5:vite-plugin-i18n-extract(⭐⭐⭐⭐)
目标:自动提取代码中的中文文本,生成 i18n JSON 文件。
// vite-plugin-i18n-extract.js
import fs from 'fs'
import path from 'path'
import { parse } from '@babel/parser'
import traverse from '@babel/traverse'
import MagicString from 'magic-string'
export default function vitePluginI18nExtract(options = {}) {
const {
output = 'src/locales/zh-CN.json',
prefix = 't',
exclude = [/node_modules/, /\.test\./]
} = options
// 提取结果
const extracted = new Map() // key → value
let dirty = false
// 检测中文字符
const CJK_REGEX = /[\u4e00-\u9fff]+/
return {
name: 'vite-plugin-i18n-extract',
enforce: 'pre',
// configResolved 中初始化
configResolved(config) {
// 加载已有翻译
const outputPath = path.resolve(config.root, output)
if (fs.existsSync(outputPath)) {
const existing = JSON.parse(fs.readFileSync(outputPath, 'utf-8'))
for (const [k, v] of Object.entries(existing)) {
extracted.set(k, v)
}
}
},
transform: {
filter: { id: /\.[vue|tsx|jsx|ts|js]$/ },
handler(code, id) {
// 排除文件
if (exclude.some(re => re.test(id))) return null
const s = new MagicString(code)
let hasChanges = false
try {
// 解析 AST
const ast = parse(code, {
sourceType: 'module',
plugins: ['jsx', 'typescript', 'vue']
})
// 遍历 AST,查找中文字符串
traverse(ast, {
StringLiteral(path) {
const value = path.node.value
if (!CJK_REGEX.test(value)) return
// 生成 key
const key = value.replace(/[^\w\u4e00-\u9fff]/g, '_')
extracted.set(key, value)
dirty = true
// 替换为 t() 调用
const start = path.node.start
const end = path.node.end
s.overwrite(start, end, `${prefix}('${key}')`)
hasChanges = true
}
})
} catch (e) {
// AST 解析失败,跳过
}
if (!hasChanges) return null
return {
code: s.toString(),
map: s.generateMap({ hires: true })
}
}
},
// 构建结束后写入 JSON
buildEnd() {
if (!dirty) return
const result = Object.fromEntries(
[...extracted.entries()].sort()
)
const outputPath = path.resolve(output)
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
fs.writeFileSync(outputPath, JSON.stringify(result, null, 2) + '\n')
console.log(`[i18n] 提取 ${extracted.size} 条翻译 → ${output}`)
dirty = false
}
}
}学到什么:
• 多钩子协作: configResolved→transform→buildEnd• Babel AST 解析 + traverse 遍历 • 增量提取 + 文件缓存 • 脏标记模式(dirty flag)避免不必要的写入
插件开发 Checklist
\0 前缀 | |||
测试与发布
单元测试(Vitest)
// vite-plugin-banner.test.js
import { describe, it, expect } from 'vitest'
import { build } from 'vite'
import banner from './vite-plugin-banner'
describe('vite-plugin-banner', () => {
it('should inject banner into bundle', async () => {
const result = await build({
plugins: [banner({ content: '/*! test */' })],
build: { write: false }
})
const output = result.output[0]
expect(output.code).toContain('/*! test */')
})
})发布流程
# 1. 构建
npx tsup src/index.ts --format esm,cjs --dts
# 2. 测试
npm test
# 3. 版本号
npm version patch # minor / major
# 4. 发布
npm publish --access public速查卡
┌──────────────────────────────────────────────────┐
│ Vite 插件开发速查 │
├─────────────┬────────────────────────────────────┤
│ 入门 │ transform + generateBundle │
│ 虚拟模块 │ resolveId(\0id) + load(id) │
│ 开发服务器 │ configureServer + middlewares │
│ 热更新 │ handleHotUpdate({ file, server }) │
│ 代码注入 │ MagicString.prepend / overwrite │
│ AST 操作 │ @babel/parser + @babel/traverse │
│ Vite 8 优化 │ hookFilter + moduleType │
│ 缓存 │ Map + dirty flag + buildEnd 写入 │
└─────────────┴────────────────────────────────────┘下期预告
09 | Rolldown 深度解析 — Rust 重写 Rollup 的技术突围,为什么它能同时替代 esbuild 和 Rollup?
💡 移动APP开发 | 资讯·工具·教程·社区
📱 关注我们,获取更多移动开发技术干货
💬 加入社群,与全国开发者交流成长
❤️ 觉得有用?点个"在看"分享给更多人!
夜雨聆风