前端开发··2 阅读·预计 27 分钟

手写 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 Phaseoptions, buildStart, resolveId, load, transform串行/并行执行,逐个模块处理
Output PhaserenderStart, 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 库。

八、生产落地要点

  1. threshold 设置 5ms:低于此值的计时数据属于测量噪声,过滤后可大幅减少内存占用
  2. 仅在 CI 环境启用:通过环境变量 process.env.ANALYZE_BUILD 控制,避免日常开发额外开销
  3. 结果写入 JSON 文件:在 generateBundle 中将 report 序列化为 .timing-report.json,供 CI 平台趋势分析
  4. rollup-plugin-visualizer 叠加使用:耗时 + 体积双维度诊断,覆盖构建全貌

总结

Vite 构建慢不是玄学,只是缺一把好「手术刀」。用 TypeScript 的泛型能力,我们可以用不到 200 行代码打造一个精准的构建耗时分析器。核心思路就三步:

  1. 泛型 wrapper 消除 Hook 拦截的重复代码
  2. 多维度聚合(按 Hook、按插件、按模块)定位瓶颈
  3. threshold + CI 开关 确保生产可落地

下次你的 CI 构建超时报警时,不用再盯着 vite build --debug 的万行日志发呆——打开 .timing-report.json,瓶颈刻在脸上。

0 评论

评论区

登录 后参与评论