手写 Vite 构建耗时分析插件:用 TypeScript 精准定位编译瓶颈
引言
Vite 开发模式下毫秒级的热更新让我们几乎忘了构建这件事。但等到 vite build 跑上三分钟,看着终端里滚动的日志却无从下手时,那种无力感每个前端都经历过。
社区方案如 rollup-plugin-visualizer 只能展示产物体积分布,对构建耗时几乎一无所知。本文将用 TypeScript 从零实现一个 Vite 构建耗时分析插件,让你精确知道:
- 每个 Rollup Plugin 的 transform / load / resolveId 各花了多少时间
- 哪个模块的 transform 耗时最长
- buildStart → generateBundle 的阶段级耗时
一、技术背景:Rollup 插件生命周期
Vite 生产构建基于 Rollup。Rollup 的插件 Hook 分为两类:
| 分类 | Hook | 说明 |
|---|---|---|
| Build Phase | options, buildStart, resolveId, load, transform | 串行/并行执行,逐个模块处理 |
| Output Phase | renderStart, renderChunk, generateBundle, writeBundle | 生成阶段,处理 chunk 拼接 |
我们的插件需要在每个 Hook 前后埋入计时器,聚合后输出报告。
二、类型定义先行
// types.ts
type HookName = 'buildStart' | 'resolveId' | 'load' | 'transform'
| 'renderChunk' | 'generateBundle';
interface TimingEntry {
hook: HookName;
plugin: string;
moduleId?: string;
duration: number; // ms
startTime: number; // performance.now()
}
interface AggregateReport {
byHook: Record<HookName, { total: number; count: number }>;
byPlugin: Record<string, { total: number; byHook: Record<string, number> }>;
topModules: Array<{ id: string; duration: number; hook: HookName }>;
totalBuildTime: number;
phaseBreakdown: Record<'build' | 'output', number>;
}
三、正例:泛型 wrap 避免代码膨胀
每个 Hook 的拦截逻辑高度相似——在调用前打点,调用后算差。我们用泛型高阶函数消除重复:
// wrapper.ts
import type { Plugin, PluginContext } from 'vite';
function withTiming<T extends (...args: any[]) => any>(
fn: T,
hook: HookName,
pluginName: string,
collector: TimingCollector,
moduleIdGetter?: (...args: Parameters<T>) => string | undefined
): T {
return (async (...args: Parameters<T>) => {
const start = performance.now();
const result = await fn.apply(this, args);
const duration = performance.now() - start;
collector.record({
hook,
plugin: pluginName,
moduleId: moduleIdGetter?.(...args) ?? getFirstStringArg(args),
duration,
startTime: start,
});
return result;
}) as T;
}
function getFirstStringArg(args: unknown[]): string | undefined {
return typeof args[0] === 'string' ? args[0] : undefined;
}
四、反例:逐个 Hook 硬编码
❌ 不要这样写——每一个 Hook 都复制粘贴:
// ❌ 反模式:扩张式重复代码
class BadTimingPlugin {
resolveId(id: string) {
const t0 = performance.now();
const result = originalResolveId.call(this, id);
const t1 = performance.now();
this.records.push({ hook: 'resolveId', duration: t1 - t0, ... });
return result;
}
load(id: string) {
const t0 = performance.now();
const result = originalLoad.call(this, id);
const t1 = performance.now();
this.records.push({ hook: 'load', duration: t1 - t0, ... });
return result;
}
// ... 6 个 Hook 重复 6 遍
}
随着需要监控的 Hook 增多,维护成本线性攀升,record.push 的格式一旦调整就要改 N 处。
五、核心插件实现
// vite-plugin-build-timing.ts
import type { Plugin, ResolvedConfig } from 'vite';
interface TimingPluginOptions {
/** 输出 top-N 个最慢模块,默认 10 */
topN?: number;
/** 阈值(ms),只记录超过此值的耗时,避免噪音,默认 0 */
threshold?: number;
/** 是否在终端打印报告,默认 true */
printReport?: boolean;
}
class TimingCollector {
private entries: TimingEntry[] = [];
record(entry: TimingEntry): void {
this.entries.push(entry);
}
aggregate(options: Required<TimingPluginOptions>): AggregateReport {
const { threshold } = options;
const filtered = this.entries.filter(e => e.duration >= threshold);
// 按 Hook 聚合
const byHook = {} as AggregateReport['byHook'];
const byPlugin = {} as AggregateReport['byPlugin'];
const moduleMap = new Map<string, { duration: number; hook: HookName }>();
let buildPhase = 0;
let outputPhase = 0;
const buildHooks = new Set<HookName>(['buildStart', 'resolveId', 'load', 'transform']);
for (const e of filtered) {
// Hook 聚合
if (!byHook[e.hook]) byHook[e.hook] = { total: 0, count: 0 };
byHook[e.hook].total += e.duration;
byHook[e.hook].count++;
// Plugin 聚合
if (!byPlugin[e.plugin]) byPlugin[e.plugin] = { total: 0, byHook: {} };
byPlugin[e.plugin].total += e.duration;
byPlugin[e.plugin].byHook[e.hook] = (byPlugin[e.plugin].byHook[e.hook] || 0) + e.duration;
// 阶段归类
if (buildHooks.has(e.hook)) buildPhase += e.duration;
else outputPhase += e.duration;
// 模块聚合(只对 transform 做)
if (e.hook === 'transform' && e.moduleId) {
const prev = moduleMap.get(e.moduleId) ?? { duration: 0, hook: 'transform' };
prev.duration += e.duration;
moduleMap.set(e.moduleId, prev);
}
}
const topModules = [...moduleMap.entries()]
.sort((a, b) => b[1].duration - a[1].duration)
.slice(0, options.topN)
.map(([id, v]) => ({ id, ...v }));
const totalBuildTime = buildPhase + outputPhase;
return { byHook, byPlugin, topModules, totalBuildTime, phaseBreakdown: { build: buildPhase, output: outputPhase } };
}
print(report: AggregateReport): void {
console.log('\n📊 ====== Vite Build Timing Report ======\n');
console.log(`⏱️ 总构建耗时: ${report.totalBuildTime.toFixed(0)}ms`);
console.log(` ├─ Build Phase: ${report.phaseBreakdown.build.toFixed(0)}ms (${(report.phaseBreakdown.build/report.totalBuildTime*100).toFixed(1)}%)`);
console.log(` └─ Output Phase: ${report.phaseBreakdown.output.toFixed(0)}ms (${(report.phaseBreakdown.output/report.totalBuildTime*100).toFixed(1)}%)`);
console.log('\n🔌 插件耗时排行 (Top 5):');
Object.entries(report.byPlugin)
.sort((a, b) => b[1].total - a[1].total)
.slice(0, 5)
.forEach(([name, data], i) => {
console.log(` ${i + 1}. ${name}: ${data.total.toFixed(0)}ms`);
Object.entries(data.byHook).forEach(([hook, dur]) => {
console.log(` └─ ${hook}: ${dur.toFixed(0)}ms`);
});
});
console.log('\n🐌 Transform 最慢模块:');
report.topModules.forEach((m, i) => {
const shortId = m.id.split('/').slice(-3).join('/');
console.log(` ${i + 1}. .../${shortId}: ${m.duration.toFixed(1)}ms`);
});
console.log('\n=========================================\n');
}
}
export function viteBuildTimingPlugin(rawOptions: TimingPluginOptions = {}): Plugin {
const options: Required<TimingPluginOptions> = {
topN: rawOptions.topN ?? 10,
threshold: rawOptions.threshold ?? 0,
printReport: rawOptions.printReport ?? true,
};
const collector = new TimingCollector();
let pluginName = 'vite-build-timing';
return {
name: pluginName,
enforce: 'pre', // 在其他插件之前执行,更全面地包裹
buildStart() {
const start = performance.now();
// rollup buildStart 无参数
const duration = performance.now() - start;
collector.record({ hook: 'buildStart', plugin: pluginName, duration, startTime: start });
},
async resolveId(source, importer, options) {
const start = performance.now();
// 不修改解析结果,仅计时。利用 resolveId 的 this 上下文继续链
// 注意:如果直接返回 null,rollup 会继续下一个插件。这里我们只是包裹测速,
// 不干扰实际解析逻辑。实际上更精确的做法是在外部 wrap 其他插件。
const duration = performance.now() - start;
if (duration >= options.threshold) {
collector.record({ hook: 'resolveId', plugin: pluginName, moduleId: source, duration, startTime: start });
}
return null;
},
async load(id) {
const start = performance.now();
const duration = performance.now() - start;
if (duration >= options.threshold) {
collector.record({ hook: 'load', plugin: pluginName, moduleId: id, duration, startTime: start });
}
return null;
},
async transform(code, id) {
const start = performance.now();
const duration = performance.now() - start;
if (duration >= options.threshold) {
collector.record({ hook: 'transform', plugin: pluginName, moduleId: id, duration, startTime: start });
}
return null;
},
async renderChunk(code, chunk) {
const start = performance.now();
const duration = performance.now() - start;
if (duration >= options.threshold) {
collector.record({ hook: 'renderChunk', plugin: pluginName, moduleId: chunk.fileName, duration, startTime: start });
}
return null;
},
generateBundle(_opts, bundle) {
const start = performance.now();
const duration = performance.now() - start;
collector.record({ hook: 'generateBundle', plugin: pluginName, moduleId: 'bundle', duration, startTime: start });
if (options.printReport) {
const report = collector.aggregate(options);
collector.print(report);
}
},
// 暴露 collector 供外部调用(例如编程式获取报告)
__collector: collector,
} as Plugin & { __collector: TimingCollector };
}
六、进阶用法:Wrap 其他插件实现全链路监控
光靠一个 enforce: 'pre' 插件无法精确测量其他插件的耗时。我们可以用 插件序列拦截 技巧:
// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
const basePlugins = [vue()];
// ⚡ 用 timingWrapper 包裹每个插件
function wrapPlugin(plugin: any, collector: TimingCollector): any {
const wrapped = { ...plugin };
for (const hook of ['resolveId', 'load', 'transform', 'buildStart', 'renderChunk', 'generateBundle'] as const) {
const original = plugin[hook];
if (typeof original === 'function') {
wrapped[hook] = withTiming(original, hook, plugin.name, collector);
}
}
return wrapped;
}
export default defineConfig({
plugins: [
...basePlugins.map(p => wrapPlugin(p, globalCollector)),
],
});
这样,每个插件的真实耗时都会被记录,而不是仅在自定义插件内部空跑。
七、输出示例
📊 ====== Vite Build Timing Report ======
⏱️ 总构建耗时: 184320ms
├─ Build Phase: 163540ms (88.7%)
└─ Output Phase: 20780ms (11.3%)
🔌 插件耗时排行 (Top 5):
1. vite:esbuild: 84210ms
└─ transform: 84210ms
2. vite-plugin-vue: 35670ms
└─ transform: 32010ms
└─ resolveId: 3660ms
3. vite-plugin-uni: 18930ms
└─ transform: 18930ms
4. vite-plugin-inspect: 4520ms
└─ resolveId: 4520ms
5. vite:css-post: 3210ms
└─ transform: 3210ms
🐌 Transform 最慢模块:
1. .../src/pages/dashboard/complex-chart.vue: 4830.5ms
2. .../node_modules/element-plus/es/index.mjs: 3210.8ms
3. .../src/components/data-table/advanced-filter.vue: 2870.2ms
...
=========================================
一眼就能看出 vite:esbuild 的 transform 占了 84 秒,而 element-plus 全量引入导致单文件编译 3.2 秒。优化方向瞬间清晰:开启 esbuild 并行、按需引入 UI 库。
八、生产落地要点
- threshold 设置 5ms:低于此值的计时数据属于测量噪声,过滤后可大幅减少内存占用
- 仅在 CI 环境启用:通过环境变量
process.env.ANALYZE_BUILD控制,避免日常开发额外开销 - 结果写入 JSON 文件:在
generateBundle中将report序列化为.timing-report.json,供 CI 平台趋势分析 - 与
rollup-plugin-visualizer叠加使用:耗时 + 体积双维度诊断,覆盖构建全貌
总结
Vite 构建慢不是玄学,只是缺一把好「手术刀」。用 TypeScript 的泛型能力,我们可以用不到 200 行代码打造一个精准的构建耗时分析器。核心思路就三步:
- 泛型 wrapper 消除 Hook 拦截的重复代码
- 多维度聚合(按 Hook、按插件、按模块)定位瓶颈
- threshold + CI 开关 确保生产可落地
下次你的 CI 构建超时报警时,不用再盯着 vite build --debug 的万行日志发呆——打开 .timing-report.json,瓶颈刻在脸上。
0 评论
评论区
登录 后参与评论