Vite 插件开发实战:从原理到自定义构建流水线
构建时转换→transformload(代码转换、注入)开发服务器增强→(HMR、代理)HTML 注入→(脚本、样式注入)产物处理→(分析、压缩)掌握这 5 个插件的实现思路,你就能应对 90% 的定制构建需求。建议从简单的环境变量注入开始,逐步挑战路由生成和产物分析。插件开发是前端工程化的高级技能,值得深入投入。
前言
Vite 之所以快,核心在于它的插件机制。Rollup 兼容插件 + Vite 独有钩子,构成了一个灵活而强大的构建流水线。但很多开发者只会用现成插件,遇到定制需求就束手无策——比如自动生成路由、注入全局变量、自定义代码转换等。
本文从 Vite 插件原理出发,手把手实现 5 个实用插件,帮你掌握 Vite 插件开发的核心技能。
一、Vite 插件机制原理
1.1 插件钩子执行顺序
Vite 插件基于 Rollup 插件接口扩展,在开发模式(dev)和生产构建(build)中执行不同的钩子:
开发模式(dev): configResolved → configureServer → transform → handleHotUpdate 生产构建(build): configResolved → buildStart → resolveId → load → transform → renderChunk → generateBundle → writeBundle
1.2 Vite 独有钩子
| 钩子 | 模式 | 用途 |
|---|---|---|
config |
dev + build | 修改 Vite 配置 |
configResolved |
dev + build | 读取最终配置 |
configureServer |
dev | 配置开发服务器 |
transformIndexHtml |
dev + build | 转换 index.html |
handleHotUpdate |
dev | 自定义 HMR 行为 |
二、插件 1:自动生成路由
2.1 基于文件系统的路由
// plugins/vite-auto-routes.ts
import { Plugin } from 'vite';
import fs from 'fs';
import path from 'path';
interface AutoRoutesOptions {
pagesDir: string; // 页面目录,默认 'src/pages'
exclude?: string[]; // 排除的文件
}
export function viteAutoRoutes(options: AutoRoutesOptions = {}): Plugin {
const { pagesDir = 'src/pages', exclude = [] } = options;
const virtualModuleId = 'virtual:auto-routes';
const resolvedVirtualModuleId = '\0' + virtualModuleId;
function scanPages(root: string): RouteItem[] {
const pagesPath = path.resolve(root, pagesDir);
if (!fs.existsSync(pagesPath)) return [];
const routes: RouteItem[] = [];
function walk(dir: string, basePath: string) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (exclude.includes(entry.name)) continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(fullPath, `${basePath}/${entry.name}`);
} else if (entry.name === 'index.vue' || entry.name.endsWith('.vue')) {
const routePath = basePath || '/';
const name = entry.name.replace('.vue', '');
routes.push({
path: routePath === '/' ? '/' : routePath,
component: `() => import('${fullPath.replace(/\\/g, '/')}')`,
});
}
}
}
walk(pagesPath, '');
return routes;
}
return {
name: 'vite-auto-routes',
resolveId(id) {
if (id === virtualModuleId) return resolvedVirtualModuleId;
},
load(id) {
if (id !== resolvedVirtualModuleId) return;
// 这个方法在 load 时会被调用,但需要 root
return null; // 在 configResolved 中处理
},
configResolved(config) {
this._routes = scanPages(config.root);
},
// 开发模式下文件变化时重新生成
configureServer(server) {
server.watcher.add(path.resolve(server.config.root, pagesDir));
server.watcher.on('add', () => {
server.moduleGraph.invalidateModule(
server.moduleGraph.getModuleById(resolvedVirtualModuleId)!
);
server.ws.send({ type: 'full-reload' });
});
},
};
}
2.2 使用方式
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router';
import routes from 'virtual:auto-routes';
export const router = createRouter({
history: createWebHistory(),
routes,
});
三、插件 2:环境变量注入
3.1 运行时动态注入
// plugins/vite-inject-env.ts
import { Plugin, ResolvedConfig } from 'vite';
interface InjectEnvOptions {
// 需要注入到 window 上的变量
vars: Record<string, string>;
// 是否在 index.html 中注入
injectHtml?: boolean;
}
export function viteInjectEnv(options: InjectEnvOptions): Plugin {
const { vars, injectHtml = true } = options;
let config: ResolvedConfig;
return {
name: 'vite-inject-env',
configResolved(resolvedConfig) {
config = resolvedConfig;
},
transformIndexHtml(html) {
if (!injectHtml) return html;
const scriptContent = Object.entries(vars)
.map(([key, value]) => `window.${key} = "${value}";`)
.join('\n');
return html.replace(
'</head>',
`<script>${scriptContent}</script>\n</head>`
);
},
// 生产构建时替换代码中的占位符
transform(code, id) {
if (!id.endsWith('.ts') && !id.endsWith('.vue') && !id.endsWith('.tsx')) {
return null;
}
let result = code;
for (const [key, value] of Object.entries(vars)) {
const pattern = new RegExp(`import\\.meta\\.env\\.VITE_${key}`, 'g');
result = result.replace(pattern, `"${value}"`);
}
return result !== code ? result : null;
},
};
}
四、插件 3:SVG 图标组件化
4.1 自动注册 SVG 为 Vue 组件
// plugins/vite-svg-icons.ts
import { Plugin } from 'vite';
import fs from 'fs';
import path from 'path';
interface SvgIconsOptions {
iconDirs: string[]; // SVG 文件目录
symbolId?: string; // symbol ID 模板
inject?: 'body-last' | 'body-first';
}
export function viteSvgIcons(options: SvgIconsOptions): Plugin {
const { iconDirs, symbolId = 'icon-[dir]-[name]', inject = 'body-last' } = options;
const virtualId = 'virtual:svg-icons-register';
const resolvedVirtualId = '\0' + virtualId;
let svgSprite = '';
function collectSvgs() {
const symbols: string[] = [];
for (const dir of iconDirs) {
if (!fs.existsSync(dir)) continue;
const files = fs.readdirSync(dir);
for (const file of files) {
if (!file.endsWith('.svg')) continue;
const filePath = path.join(dir, file);
const name = path.basename(file, '.svg');
const dirName = path.basename(dir);
const id = symbolId
.replace('[dir]', dirName)
.replace('[name]', name);
let content = fs.readFileSync(filePath, 'utf-8');
// 移除 xml 声明和注释
content = content.replace(/<\?xml.*?\?>/g, '');
content = content.replace(/<!--.*?-->/g, '');
// 将 svg 改为 symbol
content = content.replace(
/<svg([^>]*)>/,
`<symbol id="${id}"$1>`
);
content = content.replace('</svg>', '</symbol>');
symbols.push(content);
}
}
svgSprite = `<svg xmlns="http://www.w3.org/2000/svg" style="display:none">${symbols.join('')}</svg>`;
}
return {
name: 'vite-svg-icons',
buildStart() {
collectSvgs();
},
resolveId(id) {
if (id === virtualId) return resolvedVirtualId;
},
load(id) {
if (id === resolvedVirtualId) {
return `document.body.insertAdjacentHTML('${inject}', '${svgSprite.replace(/'/g, "\\'").replace(/\n/g, '')}');`;
}
},
transformIndexHtml(html) {
const injectPosition = inject === 'body-first' ? '<body>' : '</body>';
const replacement = inject === 'body-first'
? `<body>${svgSprite}`
: `${svgSprite}</body>`;
return html.replace(injectPosition, replacement);
},
configureServer(server) {
for (const dir of iconDirs) {
server.watcher.add(dir);
}
server.watcher.on('add', collectSvgs);
server.watcher.on('change', collectSvgs);
},
};
}
4.2 使用方式
<!-- 使用 SVG sprite --> <template> <svg class="icon" width="24" height="24"> <use href="#icon-icons-home" /> </svg> </template>
五、插件 4:代码自动注入
5.1 自动注入组件导入
// plugins/vite-auto-import.ts
import { Plugin } from 'vite';
interface AutoImportOptions {
imports: Record<string, string[]>; // 模块 → 导出名称
dts?: string; // 类型声明文件路径
}
export function viteAutoImport(options: AutoImportOptions): Plugin {
const { imports, dts = 'src/auto-imports.d.ts' } = options;
const virtualId = 'virtual:auto-import';
const resolvedVirtualId = '\0' + virtualId;
function generateImportCode(): string {
const lines: string[] = [];
for (const [module, names] of Object.entries(imports)) {
lines.push(`import { ${names.join(', ')} } from '${module}';`);
}
return lines.join('\n');
}
function generateDts(): string {
const declarations: string[] = [];
for (const [module, names] of Object.entries(imports)) {
for (const name of names) {
declarations.push(`const ${name}: typeof import('${module}')['${name}']`);
}
}
return `// Auto generated by vite-auto-import\ndeclare global {\n ${declarations.join(';\n ')};\n}\nexport {};\n`;
}
return {
name: 'vite-auto-import',
resolveId(id) {
if (id === virtualId) return resolvedVirtualModuleId;
},
load(id) {
if (id === resolvedVirtualId) {
return generateImportCode();
}
},
// 在每个 Vue/TS 文件头部自动注入
transform(code, id) {
if (!id.endsWith('.vue') && !id.endsWith('.tsx')) return null;
if (id.includes('node_modules')) return null;
// 在 <script setup> 中注入
if (code.includes('<script setup')) {
return code.replace(
'<script setup',
`<script setup\n// @auto-import\nimport '${virtualId}';\n<script setup`
);
}
return null;
},
buildEnd() {
// 生成类型声明文件
const fs = require('fs');
const path = require('path');
fs.writeFileSync(dts, generateDts(), 'utf-8');
},
};
}
六、插件 5:构建产物分析
6.1 体积分析与报告
// plugins/vite-bundle-analyzer.ts
import { Plugin, OutputAsset, OutputChunk } from 'rollup';
interface BundleAnalyzerOptions {
outputFile?: string; // 报告输出路径
threshold?: number; // 告警阈值(KB)
open?: boolean; // 是否自动打开报告
}
interface ModuleInfo {
name: string;
size: number;
gzipSize: number;
type: 'chunk' | 'asset';
imports?: string[];
}
export function viteBundleAnalyzer(options: BundleAnalyzerOptions = {}): Plugin {
const { outputFile = 'bundle-report.html', threshold = 100, open = false } = options;
const modules: ModuleInfo[] = [];
// 简易 gzip 大小估算
function estimateGzipSize(content: string | Buffer): number {
const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content);
// 经验值:gzip 压缩率约为原始大小的 30%
return Math.round(buffer.length * 0.3);
}
return {
name: 'vite-bundle-analyzer',
generateBundle(_, bundle) {
for (const [fileName, output] of Object.entries(bundle)) {
const info: ModuleInfo = {
name: fileName,
size: 0,
gzipSize: 0,
type: output.type,
};
if (output.type === 'chunk') {
const chunk = output as OutputChunk;
info.size = Buffer.byteLength(chunk.code, 'utf-8');
info.gzipSize = estimateGzipSize(chunk.code);
info.imports = chunk.imports;
} else {
const asset = output as OutputAsset;
const source = typeof asset.source === 'string'
? asset.source
: Buffer.from(asset.source as ArrayBuffer);
info.size = Buffer.isBuffer(source) ? source.length : Buffer.byteLength(source, 'utf-8');
info.gzipSize = estimateGzipSize(source);
}
modules.push(info);
}
},
writeBundle() {
// 按大小排序
modules.sort((a, b) => b.size - a.size);
// 超过阈值告警
const warnings = modules.filter(m => m.size > threshold * 1024);
if (warnings.length > 0) {
console.warn('\n[Bundle Analyzer] 超过阈值的模块:');
for (const w of warnings) {
console.warn(` ${w.name}: ${(w.size / 1024).toFixed(1)}KB (gzip: ${(w.gzipSize / 1024).toFixed(1)}KB)`);
}
}
// 生成 HTML 报告
const html = generateReport(modules, threshold);
const fs = require('fs');
fs.writeFileSync(outputFile, html, 'utf-8');
console.log(`\n[Bundle Analyzer] 报告已生成: ${outputFile}`);
if (open) {
require('child_process').exec(`start ${outputFile}`);
}
},
};
}
function generateReport(modules: ModuleInfo[], threshold: number): string {
const rows = modules.map(m => `
<tr class="${m.size > threshold * 1024 ? 'warning' : ''}">
<td>${m.name}</td>
<td>${(m.size / 1024).toFixed(1)} KB</td>
<td>${(m.gzipSize / 1024).toFixed(1)} KB</td>
<td>${m.type}</td>
</tr>
`).join('');
return `<!DOCTYPE html>
<html><head><title>Bundle Report</title>
<style>
body { font-family: monospace; margin: 20px; }
table { border-collapse: collapse; width: 100%; }
th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #eee; }
.warning { background: #fff3cd; }
</style></head><body>
<h1>Bundle Analysis Report</h1>
<p>Total modules: ${modules.length} | Threshold: ${threshold}KB</p>
<table><tr><th>Module</th><th>Size</th><th>Gzip</th><th>Type</th></tr>
${rows}</table></body></html>`;
}
七、插件开发最佳实践
| 实践 | 说明 |
|---|---|
| 使用虚拟模块 | 避免文件系统 IO,用 virtual: 前缀 + \0 标识 |
| 区分 dev/build | apply: 'serve' 或 apply: 'build' 限制钩子执行环境 |
| HMR 支持 | configureServer + handleHotUpdate 实现热更新 |
| 类型声明 | 为虚拟模块提供 .d.ts 类型声明 |
| 副作用清理 | buildEnd / closeBundle 中释放资源 |
| 错误处理 | 使用 this.error() 而非 throw,提供更好的错误信息 |
总结
Vite 插件开发的核心在于理解钩子执行时机和适用场景:
-
构建时转换 →
transform+load(代码转换、注入) -
开发服务器增强 →
configureServer+handleHotUpdate(HMR、代理) -
HTML 注入 →
transformIndexHtml(脚本、样式注入) -
产物处理 →
generateBundle+writeBundle(分析、压缩)
掌握这 5 个插件的实现思路,你就能应对 90% 的定制构建需求。建议从简单的环境变量注入开始,逐步挑战路由生成和产物分析。插件开发是前端工程化的高级技能,值得深入投入。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐



所有评论(0)