Vite 插件开发实战:从零编写一个自定义插件
前言
Vite 的插件系统是其强大的核心所在。Vite 插件本质上是一个 Rollup 插件的超集——它扩展了 Rollup 的插件接口,增加了开发服务器和 HMR 相关的 hooks。理解 Vite 插件体系,不仅能让你定制化构建流程,还能深入理解 Vite 的内部工作机制。
本文将从 Vite 插件的 hooks 体系讲起,逐步实现两个实战插件:自动导入组件插件和 Markdown 转 Vue 组件插件。
一、Vite 插件 Hooks 体系全解
import { Plugin } from 'vite'
export function myPlugin(options = {}): Plugin {
return {
name: 'vite-plugin-my-plugin',
// === Rollup 兼容 Hooks ===
buildStart(options) { /* 构建开始前 */ },
resolveId(source, importer) { /* 解析模块 ID */ },
load(id) { /* 加载模块内容 */ },
transform(code, id) { /* 转换模块代码 */ },
buildEnd(error) { /* 构建结束 */ },
generateBundle(options, bundle) { /* 生成产物 */ },
// === Vite 独有 Hooks ===
config(config, { mode }) { /* 配置 Vite */ },
configResolved(resolvedConfig) { /* 最终配置 */ },
configureServer(server) { /* 配置开发服务器 */ },
handleHotUpdate(ctx) { /* 自定义 HMR 更新 */ },
transformIndexHtml(html, ctx) { /* 转换 index.html */ },
}
}
Hooks 分类
| Hook 类型 | 执行时机 | 用途 |
|---|---|---|
config |
Vite 配置初始化 | 修改 Vite 配置 |
configResolved |
配置解析完成 | 读取最终配置 |
configureServer |
Dev Server 创建时 | 添加中间件 |
buildStart |
每次构建开始 | 初始化资源 |
resolveId |
模块解析阶段 | 自定义模块解析 |
load |
模块加载阶段 | 自定义模块内容 |
transform |
模块转换阶段 | 转换模块代码 |
transformIndexHtml |
HTML 转换阶段 | 修改入口 HTML |
handleHotUpdate |
HMR 更新时 | 自定义热更新 |
generateBundle |
产物生成阶段 | 修改构建产物 |
二、enforce 执行顺序
export function myPlugin(): Plugin {
return {
name: 'my-plugin',
enforce: 'pre', // 'pre' | 'post' | undefined
}
}
执行顺序:pre → Vite 核心插件 → 普通插件 → Vite 构建插件 → post
三、虚拟模块模式
export function virtualModulePlugin(): Plugin {
const virtualModuleId = 'virtual:my-module'
const resolvedVirtualModuleId = '\0' + virtualModuleId
return {
name: 'vite-plugin-virtual-module',
enforce: 'pre',
resolveId(id) {
if (id === virtualModuleId) return resolvedVirtualModuleId
},
load(id) {
if (id === resolvedVirtualModuleId) {
const data = getSomeData()
return `const data = ${JSON.stringify(data)}; export default data`
}
},
}
}
虚拟模块 ID 以
\0开头是 Vite/Rollup 的约定,表示虚拟模块不对应磁盘文件。
四、实战一:自动导入组件插件
import { Plugin, createFilter } from 'vite'
import fs from 'fs'
import path from 'path'
import { parse } from '@vue/compiler-sfc'
export function autoImportComponents(options = {}): Plugin {
const { dirs = ['src/components'], extensions = ['.vue'] } = options
const componentMap = new Map<string, string>()
function scanComponents(dir: string) {
if (!fs.existsSync(dir)) return
const items = fs.readdirSync(dir, { withFileTypes: true })
for (const item of items) {
const fullPath = path.join(dir, item.name)
if (item.isDirectory()) scanComponents(fullPath)
else if (extensions.some(ext => item.name.endsWith(ext))) {
const name = item.name.replace(/\.\w+$/, '')
componentMap.set(name, fullPath)
}
}
}
return {
name: 'vite-plugin-auto-import',
enforce: 'pre',
configResolved(config) {
dirs.forEach(dir => scanComponents(path.resolve(config.root, dir)))
},
configureServer(server) {
server.watcher.on('add', (file) => {
const name = path.basename(file).replace(/\.\w+$/, '')
componentMap.set(name, file)
})
server.watcher.on('unlink', (file) => {
const name = path.basename(file).replace(/\.\w+$/, '')
componentMap.delete(name)
})
},
transform(code, id) {
if (!id.endsWith('.vue')) return null
const { descriptor } = parse(code)
if (!descriptor.template) return null
const tagRegex = /<([A-Z][a-zA-Z0-9]*)/g
const usedComponents: string[] = []
let match
while ((match = tagRegex.exec(descriptor.template.content)) !== null) {
if (componentMap.has(match[1]) && !usedComponents.includes(match[1])) {
usedComponents.push(match[1])
}
}
if (usedComponents.length === 0) return null
const imports = usedComponents.map(name => {
const filePath = componentMap.get(name)!
const relativePath = path.relative(path.dirname(id), filePath)
return `import ${name} from '${relativePath}'`
}).join('\n')
return code.replace('<script setup>', `<script setup>\n${imports}`)
},
}
}
五、实战二:Markdown 转 Vue 组件插件
import { Plugin } from 'vite'
import matter from 'gray-matter'
import MarkdownIt from 'markdown-it'
export function markdownPlugin(options = {}): Plugin {
const md = new MarkdownIt({ html: true, linkify: true })
return {
name: 'vite-plugin-markdown',
enforce: 'pre',
resolveId(id) { if (id.endsWith('.md')) return id },
transform(code, id) {
if (!id.endsWith('.md')) return null
const { content, data: frontmatter } = matter(code)
const htmlContent = md.render(content)
const escapedHtml = htmlContent.replace(/`/g, '\\`').replace(/\$\{/g, '\\${')
return {
code: `<template><div class="markdown-body" v-html="renderedHtml"></div></template>
<script setup lang="ts">
import { ref } from 'vue'
const frontmatter = ${JSON.stringify(frontmatter)}
const renderedHtml = ref(\`${escapedHtml}\`)
defineExpose({ frontmatter })
</script>`,
map: { version: 3, mappings: '', sources: [] },
}
},
handleHotUpdate(ctx) {
if (!ctx.file.endsWith('.md')) return
ctx.server.moduleGraph.invalidateModule(
ctx.server.moduleGraph.getModuleById(ctx.file)!
)
ctx.server.ws.send({ type: 'update', updates: [{
type: 'js-update', path: ctx.file,
acceptedPath: ctx.file, timestamp: Date.now(),
}] })
return []
},
}
}
六、HMR API 深度解析
handleHotUpdate
handleHotUpdate(ctx) {
const { file, server, modules } = ctx
// 场景 1: 返回空数组,阻止默认更新
if (file.endsWith('.data')) {
server.ws.send({ type: 'custom', event: 'data-updated' })
return []
}
// 场景 2: 触发完整刷新
if (file.endsWith('.config.js')) {
server.ws.send({ type: 'full-reload' })
return []
}
}
客户端 HMR API
if (import.meta.hot) {
import.meta.hot.accept((newModule) => { /* 接受自身更新 */ })
import.meta.hot.accept('./utils', (newUtils) => { utils = newUtils })
import.meta.hot.dispose((data) => { clearInterval(timer); data.count = count })
import.meta.hot.on('data-updated', (data) => { console.log(data) })
}
七、插件性能优化
缓存策略
transform(code, id) {
const stat = fs.statSync(id)
const cached = cache.get(id)
if (cached && cached.mtime === stat.mtimeMs) {
return { code: cached.code, map: null }
}
const transformedCode = doTransform(code)
cache.set(id, { code: transformedCode, mtime: stat.mtimeMs })
return { code: transformedCode, map: null }
}
延迟初始化
let parser: any = null
transform(code, id) {
if (!parser) parser = require('heavy-parser') // 按需加载
}
八、最佳实践总结
| 实践 | 建议 |
|---|---|
| 命名规范 | 使用 vite-plugin-* 前缀 |
| enforce | 转换型插件用 pre,注入型用 post |
| 虚拟模块 | 使用 \0 前缀标记 |
| HMR | 始终实现 handleHotUpdate 提升体验 |
| 缓存 | 使用文件 mtime 做缓存失效 |
| 类型安全 | 为 options 和返回值提供 TypeScript 类型 |
| SSR 支持 | 通过 apply 属性区分环境 |
| 错误处理 | 捕获异常并提供有意义的错误信息 |
Vite 插件系统是一个设计精良的扩展机制。通过掌握 resolveId/load/transform 三大核心 hooks,配合 configureServer 和 handleHotUpdate,你可以实现几乎任何构建需求的插件。善用缓存、异步处理和 Worker 线程来保证插件性能。
Comments | 0条评论