Vite 插件系统内幕:从 Hook 机制到自定义 Rollup 插件

4080 字
20 分钟
Vite 插件系统内幕:从 Hook 机制到自定义 Rollup 插件

Vite 插件系统内幕:从 Hook 机制到自定义 Rollup 插件#

打开任意一个稍具规模的现代前端项目,你几乎不可能绕开 Vite。它的冷启动时间可以做到几百毫秒,HMR 更新几乎是即时的。但当你只停留在”会用 vite.config.ts 配插件”的层面时,你就被锁死在了别人封装好的能力上。

想要真正”驯服”Vite,必须理解它的插件系统。Vite 插件不是凭空发明的——它是一套精心设计的 Hook 流水线,复用了 Rollup 的构建插件体系,又在其上扩展了开发服务器专属的能力。理解这一点,你就拥有了给任何框架、任何构建需求写”专属插件”的能力。

本文会从三个层次展开:

  1. 机制层:Vite 插件到底是什么?它和 Rollup 插件是什么关系?
  2. 流水线层:Hook 是怎么被调用、排序、串联起来的?
  3. 实战层: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(比如 transformloadresolveId),就能直接被复用。

这带来一个非常重要的设计后果:Vite 插件的 Hook 可以分成两类

Hook 类型来源触发时机典型示例
Rollup 通用 HookRollup 规范构建时模块加载/转换resolveIdloadtransformbuildEnd
Vite 独有 HookVite 扩展开发服务器运行期间configureServerhandleHotUpdatetransformIndexHtml

关键认知:如果你的插件只在 vite build 阶段工作,它和 Rollup 插件几乎没区别;如果要开发服务器的能力,就必须使用 Vite 独有的 Hook。

1.3 一个最小的可运行插件#

plugins/log-resolve.ts
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 表示交给下一个插件处理
},
};
}
vite.config.ts
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 时,调用顺序是:

  1. enforce: 'pre' 的顺序
  2. 再按普通顺序
  3. 最后按 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 server

2.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; },
}

configconfigResolved 的区别是新手最容易踩的坑:

  • 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。

plugins/inject-build-info.ts
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)};`;
},
};
}

使用:

vite.config.ts
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 字段。

plugins/markdown-timestamp.ts
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 有多大、哪个最大。

plugins/build-analyzer.ts
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.23KB
assets/vue-vendor-def456.js 201.33KB 72.11KB
assets/index-ghi789.css 45.67KB 12.34KB

学到什么generateBundle 能让你在产物落盘前审计和修改。这个插件比 rollup-plugin-visualizer 简单 100 倍,但足够日常使用。

5.4 插件四:开发环境的”假接口”中间件#

痛点:后端没准备好,前端需要 mock 一堆 REST 接口。

plugins/mock-api.ts
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();
});
},
};
}

使用:

vite.config.ts
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 来打印插件的完整生命周期:

Terminal window
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 常见踩坑#

  1. 忘记返回 nullresolveId 返回 undefined 或不返回,Vite 会报错;返回 null 表示”我处理不了,交给下一个”。
  2. Sourcemap 丢失transform 一定要返回 map,否则断点会跳到奇怪的地方。
  3. enforce 误用:用 enforce: 'pre' 去”确保自己的插件在 CSS 插件之前”是脆弱的——核心插件顺序可能变化。
  4. 配置修改时机:在 config 中修改配置,不要在 configResolved 中修改

八、对比与选型#

场景该用哪种插件
处理自定义文件类型(.svg as 组件)Vite 插件 + transform
添加 dev 中间件Vite 插件 + configureServer
替换代码中的常量Rollup 插件(@rollup/plugin-replace
复杂的 HTML 注入Vite 插件 + transformIndexHtml
自定义 HMRVite 插件 + handleHotUpdate
给产物加元数据/分析Vite 插件 + generateBundle

九、总结#

Vite 插件系统不是黑魔法,它是一套两层架构

  • 底层:完全兼容 Rollup 插件,复用其构建期能力
  • 上层:扩展出开发服务器、HMR、HTML 注入等独家能力

掌握这套体系的关键不是记住所有 Hook,而是理解三个原则:

  1. 顺序由 enforceapply 决定
  2. 每个 Hook 都是过滤器——早退是美德
  3. 修改产物用 generateBundle,修改源码用 transform

当你写完一个插件并放进 vite.config.ts 看到效果时,你就不再是 Vite 的使用者,而是它的塑造者。

延伸阅读#

文章分享

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

Vite 插件系统内幕:从 Hook 机制到自定义 Rollup 插件
https://boke.hackerdream.xyz/posts/frontend-vite-plugin-internals/
作者
晴天
发布于
2026-06-06
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
晴天
Hello, I'm 晴天.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
155
分类
24
标签
387
总字数
345,424
运行时长
0
最后活动
0 天前

目录