Pixiv - KiraraShss
Vite 插件开发实战:从零手写一个自动导入插件
3300 字
17 分钟
Vite 插件开发实战:从零手写一个自动导入插件
Vite 插件开发实战:从零手写一个自动导入插件
Vite 的插件系统基于 Rollup,但又在其之上扩展了一套开发服务器专属的钩子。理解这套体系,你就能随心所欲地定制构建流程。本文将系统讲解 Vite 插件 API,然后从零实现一个类似 unplugin-auto-import 的自动导入插件。
一、Vite 插件基础
1.1 插件的本质
一个 Vite 插件就是一个返回对象的函数:
import type { Plugin } from 'vite';
function myPlugin(): Plugin { return { name: 'my-plugin', // 必须,唯一标识 // ... 各种钩子 };}在 vite.config.ts 中使用:
import { defineConfig } from 'vite';import myPlugin from './plugins/my-plugin';
export default defineConfig({ plugins: [myPlugin()],});1.2 插件执行顺序
Vite 插件有三个执行阶段,通过 enforce 控制:
function myPlugin(): Plugin { return { name: 'my-plugin', enforce: 'pre', // 'pre' | 默认 | 'post' };}执行顺序:
- Alias 解析
enforce: 'pre'的用户插件- Vite 核心插件
- 没有
enforce的用户插件 - Vite 构建插件
enforce: 'post'的用户插件- Vite 后置构建插件(minify、manifest 等)
1.3 条件应用
通过 apply 属性控制插件在开发还是构建时生效:
function myPlugin(): Plugin { return { name: 'my-plugin', apply: 'build', // 仅构建时生效 // apply: 'serve', // 仅开发时生效 // 也可以是函数 // apply(config, { command }) { // return command === 'build' && !config.build?.ssr; // }, };}二、核心钩子详解
2.1 config — 修改配置
在 Vite 配置被解析之前调用,可以修改或扩展配置:
function myPlugin(): Plugin { return { name: 'config-plugin', config(config, { command, mode }) { // command: 'serve' | 'build' // mode: 'development' | 'production' | ... console.log(`Running ${command} in ${mode} mode`);
// 返回一个部分配置,会深度合并到最终配置 return { define: { __APP_VERSION__: JSON.stringify('1.0.0'), }, resolve: { alias: { '@utils': '/src/utils', }, }, }; }, };}2.2 configResolved — 读取最终配置
配置解析完成后调用,用于读取(不应修改)最终配置:
function myPlugin(): Plugin { let config: ResolvedConfig;
return { name: 'resolved-config-plugin', configResolved(resolvedConfig) { config = resolvedConfig; console.log('Root:', config.root); console.log('Mode:', config.mode); console.log('Is build:', config.command === 'build'); }, };}2.3 configureServer — 自定义开发服务器
用于扩展 Vite 开发服务器(仅开发时):
function apiMockPlugin(): Plugin { return { name: 'api-mock', configureServer(server) { // 添加自定义中间件(在 Vite 内部中间件之前) server.middlewares.use('/api/user', (req, res) => { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ id: 1, name: 'Mock User' })); });
// 返回函数则在内部中间件之后执行 return () => { server.middlewares.use((req, res, next) => { // 这里的中间件在 Vite 处理之后 next(); }); }; }, };}2.4 transformIndexHtml — 转换 HTML
用于转换入口 HTML 文件:
function htmlPlugin(): Plugin { return { name: 'html-transform', transformIndexHtml(html) { // 方式一:直接返回修改后的 HTML 字符串 return html.replace( '</head>', `<script>window.__TIMESTAMP__ = ${Date.now()}</script>\n</head>` ); }, };}
// 更强大的标签注入方式function htmlTagPlugin(): Plugin { return { name: 'html-tag-inject', transformIndexHtml() { return [ { tag: 'meta', attrs: { name: 'version', content: '1.0.0' }, injectTo: 'head', }, { tag: 'script', attrs: { src: '/analytics.js', defer: true }, injectTo: 'body', }, { tag: 'link', attrs: { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, injectTo: 'head-prepend', }, ]; }, };}2.5 resolveId — 自定义模块解析
拦截模块导入,返回自定义的模块 ID:
const VIRTUAL_MODULE_ID = 'virtual:my-module';const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;
function virtualModulePlugin(): Plugin { return { name: 'virtual-module', resolveId(id) { if (id === VIRTUAL_MODULE_ID) { // '\0' 前缀是 Rollup 约定,表示这是虚拟模块 return RESOLVED_VIRTUAL_MODULE_ID; } }, load(id) { if (id === RESOLVED_VIRTUAL_MODULE_ID) { return `export const msg = "Hello from virtual module!"`; } }, };}使用:
import { msg } from 'virtual:my-module';console.log(msg); // "Hello from virtual module!"2.6 load — 自定义模块加载
为特定模块提供内容:
function yamlPlugin(): Plugin { return { name: 'yaml-loader', resolveId(id) { if (id.endsWith('.yaml')) { return id; // 标记为需要处理 } }, async load(id) { if (id.endsWith('.yaml')) { const fs = await import('fs/promises'); const yaml = await import('js-yaml'); const content = await fs.readFile(id, 'utf-8'); const data = yaml.load(content); return `export default ${JSON.stringify(data)}`; } }, };}2.7 transform — 转换模块代码
这是最常用的钩子,用于转换模块的代码内容:
function timestampPlugin(): Plugin { return { name: 'timestamp-transform', transform(code, id) { if (id.endsWith('.ts') || id.endsWith('.js')) { // 替换代码中的魔术常量 const transformed = code.replace( /__BUILD_TIME__/g, JSON.stringify(new Date().toISOString()) );
return { code: transformed, map: null, // 简单替换可以不生成 sourcemap }; } }, };}2.8 handleHotUpdate — 自定义 HMR
function customHmrPlugin(): Plugin { return { name: 'custom-hmr', handleHotUpdate({ file, server, modules }) { if (file.endsWith('.custom')) { console.log('Custom file changed:', file);
// 发送自定义 HMR 事件 server.ws.send({ type: 'custom', event: 'custom-update', data: { file }, });
// 返回空数组阻止默认 HMR return []; } }, };}客户端监听:
if (import.meta.hot) { import.meta.hot.on('custom-update', (data) => { console.log('Custom file updated:', data.file); });}三、实战:手写自动导入插件
现在来实现一个真正的自动导入插件。目标:在代码中直接使用 ref、computed 等 Vue API,无需手动 import。
3.1 设计思路
- 扫描预设:定义哪些 API 从哪个包导入
- 代码分析:检测代码中使用了哪些未导入的 API
- 代码转换:在文件头部自动插入 import 语句
- 类型生成:生成
.d.ts文件让 IDE 也能识别
3.2 预设定义
export interface ImportInfo { name: string; // 导出名 as?: string; // 重命名 from: string; // 包名}
export interface Preset { [key: string]: ImportInfo;}
export function definePreset( packageName: string, exports: string[]): Preset { const preset: Preset = {}; for (const name of exports) { preset[name] = { name, from: packageName }; } return preset;}
// Vue 预设export const vuePreset = definePreset('vue', [ 'ref', 'reactive', 'computed', 'watch', 'watchEffect', 'onMounted', 'onUnmounted', 'onBeforeMount', 'onBeforeUnmount', 'nextTick', 'toRef', 'toRefs', 'unref', 'isRef', 'shallowRef', 'triggerRef', 'customRef', 'provide', 'inject', 'defineComponent', 'defineAsyncComponent', 'h', 'createApp',]);
// VueRouter 预设export const vueRouterPreset = definePreset('vue-router', [ 'useRouter', 'useRoute', 'createRouter', 'createWebHistory', 'createWebHashHistory', 'onBeforeRouteLeave', 'onBeforeRouteUpdate',]);
// Pinia 预设export const piniaPreset = definePreset('pinia', [ 'defineStore', 'storeToRefs', 'createPinia',]);3.3 代码扫描器
import type { ImportInfo, Preset } from './presets';
export interface ScanResult { imports: Map<string, ImportInfo[]>; // from => ImportInfo[] usedIdentifiers: Set<string>;}
/** * 使用正则扫描代码中使用的标识符 * 生产级实现应该用 AST,这里为了简洁用正则 */export function scanCode( code: string, presets: Preset[]): ScanResult { // 合并所有预设 const allImports = new Map<string, ImportInfo>(); for (const preset of presets) { for (const [key, info] of Object.entries(preset)) { allImports.set(key, info); } }
// 提取已有的 import 语句中的标识符 const existingImports = new Set<string>(); const importRegex = /import\s+\{([^}]+)\}\s+from\s+['"][^'"]+['"]/g; let match; while ((match = importRegex.exec(code)) !== null) { const names = match[1].split(',').map(s => s.trim().split(/\s+as\s+/).pop()!.trim()); names.forEach(n => existingImports.add(n)); }
// 移除字符串和注释,避免误匹配 const cleanCode = code .replace(/\/\*[\s\S]*?\*\//g, '') // 块注释 .replace(/\/\/.*/g, '') // 行注释 .replace(/'[^']*'/g, '""') // 单引号字符串 .replace(/"[^"]*"/g, '""') // 双引号字符串 .replace(/`[^`]*`/g, '""'); // 模板字符串
// 扫描使用的标识符 const usedIdentifiers = new Set<string>(); const imports = new Map<string, ImportInfo[]>();
for (const [name, info] of allImports) { if (existingImports.has(name)) continue;
// 使用词边界匹配,避免部分匹配 const regex = new RegExp(`\\b${name}\\b`); if (regex.test(cleanCode)) { usedIdentifiers.add(name);
if (!imports.has(info.from)) { imports.set(info.from, []); } imports.get(info.from)!.push(info); } }
return { imports, usedIdentifiers };}
/** * 生成 import 语句 */export function generateImports( imports: Map<string, ImportInfo[]>): string { const lines: string[] = [];
for (const [from, infos] of imports) { const specifiers = infos .map(info => info.as ? `${info.name} as ${info.as}` : info.name) .sort() .join(', ');
lines.push(`import { ${specifiers} } from '${from}';`); }
return lines.join('\n');}3.4 DTS 生成器
import type { Preset } from './presets';import { writeFileSync, mkdirSync } from 'fs';import { dirname } from 'path';
export function generateDts( presets: Preset[], filePath: string): void { const lines: string[] = [ '/* eslint-disable */', '/* prettier-ignore */', '// Auto-generated by vite-plugin-auto-import', '// Do not edit manually', '', 'export {}', '', 'declare global {', ];
// 按包分组 const byPackage = new Map<string, string[]>(); for (const preset of presets) { for (const [name, info] of Object.entries(preset)) { if (!byPackage.has(info.from)) { byPackage.set(info.from, []); } byPackage.get(info.from)!.push(name); } }
for (const [pkg, names] of byPackage) { lines.push(` // ${pkg}`); for (const name of names.sort()) { lines.push(` const ${name}: typeof import('${pkg}')['${name}']`); } }
lines.push('}', '');
mkdirSync(dirname(filePath), { recursive: true }); writeFileSync(filePath, lines.join('\n'), 'utf-8');}3.5 主插件实现
import type { Plugin, ResolvedConfig } from 'vite';import { scanCode, generateImports } from './scanner';import { generateDts } from './dts';import { vuePreset, vueRouterPreset, piniaPreset } from './presets';import type { Preset } from './presets';import { resolve } from 'path';
export interface AutoImportOptions { /** 预设列表 */ presets?: ('vue' | 'vue-router' | 'pinia')[]; /** 自定义导入映射 */ imports?: Record<string, string[]>; /** 生成 dts 文件的路径 */ dts?: string | boolean; /** 需要处理的文件 */ include?: RegExp[]; /** 排除的文件 */ exclude?: RegExp[];}
const builtinPresets: Record<string, Preset> = { 'vue': vuePreset, 'vue-router': vueRouterPreset, 'pinia': piniaPreset,};
export default function autoImport(options: AutoImportOptions = {}): Plugin { const { presets: presetNames = ['vue'], imports = {}, dts = './auto-imports.d.ts', include = [/\.[jt]sx?$/, /\.vue$/], exclude = [/node_modules/, /\.d\.ts$/], } = options;
// 解析预设 const presets: Preset[] = [];
for (const name of presetNames) { if (builtinPresets[name]) { presets.push(builtinPresets[name]); } }
// 解析自定义导入 if (Object.keys(imports).length > 0) { const customPreset: Preset = {}; for (const [pkg, names] of Object.entries(imports)) { for (const name of names) { customPreset[name] = { name, from: pkg }; } } presets.push(customPreset); }
let config: ResolvedConfig;
return { name: 'vite-plugin-auto-import', enforce: 'pre',
configResolved(resolvedConfig) { config = resolvedConfig;
// 生成 .d.ts 文件 if (dts) { const dtsPath = typeof dts === 'string' ? resolve(config.root, dts) : resolve(config.root, 'auto-imports.d.ts');
generateDts(presets, dtsPath); console.log(`[auto-import] Generated ${dtsPath}`); } },
transform(code, id) { // 过滤文件 if (exclude.some(re => re.test(id))) return; if (!include.some(re => re.test(id))) return;
// 处理 Vue SFC — 只处理 <script> 部分 let scriptCode = code; let scriptOffset = 0;
if (id.endsWith('.vue')) { const scriptMatch = code.match( /<script[^>]*>([\s\S]*?)<\/script>/ ); if (!scriptMatch) return; scriptCode = scriptMatch[1]; scriptOffset = code.indexOf(scriptCode); }
// 扫描代码 const result = scanCode(scriptCode, presets);
if (result.imports.size === 0) return;
// 生成 import 语句 const importStatements = generateImports(result.imports);
// 注入 import let newCode: string;
if (id.endsWith('.vue')) { // Vue SFC:在 <script> 标签内部顶部注入 const insertPos = scriptOffset; newCode = code.slice(0, insertPos) + '\n' + importStatements + '\n' + code.slice(insertPos); } else { // 普通 JS/TS:在文件顶部注入(跳过已有的 import) const lastImportIndex = findLastImportIndex(code); if (lastImportIndex >= 0) { newCode = code.slice(0, lastImportIndex) + '\n' + importStatements + code.slice(lastImportIndex); } else { newCode = importStatements + '\n' + code; } }
return { code: newCode, map: null, // 生产级应该生成 sourcemap }; }, };}
function findLastImportIndex(code: string): number { const lines = code.split('\n'); let lastImportLine = -1;
for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (line.startsWith('import ')) { lastImportLine = i; } }
if (lastImportLine === -1) return -1;
let index = 0; for (let i = 0; i <= lastImportLine; i++) { index += lines[i].length + 1; } return index;}3.6 使用方式
import { defineConfig } from 'vite';import vue from '@vitejs/plugin-vue';import autoImport from './plugins/auto-import';
export default defineConfig({ plugins: [ autoImport({ presets: ['vue', 'vue-router', 'pinia'], imports: { 'axios': ['axios'], '@vueuse/core': ['useStorage', 'useMouse', 'useToggle'], }, dts: './src/auto-imports.d.ts', }), vue(), ],});使用后的效果:
<!-- 不需要手动写 import --><script setup lang="ts">// ref, computed, onMounted 都会被自动导入const count = ref(0);const double = computed(() => count.value * 2);
const router = useRouter();const store = useCounterStore();
onMounted(() => { console.log('mounted!');});</script>编译后自动变成:
import { ref, computed, onMounted } from 'vue';import { useRouter } from 'vue-router';
const count = ref(0);const double = computed(() => count.value * 2);// ...四、进阶:支持目录扫描
除了预设,还可以扫描指定目录下的模块自动导入:
import { readdirSync, statSync } from 'fs';import { join, basename, extname, relative } from 'path';import type { Preset } from './presets';
export function scanDir( dir: string, rootDir: string): Preset { const preset: Preset = {};
function scan(currentDir: string) { const entries = readdirSync(currentDir);
for (const entry of entries) { const fullPath = join(currentDir, entry); const stat = statSync(fullPath);
if (stat.isDirectory()) { // 递归扫描子目录 scan(fullPath); } else if (/\.[jt]sx?$/.test(entry) && !entry.endsWith('.d.ts')) { const name = basename(entry, extname(entry)); const relativePath = relative(rootDir, fullPath) .replace(/\\/g, '/') .replace(/\.[jt]sx?$/, '');
// 假设默认导出以文件名命名 preset[name] = { name: 'default', as: name, from: `~/${relativePath}`, }; } } }
scan(dir); return preset;}配置使用:
autoImport({ dirs: ['./src/composables', './src/utils'],})五、测试插件
写插件不能少了测试:
import { describe, it, expect } from 'vitest';import { scanCode, generateImports } from '../scanner';import { vuePreset } from '../presets';
describe('scanCode', () => { it('should detect used Vue APIs', () => { const code = ` const count = ref(0); const double = computed(() => count.value * 2); onMounted(() => {}); `;
const result = scanCode(code, [vuePreset]);
expect(result.usedIdentifiers).toContain('ref'); expect(result.usedIdentifiers).toContain('computed'); expect(result.usedIdentifiers).toContain('onMounted'); expect(result.imports.has('vue')).toBe(true); });
it('should not duplicate existing imports', () => { const code = ` import { ref } from 'vue'; const count = ref(0); const double = computed(() => count.value * 2); `;
const result = scanCode(code, [vuePreset]);
expect(result.usedIdentifiers).not.toContain('ref'); expect(result.usedIdentifiers).toContain('computed'); });
it('should ignore identifiers in comments', () => { const code = ` // const count = ref(0); /* computed(() => {}) */ const x = 1; `;
const result = scanCode(code, [vuePreset]); expect(result.usedIdentifiers.size).toBe(0); });
it('should ignore identifiers in strings', () => { const code = ` const s = "ref is a function"; const t = 'computed value'; `;
const result = scanCode(code, [vuePreset]); expect(result.usedIdentifiers.size).toBe(0); });});
describe('generateImports', () => { it('should generate correct import statements', () => { const imports = new Map([ ['vue', [ { name: 'ref', from: 'vue' }, { name: 'computed', from: 'vue' }, ]], ]);
const result = generateImports(imports); expect(result).toBe("import { computed, ref } from 'vue';"); });});六、发布为 npm 包
6.1 项目结构
vite-plugin-auto-import/├── src/│ ├── index.ts│ ├── presets.ts│ ├── scanner.ts│ ├── dts.ts│ └── dir-scanner.ts├── __tests__/│ └── scanner.test.ts├── tsconfig.json├── package.json└── README.md6.2 package.json
{ "name": "vite-plugin-my-auto-import", "version": "1.0.0", "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "exports": { ".": { "import": "./dist/index.mjs", "require": "./dist/index.cjs", "types": "./dist/index.d.ts" } }, "files": ["dist"], "scripts": { "build": "tsup src/index.ts --format cjs,esm --dts", "test": "vitest", "prepublishOnly": "npm run build" }, "peerDependencies": { "vite": ">=4.0.0" }, "devDependencies": { "tsup": "^8.0.0", "typescript": "^5.0.0", "vite": "^5.0.0", "vitest": "^1.0.0" }}七、Vite 插件开发最佳实践
7.1 性能注意事项
function performantPlugin(): Plugin { // ✅ 在闭包中缓存,避免重复计算 const filter = /\.(ts|js|vue)$/; const cache = new Map<string, string>();
return { name: 'performant-plugin', transform(code, id) { // ✅ 尽早过滤,减少不必要的处理 if (!filter.test(id)) return; if (id.includes('node_modules')) return;
// ✅ 使用缓存 const cached = cache.get(id); if (cached === code) return; // 内容未变,跳过
cache.set(id, code);
// 实际转换逻辑... }, };}7.2 SourceMap 支持
生产级插件应该生成正确的 sourcemap:
import MagicString from 'magic-string';
function sourcemapPlugin(): Plugin { return { name: 'sourcemap-plugin', transform(code, id) { const s = new MagicString(code);
// 使用 MagicString 做精确的字符串操作 s.prepend('import { ref } from "vue";\n'); s.replace('__PLACEHOLDER__', '"replaced"');
return { code: s.toString(), map: s.generateMap({ source: id, file: `${id}.map`, includeContent: true, }), }; }, };}7.3 错误处理
function robustPlugin(): Plugin { return { name: 'robust-plugin', transform(code, id) { try { // 转换逻辑 return transformCode(code); } catch (e) { // Vite 会捕获并展示友好的错误信息 this.error(`Failed to transform ${id}: ${e.message}`); // 或者只是警告,不中断构建 // this.warn(`Warning in ${id}: ${e.message}`); } }, };}八、总结
Vite 插件开发的核心要点:
- 理解钩子执行顺序:
config→configResolved→configureServer→resolveId→load→transform - 合理使用 enforce:
pre用于预处理,post用于后处理 - 善用虚拟模块:
resolveId+load可以凭空创造模块 - 注意性能:尽早过滤、使用缓存、生成 sourcemap
- 写测试:transform 逻辑是纯函数,非常容易测试
Vite 的插件系统继承了 Rollup 的优雅设计,同时又针对开发体验做了大量扩展。掌握了这套体系,你不仅能写自己的插件,更能深入理解 Vite 的工作原理。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
Vite 插件开发实战:从零手写一个自动导入插件
https://boke.hackerdream.xyz/posts/vite-plugin-development/ 相关文章 智能推荐
1
Web Components 深度实战:用浏览器原生 API 搭建可复用组件库
前端架构 深入 Custom Elements v2、Shadow DOM、Declarative Shadow DOM 等浏览器原生组件化方案,结合 Python FastAPI 后端与 Docker 部署,从零搭建可复用组件库。
2
Astro 博客在 4G 内存服务器上的构建优化实战
工程化与工具 2026-03-25
3
Python 类型提示完全实战指南:从「动态一时爽」到「重构火葬场」的救赎之路
Python入门进阶 深入解析 Python 类型提示(Type Hints)的实战用法,涵盖基础语法、泛型、Protocol、TypeGuard、dataclass 集成、mypy 配置,帮助你写出更安全可维护的 Python 代码。
4
告别 ESLint + Prettier:Biome 统一工具链 + AI 代码审查实战
工程化 深入解析 Biome 如何用一个工具取代 ESLint 和 Prettier,配合 AI 代码审查构建下一代前端代码质量体系,附性能对比和完整实战配置。
5
Tailwind CSS v4 深入:基于 Rust 的新引擎与零配置体验
CSS 与动画 2026-02-18
随机文章 随机推荐