Vite 插件系统内幕:从 Hook 机制到自定义 Rollup 插件
Vite 插件系统内幕:从 Hook 机制到自定义 Rollup 插件
打开任意一个稍具规模的现代前端项目,你几乎不可能绕开 Vite。它的冷启动时间可以做到几百毫秒,HMR 更新几乎是即时的。但当你只停留在”会用 vite.config.ts 配插件”的层面时,你就被锁死在了别人封装好的能力上。
想要真正”驯服”Vite,必须理解它的插件系统。Vite 插件不是凭空发明的——它是一套精心设计的 Hook 流水线,复用了 Rollup 的构建插件体系,又在其上扩展了开发服务器专属的能力。理解这一点,你就拥有了给任何框架、任何构建需求写”专属插件”的能力。
本文会从三个层次展开:
- 机制层:Vite 插件到底是什么?它和 Rollup 插件是什么关系?
- 流水线层:Hook 是怎么被调用、排序、串联起来的?
- 实战层:4 个从简到繁的自定义插件,覆盖 90% 的真实场景。
一、机制:Vite 插件到底是个什么东西
1.1 插件的本质:返回对象的函数
Vite 插件的 TypeScript 定义暴露在 vite 这个包里,它实际上是一个带有 Hook 的对象:
import type { Plugin } from 'vite';
function myPlugin(): Plugin { return { name: 'my-plugin', // 必须字段:插件唯一标识 // 可选:配置插件行为 enforce: 'pre', // 'pre' | 'post' | undefined apply: 'build', // 'build' | 'serve' | 函数 // 各种 Hook(钩子) buildStart() {}, transform() {}, // ... };}这个对象会被 Vite 收集起来,组成一条处理流水线。每个文件、每个模块、每个请求在经过这条流水线时,会按顺序调用每个插件的对应 Hook。
1.2 Vite 与 Rollup 的”父子关系”
很多人知道”Vite 底层用 Rollup 做构建”,但不知道这种”父子关系”在插件层面是怎么体现的。答案是:Vite 插件和 Rollup 插件是兼容的。
Vite 在启动构建(vite build)时,会把所有 Vite 插件转交给 Rollup 的插件系统去执行。Rollup 内部维护着自己的 Hook 列表,Vite 插件只要实现了 Rollup 认识的 Hook(比如 transform、load、resolveId),就能直接被复用。
这带来一个非常重要的设计后果:Vite 插件的 Hook 可以分成两类。
| Hook 类型 | 来源 | 触发时机 | 典型示例 |
|---|---|---|---|
| Rollup 通用 Hook | Rollup 规范 | 构建时模块加载/转换 | resolveId、load、transform、buildEnd |
| Vite 独有 Hook | Vite 扩展 | 开发服务器运行期间 | configureServer、handleHotUpdate、transformIndexHtml |
关键认知:如果你的插件只在
vite build阶段工作,它和 Rollup 插件几乎没区别;如果要开发服务器的能力,就必须使用 Vite 独有的 Hook。
1.3 一个最小的可运行插件
import type { Plugin } from 'vite';
export default function logResolvePlugin(): Plugin { return { name: 'log-resolve', resolveId(source, importer) { console.log(`[resolveId] ${source} (from ${importer ?? 'entry'})`); return null; // 返回 null 表示交给下一个插件处理 }, };}import { defineConfig } from 'vite';import logResolve from './plugins/log-resolve';
export default defineConfig({ plugins: [logResolve()],});执行 vite build,你会看到终端打印出一行行模块解析记录。这就是插件的本质——流水线上的观察者和改造者。
二、流水线:Hook 是怎么被调用的
理解了”插件是对象”,下一步就是理解”它们怎么协作”。
2.1 顺序:Vite 怎么决定插件的执行顺序
Vite 内部维护了一个 插件排序算法,大致遵循这个流程:
1. 别名(alias)插件2. enforce: 'pre' 的用户插件3. Vite 核心插件(alias、define、css、asset 等)4. 没有 enforce 的用户插件5. Vite 内部构建插件6. enforce: 'post' 的用户插件7. Vite 后置构建插件(minify、manifest、report)这个顺序为什么重要?因为前面的插件可以拦截和修改后续插件的工作内容。比如你写一个 enforce: 'pre' 的 transform 插件,它可以处理 Vite 核心插件还没动过的”原始源码”。
// 想在 Vite 处理 .ts 之前先剥离注释?function stripCommentsPlugin(): Plugin { return { name: 'strip-comments', enforce: 'pre', transform(code, id) { if (!id.endsWith('.ts')) return; return { code: code.replace(/\/\*[\s\S]*?\*\//g, ''), map: null, }; }, };}2.2 顺序:同一个 Hook 内的插件按什么顺序执行
以 transform 为例,多个插件都实现了这个 Hook 时,调用顺序是:
- 按
enforce: 'pre'的顺序 - 再按普通顺序
- 最后按
enforce: 'post'的顺序
每个插件返回的结果会作为下一个插件的输入。这构成了”流水线处理”:
源码 → 插件A.transform → 插件B.transform → 插件C.transform → 最终代码这就是为什么插件 A 注入的代码,插件 B 也能”看到”。
2.3 顺序:构建 vs 开发
Vite 的 apply 字段是插件的”守门员”:
function conditionalPlugin(): Plugin { return { name: 'conditional', apply(config, { command }) { // 只在 SSR 构建时生效 return command === 'build' && !!config.build?.ssr; }, transform(code) { return { code: code + '\n// ssr-only', map: null }; }, };}也支持简写:
apply: 'build', // 仅构建apply: 'serve', // 仅 dev server2.4 过滤:怎么让插件”只对某些文件生效”
每个 Hook 的入参都包含模块 ID(id) 或文件名(file)。几乎所有 Vite 插件的第一行都是这个模式:
transform(code, id) { if (!/\.(vue|ts|tsx)$/.test(id)) return; // 早退 // ... 处理逻辑}这个习惯不是为了性能——虽然确实有性能收益——而是为了避免污染其他文件。比如你的插件想给 Vue SFC 添加 <script setup> 的语法糖,它绝不应该处理 .scss 文件。
三、生命周期:构建阶段的 Hook 全景
让我们把所有 Hook 按时间顺序串起来。先看构建(vite build):
3.1 阶段一:配置与启动
{ // Vite 解析配置前调用,可修改配置 config(config, { command, mode }) { return { define: { __VERSION__: '1.0.0' } }; },
// 配置解析完成后调用 configResolved(config) { // 此时拿到的 config 是"最终态",可以读取但不要改 },
// 构建开始时调用 buildStart(options) {},
// 选项被传递给 Rollup 后 options(options) { return options; },}config 和 configResolved 的区别是新手最容易踩的坑:
config:可以修改配置(Vite 会合并你的返回)configResolved:应该只读配置(写入是无效的,且不推荐)
3.2 阶段二:模块解析与加载
这是流水线最核心的部分:
{ // 解析模块 ID(路径 → 绝对路径) resolveId(id, importer) { if (id === 'virtual:my-data') return '\0virtual:my-data'; return null; // null = 交给下一个插件 },
// 加载模块内容 load(id) { if (id === '\0virtual:my-data') { return 'export const data = [1, 2, 3]'; } },
// 转换模块代码 transform(code, id) { return { code: transformedCode, map: sourcemap }; },}注意 \0 前缀——这是 Rollup 的约定,表示虚拟模块(不实际存在文件系统的模块)。virtual:my-data 是用户友好的写法,\0virtual:my-data 是内部真实 ID。
3.3 阶段三:生成与完成
{ // 输出资源前,可修改 chunk renderChunk(code, chunk) { return { code, map: null }; },
// 即将写入文件时(最后一个修改机会!) generateBundle(options, bundle) { // bundle 是 { 文件名 → 输出资源 } 的对象 for (const fileName in bundle) { // 可以删除、修改、新增输出 } },
// 构建结束(无论成功失败都调用) buildEnd(error) {}, closeBundle() {},}generateBundle 极其强大:你可以篡改最终产物。比如把一个 chunk 拆成多个、删除无用文件、注入运行时探针——它都是你的工具。
3.4 完整时序图
vite build │ ▼config ──→ configResolved ──→ options ──→ buildStart │ ┌──────────────────────────────────────┘ │ 对每个 entry: ▼resolveId ──→ load ──→ transform ──→ (更多 transform) │ ▼ renderChunk │ ▼ generateBundle │ ▼ closeBundle / buildEnd四、扩展能力:开发服务器专属 Hook
构建的 Hook 适用于 vite build。但 Vite 真正的杀手锏是开发服务器——它的 Hook 完全独立于 Rollup:
4.1 configureServer:自定义中间件
function apiMockPlugin(): Plugin { return { name: 'api-mock', configureServer(server) { // 在 Vite 内部中间件之前注册 server.middlewares.use('/api/users', (req, res) => { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify([ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, ])); });
// 返回函数则在内置中间件之后执行 return () => { server.middlewares.use((req, res, next) => { res.setHeader('X-Custom-Header', 'vite-plugin'); next(); }); }; }, };}configureServer 只在 vite dev 时触发。server.middlewares 底层是 connect,和 Express 的中间件是同一套机制。
4.2 transformIndexHtml:HTML 注入
function injectVersionPlugin(): Plugin { return { name: 'inject-version', transformIndexHtml(html) { return [ // 注入 meta 标签 { tag: 'meta', attrs: { name: 'build-time', content: new Date().toISOString() }, injectTo: 'head', }, // 注入 script(defer) { tag: 'script', attrs: { src: '/runtime.js', defer: true }, injectTo: 'body', }, ]; }, };}injectTo 可选值:head | head-prepend | body | body-prepend,分别对应”追加到末尾/开头”。
4.3 handleHotUpdate:自定义 HMR
function customHmrPlugin(): Plugin { return { name: 'custom-hmr', handleHotUpdate({ file, server, modules }) { if (file.endsWith('.proto')) { // 给客户端发自定义事件 server.ws.send({ type: 'custom', event: 'proto-update', data: { file, timestamp: Date.now() }, }); // 返回空数组阻止默认 HMR return []; } }, };}客户端对应:
if (import.meta.hot) { import.meta.hot.on('proto-update', (data) => { console.log('proto file changed:', data); });}这就是为什么有些项目里修改 .proto 文件会触发 UI 刷新——背后是某个插件在做这件事。
4.4 configurePreviewServer:preview 模式
configurePreviewServer 让你能在 vite preview 时扩展行为(生产构建的本地预览),API 与 configureServer 几乎一致。
五、实战:4 个真实场景的自定义插件
光说不练假把式。我们来手写 4 个真实项目中会遇到的插件。
5.1 插件一:把 .env.local 注入到运行时
痛点:想把构建时的不变数据(比如接口基地址、feature flag)注入到前端代码里,避免每次打包都打一个 JSON。
import type { Plugin } from 'vite';import { readFileSync } from 'node:fs';import { resolve } from 'node:path';
export interface BuildInfoOptions { /** 要注入的字段,从环境变量读取 */ fields: Record<string, string>; /** 注入到哪个虚拟模块 */ virtualId?: string;}
export default function injectBuildInfo(options: BuildInfoOptions): Plugin { const virtualId = options.virtualId ?? 'virtual:build-info'; const resolvedId = '\0' + virtualId; const name = 'inject-build-info';
return { name, resolveId(id) { if (id === virtualId) return resolvedId; return null; }, load(id) { if (id !== resolvedId) return null;
// 收集所有字段 const data: Record<string, string> = {}; for (const [key, envKey] of Object.entries(options.fields)) { data[key] = process.env[envKey] ?? ''; }
// 输出 ESM 模块 return `export const buildInfo = ${JSON.stringify(data, null, 2)};`; }, };}使用:
import injectBuildInfo from './plugins/inject-build-info';
export default defineConfig({ plugins: [ injectBuildInfo({ fields: { apiBase: 'VITE_API_BASE', version: 'npm_package_version', }, }), ],});// 在业务代码中import { buildInfo } from 'virtual:build-info';console.log(buildInfo.apiBase); // "https://api.example.com"学到什么:resolveId + load 是虚拟模块的标准模式。\0 前缀让 Rollup 知道”这是内部生成的”,不会去磁盘找文件。
5.2 插件二:自动给 Markdown 文件加更新时间戳
痛点:博客系统需要给每篇 markdown 自动注入 lastUpdated 字段。
import type { Plugin } from 'vite';import { statSync } from 'node:fs';
export default function markdownTimestamp(): Plugin { return { name: 'markdown-timestamp', enforce: 'pre', // 在 Vite 处理 .md 之前 transform(code, id) { if (!id.endsWith('.md')) return null;
try { const stats = statSync(id); const lastUpdated = stats.mtime.toISOString(); // 注入 frontmatter if (code.startsWith('---')) { return { code: `---\nlastUpdated: ${lastUpdated}\n---\n${code.slice(3)}`, map: null, }; } return { code: `---\nlastUpdated: ${lastUpdated}\n---\n\n${code}`, map: null, }; } catch { return null; } }, };}学到什么:enforce: 'pre' 让你在 Vite 核心逻辑之前修改源码。这种模式适合做”代码增强”。
5.3 插件三:构建产物分析(输出每个 chunk 的体积)
痛点:想知道打包后每个 chunk 有多大、哪个最大。
import type { Plugin } from 'vite';
interface ChunkInfo { name: string; size: number; gzip: number;}
export default function buildAnalyzer(): Plugin { return { name: 'build-analyzer', apply: 'build', generateBundle(_options, bundle) { const { gzipSync } = require('node:zlib'); const chunks: ChunkInfo[] = [];
for (const [fileName, asset] of Object.entries(bundle)) { if (asset.type !== 'chunk') continue; const size = Buffer.byteLength(asset.code, 'utf-8'); const gzip = gzipSync(Buffer.from(asset.code, 'utf-8')).length; chunks.push({ name: fileName, size, gzip }); }
chunks.sort((a, b) => b.size - a.size);
console.log('\n📦 构建产物分析(按体积排序)'); console.log('─'.repeat(60)); console.log( 'File'.padEnd(40) + 'Size'.padStart(10) + 'Gzip'.padStart(10) ); console.log('─'.repeat(60)); for (const c of chunks.slice(0, 20)) { console.log( c.name.padEnd(40) + `${(c.size / 1024).toFixed(2)}KB`.padStart(10) + `${(c.gzip / 1024).toFixed(2)}KB`.padStart(10) ); } console.log('─'.repeat(60)); }, };}输出效果:
📦 构建产物分析(按体积排序)────────────────────────────────────────────────────────────File Size Gzip────────────────────────────────────────────────────────────assets/index-abc123.js 312.45KB 98.23KBassets/vue-vendor-def456.js 201.33KB 72.11KBassets/index-ghi789.css 45.67KB 12.34KB学到什么:generateBundle 能让你在产物落盘前审计和修改。这个插件比 rollup-plugin-visualizer 简单 100 倍,但足够日常使用。
5.4 插件四:开发环境的”假接口”中间件
痛点:后端没准备好,前端需要 mock 一堆 REST 接口。
import type { Plugin } from 'vite';import type { IncomingMessage, ServerResponse } from 'node:http';
type Handler = (req: IncomingMessage, res: ServerResponse, params: Record<string, string>) => void | Promise<void>;
interface Route { method: string; pattern: RegExp; paramNames: string[]; handler: Handler;}
export default function mockApi(routes: Array<{ method: string; path: string; handler: Handler }>): Plugin { const compiled: Route[] = routes.map((r) => { const paramNames: string[] = []; const pattern = new RegExp( '^' + r.path.replace(/:[a-zA-Z_]\w*/g, (m) => { paramNames.push(m.slice(1)); return '([^/]+)'; }) + '$' ); return { method: r.method.toUpperCase(), pattern, paramNames, handler: r.handler }; });
return { name: 'mock-api', apply: 'serve', configureServer(server) { server.middlewares.use(async (req, res, next) => { if (!req.url || !req.method) return next();
const url = new URL(req.url, 'http://localhost'); const pathname = url.pathname;
for (const route of compiled) { if (route.method !== req.method) continue; const match = pathname.match(route.pattern); if (!match) continue;
const params: Record<string, string> = {}; route.paramNames.forEach((name, i) => { params[name] = decodeURIComponent(match[i + 1]); });
res.setHeader('Content-Type', 'application/json'); try { await route.handler(req, res, params); } catch (err) { res.statusCode = 500; res.end(JSON.stringify({ error: String(err) })); } return; }
next(); }); }, };}使用:
import mockApi from './plugins/mock-api';
export default defineConfig({ plugins: [ mockApi([ { method: 'GET', path: '/api/users/:id', handler: (req, res, params) => { res.end(JSON.stringify({ id: params.id, name: `User ${params.id}` })); }, }, { method: 'POST', path: '/api/orders', handler: (req, res) => { res.end(JSON.stringify({ ok: true, orderId: '12345' })); }, }, ]), ],});学到什么:configureServer + server.middlewares 是构建 dev-only 工具的标准范式。本质上你已经写出了一个迷你版的 msw / vite-plugin-mock。
六、Rollup 插件的复用
Vite 插件可以重用 Rollup 插件——只要在插件名前加 vite: 前缀,告诉 Vite 跳过重复检查:
import replace from '@rollup/plugin-replace';import image from '@rollup/plugin-image';
export default defineConfig({ plugins: [ // 直接使用 Rollup 插件 replace({ __DEV__: 'false' }), image(),
// 自定义 Vite 插件 myVitePlugin(), ],});Vite 内部做了兼容处理。这条桥梁让你能直接用 Rollup 生态几千个现成插件(替换、压缩、图片处理、wasm 加载……),不必每个都找 Vite 版。
七、性能与调试
7.1 调试 Hook 执行顺序
Vite 提供了一个环境变量 DEBUG=vite:plugin 来打印插件的完整生命周期:
DEBUG=vite:plugin vite build这会输出每个 Hook 的调用顺序和耗时。线上排查”哪个插件把代码改坏了”时,这是最有效的工具。
7.2 性能:插件越少越好
每个 transform 都会遍历模块。10 个 transform 插件意味着每个模块被处理 10 次。一些经验法则:
| 实践 | 收益 |
|---|---|
| 合并多个 transform | 减少遍历次数 |
用 id 早退 | 跳过不相关的文件 |
缓存计算结果(moduleCache) | 避免重复 AST 解析 |
异步 transform 用 Promise.all | 并行 IO |
// 反例:每个文件都跑全量正则function slowPlugin(): Plugin { return { name: 'slow', transform(code) { // 哪怕是 .png 也跑一次正则 return { code: code.replace(/foo/g, 'bar'), map: null }; }, };}
// 正例:早退function fastPlugin(): Plugin { return { name: 'fast', transform(code, id) { if (!/\.(ts|tsx)$/.test(id)) return null; return { code: code.replace(/foo/g, 'bar'), map: null }; }, };}7.3 常见踩坑
- 忘记返回
null:resolveId返回undefined或不返回,Vite 会报错;返回null表示”我处理不了,交给下一个”。 - Sourcemap 丢失:
transform一定要返回map,否则断点会跳到奇怪的地方。 enforce误用:用enforce: 'pre'去”确保自己的插件在 CSS 插件之前”是脆弱的——核心插件顺序可能变化。- 配置修改时机:在
config中修改配置,不要在configResolved中修改。
八、对比与选型
| 场景 | 该用哪种插件 |
|---|---|
| 处理自定义文件类型(.svg as 组件) | Vite 插件 + transform |
| 添加 dev 中间件 | Vite 插件 + configureServer |
| 替换代码中的常量 | Rollup 插件(@rollup/plugin-replace) |
| 复杂的 HTML 注入 | Vite 插件 + transformIndexHtml |
| 自定义 HMR | Vite 插件 + handleHotUpdate |
| 给产物加元数据/分析 | Vite 插件 + generateBundle |
九、总结
Vite 插件系统不是黑魔法,它是一套两层架构:
- 底层:完全兼容 Rollup 插件,复用其构建期能力
- 上层:扩展出开发服务器、HMR、HTML 注入等独家能力
掌握这套体系的关键不是记住所有 Hook,而是理解三个原则:
- 顺序由
enforce和apply决定 - 每个 Hook 都是过滤器——早退是美德
- 修改产物用
generateBundle,修改源码用transform
当你写完一个插件并放进 vite.config.ts 看到效果时,你就不再是 Vite 的使用者,而是它的塑造者。
延伸阅读
- Vite 官方插件 API — 最权威的参考
- Rollup 插件开发指南 — Vite 插件的”父规范”
- 《Vite 插件开发实战:从零手写一个自动导入插件》 — 进阶实战
- 《esbuild 深入:为什么它比 Webpack 快 100 倍》 — Vite 依赖预构建的引擎
- 《Vite CJS 依赖与 Vue 3 冲突修复》 — 真实问题排查
- unplugin — 写一次插件同时支持 Vite/Webpack/Rollup/Esbuild 的库
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!