前言

Vite 之所以快,核心在于它的插件机制。Rollup 兼容插件 + Vite 独有钩子,构成了一个灵活而强大的构建流水线。但很多开发者只会用现成插件,遇到定制需求就束手无策——比如自动生成路由、注入全局变量、自定义代码转换等。

本文从 Vite 插件原理出发,手把手实现 5 个实用插件,帮你掌握 Vite 插件开发的核心技能。


一、Vite 插件机制原理

1.1 插件钩子执行顺序

Vite 插件基于 Rollup 插件接口扩展,在开发模式(dev)和生产构建(build)中执行不同的钩子:

开发模式(dev):
  configResolved → configureServer → transform → handleHotUpdate
​
生产构建(build):
  configResolved → buildStart → resolveId → load → transform → renderChunk → generateBundle → writeBundle

1.2 Vite 独有钩子

钩子 模式 用途
config dev + build 修改 Vite 配置
configResolved dev + build 读取最终配置
configureServer dev 配置开发服务器
transformIndexHtml dev + build 转换 index.html
handleHotUpdate dev 自定义 HMR 行为

二、插件 1:自动生成路由

2.1 基于文件系统的路由

// plugins/vite-auto-routes.ts
import { Plugin } from 'vite';
import fs from 'fs';
import path from 'path';
​
interface AutoRoutesOptions {
  pagesDir: string;      // 页面目录,默认 'src/pages'
  exclude?: string[];    // 排除的文件
}
​
export function viteAutoRoutes(options: AutoRoutesOptions = {}): Plugin {
  const { pagesDir = 'src/pages', exclude = [] } = options;
  const virtualModuleId = 'virtual:auto-routes';
  const resolvedVirtualModuleId = '\0' + virtualModuleId;
​
  function scanPages(root: string): RouteItem[] {
    const pagesPath = path.resolve(root, pagesDir);
    if (!fs.existsSync(pagesPath)) return [];
​
    const routes: RouteItem[] = [];
​
    function walk(dir: string, basePath: string) {
      const entries = fs.readdirSync(dir, { withFileTypes: true });
​
      for (const entry of entries) {
        if (exclude.includes(entry.name)) continue;
​
        const fullPath = path.join(dir, entry.name);
​
        if (entry.isDirectory()) {
          walk(fullPath, `${basePath}/${entry.name}`);
        } else if (entry.name === 'index.vue' || entry.name.endsWith('.vue')) {
          const routePath = basePath || '/';
          const name = entry.name.replace('.vue', '');
          routes.push({
            path: routePath === '/' ? '/' : routePath,
            component: `() => import('${fullPath.replace(/\\/g, '/')}')`,
          });
        }
      }
    }
​
    walk(pagesPath, '');
    return routes;
  }
​
  return {
    name: 'vite-auto-routes',
    resolveId(id) {
      if (id === virtualModuleId) return resolvedVirtualModuleId;
    },
    load(id) {
      if (id !== resolvedVirtualModuleId) return;
      // 这个方法在 load 时会被调用,但需要 root
      return null; // 在 configResolved 中处理
    },
    configResolved(config) {
      this._routes = scanPages(config.root);
    },
    // 开发模式下文件变化时重新生成
    configureServer(server) {
      server.watcher.add(path.resolve(server.config.root, pagesDir));
      server.watcher.on('add', () => {
        server.moduleGraph.invalidateModule(
          server.moduleGraph.getModuleById(resolvedVirtualModuleId)!
        );
        server.ws.send({ type: 'full-reload' });
      });
    },
  };
}

2.2 使用方式

// router/index.ts
import { createRouter, createWebHistory } from 'vue-router';
import routes from 'virtual:auto-routes';
​
export const router = createRouter({
  history: createWebHistory(),
  routes,
});

三、插件 2:环境变量注入

3.1 运行时动态注入

// plugins/vite-inject-env.ts
import { Plugin, ResolvedConfig } from 'vite';
​
interface InjectEnvOptions {
  // 需要注入到 window 上的变量
  vars: Record<string, string>;
  // 是否在 index.html 中注入
  injectHtml?: boolean;
}
​
export function viteInjectEnv(options: InjectEnvOptions): Plugin {
  const { vars, injectHtml = true } = options;
  let config: ResolvedConfig;
​
  return {
    name: 'vite-inject-env',
    configResolved(resolvedConfig) {
      config = resolvedConfig;
    },
    transformIndexHtml(html) {
      if (!injectHtml) return html;
​
      const scriptContent = Object.entries(vars)
        .map(([key, value]) => `window.${key} = "${value}";`)
        .join('\n');
​
      return html.replace(
        '</head>',
        `<script>${scriptContent}</script>\n</head>`
      );
    },
    // 生产构建时替换代码中的占位符
    transform(code, id) {
      if (!id.endsWith('.ts') && !id.endsWith('.vue') && !id.endsWith('.tsx')) {
        return null;
      }
​
      let result = code;
      for (const [key, value] of Object.entries(vars)) {
        const pattern = new RegExp(`import\\.meta\\.env\\.VITE_${key}`, 'g');
        result = result.replace(pattern, `"${value}"`);
      }
​
      return result !== code ? result : null;
    },
  };
}

四、插件 3:SVG 图标组件化

4.1 自动注册 SVG 为 Vue 组件

// plugins/vite-svg-icons.ts
import { Plugin } from 'vite';
import fs from 'fs';
import path from 'path';
​
interface SvgIconsOptions {
  iconDirs: string[];        // SVG 文件目录
  symbolId?: string;         // symbol ID 模板
  inject?: 'body-last' | 'body-first';
}
​
export function viteSvgIcons(options: SvgIconsOptions): Plugin {
  const { iconDirs, symbolId = 'icon-[dir]-[name]', inject = 'body-last' } = options;
  const virtualId = 'virtual:svg-icons-register';
  const resolvedVirtualId = '\0' + virtualId;
​
  let svgSprite = '';
​
  function collectSvgs() {
    const symbols: string[] = [];
​
    for (const dir of iconDirs) {
      if (!fs.existsSync(dir)) continue;
​
      const files = fs.readdirSync(dir);
      for (const file of files) {
        if (!file.endsWith('.svg')) continue;
​
        const filePath = path.join(dir, file);
        const name = path.basename(file, '.svg');
        const dirName = path.basename(dir);
        const id = symbolId
          .replace('[dir]', dirName)
          .replace('[name]', name);
​
        let content = fs.readFileSync(filePath, 'utf-8');
        // 移除 xml 声明和注释
        content = content.replace(/<\?xml.*?\?>/g, '');
        content = content.replace(/<!--.*?-->/g, '');
        // 将 svg 改为 symbol
        content = content.replace(
          /<svg([^>]*)>/,
          `<symbol id="${id}"$1>`
        );
        content = content.replace('</svg>', '</symbol>');
​
        symbols.push(content);
      }
    }
​
    svgSprite = `<svg xmlns="http://www.w3.org/2000/svg" style="display:none">${symbols.join('')}</svg>`;
  }
​
  return {
    name: 'vite-svg-icons',
    buildStart() {
      collectSvgs();
    },
    resolveId(id) {
      if (id === virtualId) return resolvedVirtualId;
    },
    load(id) {
      if (id === resolvedVirtualId) {
        return `document.body.insertAdjacentHTML('${inject}', '${svgSprite.replace(/'/g, "\\'").replace(/\n/g, '')}');`;
      }
    },
    transformIndexHtml(html) {
      const injectPosition = inject === 'body-first' ? '<body>' : '</body>';
      const replacement = inject === 'body-first'
        ? `<body>${svgSprite}`
        : `${svgSprite}</body>`;
​
      return html.replace(injectPosition, replacement);
    },
    configureServer(server) {
      for (const dir of iconDirs) {
        server.watcher.add(dir);
      }
      server.watcher.on('add', collectSvgs);
      server.watcher.on('change', collectSvgs);
    },
  };
}

4.2 使用方式

<!-- 使用 SVG sprite -->
<template>
  <svg class="icon" width="24" height="24">
    <use href="#icon-icons-home" />
  </svg>
</template>

五、插件 4:代码自动注入

5.1 自动注入组件导入

// plugins/vite-auto-import.ts
import { Plugin } from 'vite';
​
interface AutoImportOptions {
  imports: Record<string, string[]>;  // 模块 → 导出名称
  dts?: string;                       // 类型声明文件路径
}
​
export function viteAutoImport(options: AutoImportOptions): Plugin {
  const { imports, dts = 'src/auto-imports.d.ts' } = options;
  const virtualId = 'virtual:auto-import';
  const resolvedVirtualId = '\0' + virtualId;
​
  function generateImportCode(): string {
    const lines: string[] = [];
​
    for (const [module, names] of Object.entries(imports)) {
      lines.push(`import { ${names.join(', ')} } from '${module}';`);
    }
​
    return lines.join('\n');
  }
​
  function generateDts(): string {
    const declarations: string[] = [];
​
    for (const [module, names] of Object.entries(imports)) {
      for (const name of names) {
        declarations.push(`const ${name}: typeof import('${module}')['${name}']`);
      }
    }
​
    return `// Auto generated by vite-auto-import\ndeclare global {\n  ${declarations.join(';\n  ')};\n}\nexport {};\n`;
  }
​
  return {
    name: 'vite-auto-import',
    resolveId(id) {
      if (id === virtualId) return resolvedVirtualModuleId;
    },
    load(id) {
      if (id === resolvedVirtualId) {
        return generateImportCode();
      }
    },
    // 在每个 Vue/TS 文件头部自动注入
    transform(code, id) {
      if (!id.endsWith('.vue') && !id.endsWith('.tsx')) return null;
      if (id.includes('node_modules')) return null;
​
      // 在 <script setup> 中注入
      if (code.includes('<script setup')) {
        return code.replace(
          '<script setup',
          `<script setup\n// @auto-import\nimport '${virtualId}';\n<script setup`
        );
      }
​
      return null;
    },
    buildEnd() {
      // 生成类型声明文件
      const fs = require('fs');
      const path = require('path');
      fs.writeFileSync(dts, generateDts(), 'utf-8');
    },
  };
}

六、插件 5:构建产物分析

6.1 体积分析与报告

// plugins/vite-bundle-analyzer.ts
import { Plugin, OutputAsset, OutputChunk } from 'rollup';
​
interface BundleAnalyzerOptions {
  outputFile?: string;       // 报告输出路径
  threshold?: number;        // 告警阈值(KB)
  open?: boolean;            // 是否自动打开报告
}
​
interface ModuleInfo {
  name: string;
  size: number;
  gzipSize: number;
  type: 'chunk' | 'asset';
  imports?: string[];
}
​
export function viteBundleAnalyzer(options: BundleAnalyzerOptions = {}): Plugin {
  const { outputFile = 'bundle-report.html', threshold = 100, open = false } = options;
​
  const modules: ModuleInfo[] = [];
​
  // 简易 gzip 大小估算
  function estimateGzipSize(content: string | Buffer): number {
    const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content);
    // 经验值:gzip 压缩率约为原始大小的 30%
    return Math.round(buffer.length * 0.3);
  }
​
  return {
    name: 'vite-bundle-analyzer',
    generateBundle(_, bundle) {
      for (const [fileName, output] of Object.entries(bundle)) {
        const info: ModuleInfo = {
          name: fileName,
          size: 0,
          gzipSize: 0,
          type: output.type,
        };
​
        if (output.type === 'chunk') {
          const chunk = output as OutputChunk;
          info.size = Buffer.byteLength(chunk.code, 'utf-8');
          info.gzipSize = estimateGzipSize(chunk.code);
          info.imports = chunk.imports;
        } else {
          const asset = output as OutputAsset;
          const source = typeof asset.source === 'string'
            ? asset.source
            : Buffer.from(asset.source as ArrayBuffer);
          info.size = Buffer.isBuffer(source) ? source.length : Buffer.byteLength(source, 'utf-8');
          info.gzipSize = estimateGzipSize(source);
        }
​
        modules.push(info);
      }
    },
    writeBundle() {
      // 按大小排序
      modules.sort((a, b) => b.size - a.size);
​
      // 超过阈值告警
      const warnings = modules.filter(m => m.size > threshold * 1024);
      if (warnings.length > 0) {
        console.warn('\n[Bundle Analyzer] 超过阈值的模块:');
        for (const w of warnings) {
          console.warn(`  ${w.name}: ${(w.size / 1024).toFixed(1)}KB (gzip: ${(w.gzipSize / 1024).toFixed(1)}KB)`);
        }
      }
​
      // 生成 HTML 报告
      const html = generateReport(modules, threshold);
      const fs = require('fs');
      fs.writeFileSync(outputFile, html, 'utf-8');
​
      console.log(`\n[Bundle Analyzer] 报告已生成: ${outputFile}`);
​
      if (open) {
        require('child_process').exec(`start ${outputFile}`);
      }
    },
  };
}
​
function generateReport(modules: ModuleInfo[], threshold: number): string {
  const rows = modules.map(m => `
    <tr class="${m.size > threshold * 1024 ? 'warning' : ''}">
      <td>${m.name}</td>
      <td>${(m.size / 1024).toFixed(1)} KB</td>
      <td>${(m.gzipSize / 1024).toFixed(1)} KB</td>
      <td>${m.type}</td>
    </tr>
  `).join('');
​
  return `<!DOCTYPE html>
<html><head><title>Bundle Report</title>
<style>
  body { font-family: monospace; margin: 20px; }
  table { border-collapse: collapse; width: 100%; }
  th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #eee; }
  .warning { background: #fff3cd; }
</style></head><body>
<h1>Bundle Analysis Report</h1>
<p>Total modules: ${modules.length} | Threshold: ${threshold}KB</p>
<table><tr><th>Module</th><th>Size</th><th>Gzip</th><th>Type</th></tr>
${rows}</table></body></html>`;
}

七、插件开发最佳实践

实践 说明
使用虚拟模块 避免文件系统 IO,用 virtual: 前缀 + \0 标识
区分 dev/build apply: 'serve'apply: 'build' 限制钩子执行环境
HMR 支持 configureServer + handleHotUpdate 实现热更新
类型声明 为虚拟模块提供 .d.ts 类型声明
副作用清理 buildEnd / closeBundle 中释放资源
错误处理 使用 this.error() 而非 throw,提供更好的错误信息

总结

Vite 插件开发的核心在于理解钩子执行时机和适用场景:

  1. 构建时转换transform + load(代码转换、注入)

  2. 开发服务器增强configureServer + handleHotUpdate(HMR、代理)

  3. HTML 注入transformIndexHtml(脚本、样式注入)

  4. 产物处理generateBundle + writeBundle(分析、压缩)

掌握这 5 个插件的实现思路,你就能应对 90% 的定制构建需求。建议从简单的环境变量注入开始,逐步挑战路由生成和产物分析。插件开发是前端工程化的高级技能,值得深入投入。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐