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'
};
}

执行顺序:

  1. Alias 解析
  2. enforce: 'pre' 的用户插件
  3. Vite 核心插件
  4. 没有 enforce 的用户插件
  5. Vite 构建插件
  6. enforce: 'post' 的用户插件
  7. 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);
});
}

三、实战:手写自动导入插件#

现在来实现一个真正的自动导入插件。目标:在代码中直接使用 refcomputed 等 Vue API,无需手动 import。

3.1 设计思路#

  1. 扫描预设:定义哪些 API 从哪个包导入
  2. 代码分析:检测代码中使用了哪些未导入的 API
  3. 代码转换:在文件头部自动插入 import 语句
  4. 类型生成:生成 .d.ts 文件让 IDE 也能识别

3.2 预设定义#

presets.ts
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 代码扫描器#

scanner.ts
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 生成器#

dts.ts
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 主插件实现#

index.ts
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 使用方式#

vite.config.ts
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);
// ...

四、进阶:支持目录扫描#

除了预设,还可以扫描指定目录下的模块自动导入:

dir-scanner.ts
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'],
})

五、测试插件#

写插件不能少了测试:

__tests__/scanner.test.ts
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.md

6.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 插件开发的核心要点:

  1. 理解钩子执行顺序configconfigResolvedconfigureServerresolveIdloadtransform
  2. 合理使用 enforcepre 用于预处理,post 用于后处理
  3. 善用虚拟模块resolveId + load 可以凭空创造模块
  4. 注意性能:尽早过滤、使用缓存、生成 sourcemap
  5. 写测试:transform 逻辑是纯函数,非常容易测试

Vite 的插件系统继承了 Rollup 的优雅设计,同时又针对开发体验做了大量扩展。掌握了这套体系,你不仅能写自己的插件,更能深入理解 Vite 的工作原理。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Vite 插件开发实战:从零手写一个自动导入插件
https://boke.hackerdream.xyz/posts/vite-plugin-development/
作者
晴天
发布于
2026-03-09
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
晴天
Hello, I'm 晴天.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
125
分类
17
标签
287
总字数
257,955
运行时长
0
最后活动
0 天前

目录