上线前1小时崩了!Vite插件并行Bug根治方案:用Rollup API玩明白「插件接力」

66 阅读4分钟

一、血泪开篇:一个插件并行 Bug 毁了我的上线日

上周三晚上 8 点,距离项目上线只剩 1 小时 ——

我信心满满地执行vite build,控制台却突然爆红:Error: 雪碧图文件不存在。

明明本地开发时好好的,为啥打包就翻车?

  • 我用了「SVG 雪碧图插件 A」处理图标,再用「Icon 组件生成插件 B」自动生成组件
  • 为了让插件 A 先跑,还特意给 A 加了enforce: 'pre'
  • 结果插件 B 的buildStart比插件 A 的异步处理先执行,拿着 “还没生成的雪碧图路径” 去造组件,直接报错!

相信不少做 Vite 插件开发的同学都踩过这坑:Vite 的 enforce 只能控 “插件顺序”,控不了 “方法级时序” —— 就像两个人同时跑接力赛,第二棒还没等第一棒把接力棒递过来,就先冲出去了,不摔才怪!

二、破局脑洞:把 Rollup 的「API 属性」变成 “接力棒”

既然 Vite 插件是 Rollup 的扩展,那就能利用 Rollup 的 “隐藏技能”:

插件能通过 api 属性对外暴露方法,就像运动员手里的接力棒 —— 前一棒跑完,把棒递出去,后一棒拿到再跑

核心逻辑用流程图一看就懂:

image.png

简单说:用 Promise 当 “接力棒”,前序插件跑完异步操作再 “交棒”,后续插件拿到 “棒” 才启动 —— 彻底根治 “抢跑” 问题!

三、手把手实现:3 步玩明白「插件接力」

Step 1:前序插件(svg-sprite-plugin):打造 “接力棒”

核心是完成 “异步处理 SVG” 和 “暴露 Promise”,确保后续插件能等待:

// svg-sprite-plugin.js
import fs from 'fs/promises';
import path from 'path';
export default function svgSpritePlugin(options = {}) {
  const { iconDir = './src/icons', outputPath = './src/assets/sprite.svg' } = options;
  let spritePromise;
  return {
    name: 'vite-plugin-svg-sprite', // 唯一标识,供后续插件定位
    api: {
      // 暴露方法,返回异步处理的Promise
      waitForSpriteReady() {
        return spritePromise || Promise.resolve();
      }
    },
    async buildStart() {
      console.log(`开始处理SVG图标(目录:${iconDir})`);
      // 用Promise包裹异步处理逻辑
      spritePromise = new Promise(async (resolve, reject) => {
        try {
          // 读取并过滤SVG文件
          const svgFiles = await fs.readdir(iconDir);
          const validSvgs = svgFiles.filter(file => path.extname(file) === '.svg');
          if (validSvgs.length === 0) {
            console.warn(`${iconDir}目录下没找到SVG文件`);
            resolve();
            return;
          }
          // 生成雪碧图内容
          let spriteContent = `<svg xmlns="http://www.w3.org/2000/svg" style="display: none;">`;
          for (const file of validSvgs) {
            const fileName = path.basename(file, '.svg');
            const svgPath = path.join(iconDir, file);
            const svgContent = await fs.readFile(svgPath, 'utf-8');
            const svgWithId = svgContent.replace('<svg', `<symbol id="icon-${fileName}"`).replace('</svg>', '</symbol>');
            spriteContent += svgWithId;
          }
          spriteContent += `</svg>`;
          // 写入雪碧图文件
          await fs.writeFile(outputPath, spriteContent);
          console.log(`雪碧图生成完成!路径:${outputPath}`);
          resolve();
        } catch (err) {
          console.error(`SVG处理失败:${err.message}`);
          reject(err);
        }
      });
    }
  };
}

Step 2:后续插件(icon-component-plugin):接住 “接力棒”

先定位前序插件、等待异步完成,再生成 Icon 组件:

// icon-component-plugin.js
import fs from 'fs/promises';
import path from 'path';
export default function iconComponentPlugin(options = {}) {
  const { outputPath = './src/components/Icon.vue', spritePath = './src/assets/sprite.svg' } = options;
  return {
    name: 'vite-plugin-icon-component',
    async buildStart({ plugins }) {
      console.log(`正在寻找SVG雪碧图插件...`);
      // 1. 定位前序插件(通过name匹配)
      const svgPlugin = plugins.find(
        plugin => plugin?.name === 'vite-plugin-svg-sprite'
      );
      if (!svgPlugin) {
        console.error('未找到vite-plugin-svg-sprite,请先在vite.config中注册它');
        return;
      }
      // 2. 等待前序插件异步处理完成
      console.log(`等待雪碧图生成...`);
      try {
        await svgPlugin.api.waitForSpriteReady();
      } catch (err) {
        console.error(`等待雪碧图失败:${err.message}`);
        return;
      }
      // 3. 生成Icon组件
      console.log(`开始生成Icon组件...`);
      const componentCode = `
<template>
  <svg 
    class="icon" 
    :class="{'icon--spin': spin}" 
    :style="{ width: size + 'px', height: size + 'px', color: color }"
  >
    <use :xlink:href="`${spritePath}#icon-${name}`" />
  </svg>
</template>
<script setup>
import { defineProps, withDefaults } from 'vue';
const props = withDefaults(defineProps({
  name: { type: String, required: true },
  size: { type: [Number, String], default: 24 },
  color: { type: String, default: 'currentColor' },
  spin: { type: Boolean, default: false }
}), {});
const spritePath = import.meta.env.VITE_SPRITE_PATH || '${spritePath}';
</script>
<style scoped>
.icon { display: inline-block; vertical-align: middle; fill: currentColor; }
.icon--spin { animation: spin 1s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
</style>
      `.trim();
      // 确保目录存在并写入文件
      await fs.mkdir(path.dirname(outputPath), { recursive: true });
      await fs.writeFile(outputPath, componentCode);
      console.log(`Icon组件生成完成!路径:${outputPath}`);
    }
  };
}

Step 3:Vite 配置 + 实测:看 “接力赛” 怎么跑

前序插件需先注册,确保后续插件能定位到:

// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import svgSpritePlugin from './svg-sprite-plugin';
import iconComponentPlugin from './icon-component-plugin';
export default defineConfig({
  plugins: [
    vue(),
    // 1. 先注册前序插件(处理SVG)
    svgSpritePlugin({
      iconDir: './src/icons',
      outputPath: './src/assets/sprite.svg'
    }),
    // 2. 后注册后续插件(生成Icon组件)
    iconComponentPlugin({
      outputPath: './src/components/Icon.vue'
    })
  ],
  // 可选:配置环境变量切换雪碧图路径
  define: {
    'import.meta.env.VITE_SPRITE_PATH': JSON.stringify('/src/assets/sprite.svg')
  }
});

实测效果:在src/icons放 2 个 SVG 文件,执行vite build,控制台会按 “处理 SVG→等待→生成组件” 的顺序打印日志,再也不会报 “文件不存在”!

四、避坑要点:3 个高频问题解决方案

坑点症状解决方案
插件 name 重复后续插件找不到前序插件给插件起唯一 name,如加项目前缀
前序插件没 resolve后续插件无限等待异步操作完成后调用resolve(),出错时调用reject()
插件注册顺序反了后续插件定位不到前序插件前序插件放在plugins数组前面

五、进阶场景:不止于 buildStart

该方案可覆盖 Vite 全生命周期,举 3 个实用场景:

  1. transform 阶段:插件 A 转译 Vue 模板后,插件 B 等待并执行代码压缩;
  1. generateBundle 阶段:插件 A 生成配置文件后,插件 B 等待并注入到index.html;
  1. 多插件链式依赖:插件 A→B→C,B 暴露 Promise 供 C 等待,形成链式接力。

六、总结

Vite 插件并行 Bug 的核心解法,是用 Rollup 的api属性做 “接力通信”—— 前序插件用 Promise 包裹异步逻辑,后续插件await等待后再执行。这套方案无需额外依赖,仅靠 Rollup 原生能力,就能根治 “方法级时序” 问题,从 “上线前踩坑” 变成 “稳定交付”。

下次遇到插件依赖问题,直接用 “Promise 接力” 思路,就能高效解决!