Vue 3 Vite插件開發實戰:構建自定義優化插件的5個核心模式

前端工程

構建優化的痛點:為什麼需要自定義Vite插件

2026年,Vite已成為Vue 3專案的標配構建工具,但開箱即用的優化並不總能滿足業務需求。打包體積膨脹、冷啟動慢、自定義程式碼轉換無門、Rollup插件API學習曲線陡峭——這些問題讓開發者對構建優化望而卻步。Vite插件基於Rollup插件體系擴展,掌握其核心模式,就能精準解決構建鏈路中的效能瓶頸。

痛點 具體表現 影響
打包體積大 第三方庫未Tree-shake、重複依賴、未壓縮資源 首屏載入超3秒
構建速度慢 全量transform、無快取、同步處理 CI/CD流水線超時
自定義需求無法滿足 框架內建插件不覆蓋業務場景 手動hack構建流程
Rollup插件API複雜 Hook眾多、執行順序不明、enforce機制模糊 插件衝突頻發

核心概念速覽

概念 說明
Vite Plugin 基於Rollup插件介面擴展,增加Vite專屬Hook
Rollup Plugin Hook resolveIdloadtransform等構建生命週期鉤子
transform 對模組原始碼進行轉換,回傳修改後的code和sourcemap
load 自定義模組載入邏輯,可回傳程式碼內容替代預設檔案讀取
resolveId 自定義模組解析,攔截import路徑對映到虛擬模組或真實檔案
configureServer 配置Vite開發伺服器,新增中介軟體和自定義請求處理
buildStart 構建開始時觸發,用於初始化資源、讀取配置
buildEnd 構建結束時觸發,用於清理資源、生成報告
HMR 熱模組替換,開發時區域性更新模組無需重新整理頁面

5大挑戰深度分析

挑戰1:插件執行順序

Vite插件按enforcepre/post/預設)排序,同組內按陣列順序執行。多個插件操作同一檔案時,順序錯誤會導致轉換結果不可預期。

挑戰2:開發/生產環境差異

Vite開發模式使用esbuild處理TS/JSX,生產模式使用Rollup。同一插件在兩種模式下行為可能不同,需用apply: 'build' | 'serve'控制。

挑戰3:HMR相容

自定義transform必須回傳正確的sourcemap,否則HMR定位失敗。虛擬模組需透過handleHotUpdate手動管理更新邊界。

挑戰4:虛擬模組處理

虛擬模組(如virtual:xxx)沒有對應檔案,需在resolveId中標記\0前綴,防止其他插件處理,並在load中回傳內容。

挑戰5:插件除錯困難

插件內部錯誤不會直接暴露到終端,transform回傳值格式錯誤會導致靜默失敗。需要手動新增console.log或使用vite --debug plugin


模式1:Vite插件基礎結構與Hook

import type { Plugin, ResolvedConfig } from 'vite'

interface BuildStatsPluginOptions {
  outputFileName?: string
  includeModules?: RegExp
}

export default function buildStatsPlugin(
  options: BuildStatsPluginOptions = {}
): Plugin {
  const outputFileName = options.outputFileName ?? 'build-stats.json'
  const includeModules = options.includeModules ?? /\.[jt]sx?$/
  let config: ResolvedConfig
  const moduleSizes = new Map<string, number>()

  return {
    name: 'vite-plugin-build-stats',
    enforce: 'post',
    apply: 'build',

    configResolved(resolvedConfig) {
      config = resolvedConfig
    },

    buildStart() {
      moduleSizes.clear()
      console.log(`[build-stats] 構建開始,模式: ${config.mode}`)
    },

    transform(code, id) {
      if (includeModules.test(id)) {
        moduleSizes.set(id, code.length)
      }
      return null
    },

    buildEnd(error) {
      if (error) {
        console.error(`[build-stats] 構建失敗: ${error.message}`)
        return
      }
      const totalSize = Array.from(moduleSizes.values()).reduce(
        (sum, size) => sum + size,
        0
      )
      console.log(
        `[build-stats] 共${moduleSizes.size}個模組,總大小${(totalSize / 1024).toFixed(1)}KB`
      )
    },

    closeBundle() {
      const stats = Object.fromEntries(moduleSizes)
      const fs = require('fs')
      const path = require('path')
      fs.writeFileSync(
        path.join(config.build.outDir, outputFileName),
        JSON.stringify(stats, null, 2)
      )
    }
  }
}

模式2:自定義transform程式碼轉換

import type { Plugin } from 'vite'
import { createFilter, type FilterPattern } from '@rollup/pluginutils'

interface RemoveConsolePluginOptions {
  include?: FilterPattern
  exclude?: FilterPattern
  removeMethods?: string[]
  retainExternal?: boolean
}

export default function removeConsolePlugin(
  options: RemoveConsolePluginOptions = {}
): Plugin {
  const {
    removeMethods = ['log', 'debug', 'info'],
    retainExternal = true
  } = options
  const filter = createFilter(options.include, options.exclude)
  const methodPattern = removeMethods.join('|')
  const consoleRegex = new RegExp(
    `console\\.(${methodPattern})\\s*\\([^)]*\\)\\s*;?`,
    'g'
  )

  return {
    name: 'vite-plugin-remove-console',
    enforce: 'post',
    apply: 'build',

    transform(code, id) {
      if (!filter(id)) return null
      const matches = code.match(consoleRegex)
      if (!matches || matches.length === 0) return null

      const newCode = code.replace(consoleRegex, '')
      const linesRemoved = matches.length
      console.log(
        `[remove-console] ${id}: 移除${linesRemoved}條console呼叫`
      )

      return {
        code: newCode,
        map: null
      }
    }
  }
}

模式3:虛擬模組與resolveId

import type { Plugin } from 'vite'

const VIRTUAL_MODULE_ID = 'virtual:env-config'
const RESOLVED_MODULE_ID = '\0' + VIRTUAL_MODULE_ID

interface EnvConfigPluginOptions {
  publicVars: Record<string, string>
  secretVars?: Record<string, string>
}

export default function envConfigPlugin(
  options: EnvConfigPluginOptions
): Plugin {
  const { publicVars, secretVars = {} } = options
  let isProduction = false

  return {
    name: 'vite-plugin-env-config',
    enforce: 'pre',

    configResolved(config) {
      isProduction = config.command === 'build'
    },

    resolveId(source) {
      if (source === VIRTUAL_MODULE_ID) {
        return RESOLVED_MODULE_ID
      }
      return null
    },

    load(id) {
      if (id !== RESOLVED_MODULE_ID) return null

      const allVars = isProduction
        ? { ...publicVars, ...secretVars }
        : publicVars

      const exports = Object.entries(allVars)
        .map(([key, value]) => `export const ${key} = ${JSON.stringify(value)}`)
        .join('\n')

      return `${exports}\nexport default { ${Object.keys(allVars).join(', ')} }`
    },

    handleHotUpdate({ file, server }) {
      if (file.endsWith('.env') || file.endsWith('.env.local')) {
        server.ws.send({
          type: 'full-reload',
          path: '*'
        })
      }
    }
  }
}

// 使用:import envConfig from 'virtual:env-config'

模式4:開發伺服器中介軟體與HMR

import type { Plugin, ViteDevServer } from 'vite'

interface ApiMockPluginOptions {
  mockDir?: string
  prefix?: string
}

export default function apiMockPlugin(
  options: ApiMockPluginOptions = {}
): Plugin {
  const mockDir = options.mockDir ?? 'mocks'
  const prefix = options.prefix ?? '/api/'
  let server: ViteDevServer
  const mockModules = new Map<string, any>()

  return {
    name: 'vite-plugin-api-mock',
    apply: 'serve',
    enforce: 'pre',

    configureServer(devServer) {
      server = devServer

      devServer.middlewares.use(async (req, res, next) => {
        if (!req.url?.startsWith(prefix)) {
          return next()
        }

        const mockPath = req.url.replace(prefix, '').replace(/\?.*$/, '')
        const handler = mockModules.get(mockPath)

        if (handler) {
          res.setHeader('Content-Type', 'application/json')
          res.setHeader('Access-Control-Allow-Origin', '*')
          const body =
            typeof handler === 'function'
              ? await handler(req)
              : handler
          res.end(JSON.stringify(body))
          return
        }

        next()
      })
    },

    async buildStart() {
      const fs = require('fs')
      const path = require('path')
      const mockRoot = path.resolve(process.cwd(), mockDir)

      if (!fs.existsSync(mockRoot)) return

      const files = fs.readdirSync(mockRoot).filter((f: string) => f.endsWith('.json'))
      for (const file of files) {
        const name = file.replace('.json', '')
        const content = fs.readFileSync(path.join(mockRoot, file), 'utf-8')
        mockModules.set(name, JSON.parse(content))
      }
      console.log(`[api-mock] 已載入${mockModules.size}個mock介面`)
    },

    handleHotUpdate({ file }) {
      if (file.includes(mockDir)) {
        const name = file.split('/').pop()?.replace('.json', '')
        if (name) {
          const fs = require('fs')
          const content = fs.readFileSync(file, 'utf-8')
          mockModules.set(name, JSON.parse(content))
          console.log(`[api-mock] 熱更新: ${name}`)
        }
        return []
      }
    }
  }
}

模式5:生產構建優化插件

import type { Plugin, RollupOutputAsset } from 'vite'
import { gzipSync } from 'zlib'

interface CompressPluginOptions {
  algorithm?: 'gzip' | 'brotli'
  threshold?: number
  deleteOriginal?: boolean
}

export default function compressPlugin(
  options: CompressPluginOptions = {}
): Plugin {
  const algorithm = options.algorithm ?? 'gzip'
  const threshold = options.threshold ?? 1024
  const deleteOriginal = options.deleteOriginal ?? false

  return {
    name: 'vite-plugin-compress',
    enforce: 'post',
    apply: 'build',

    generateBundle(_, bundle) {
      const extensions = ['.js', '.css', '.html', '.svg', '.json']

      for (const [fileName, file] of Object.entries(bundle)) {
        if (file.type !== 'chunk' && file.type !== 'asset') continue
        if (!extensions.some((ext) => fileName.endsWith(ext))) continue

        const source =
          file.type === 'chunk'
            ? Buffer.from(file.code, 'utf-8')
            : Buffer.from((file as RollupOutputAsset).source as string, 'utf-8')

        if (source.length < threshold) continue

        if (algorithm === 'gzip') {
          const compressed = gzipSync(source, { level: 9 })
          this.emitFile({
            type: 'asset',
            fileName: `${fileName}.gz`,
            source: compressed
          })
          console.log(
            `[compress] ${fileName}: ${(source.length / 1024).toFixed(1)}KB → ${(compressed.length / 1024).toFixed(1)}KB (${((1 - compressed.length / source.length) * 100).toFixed(1)}%)`
          )
        }

        if (deleteOriginal) {
          delete bundle[fileName]
        }
      }
    }
  }
}

避坑指南

場景 錯誤做法 正確做法
Hook回傳值 ❌ transform中不回傳sourcemap ✅ 回傳{ code, map }或使用MagicString生成map
虛擬模組ID ❌ 虛擬模組ID不加\0前綴 ✅ resolveId回傳\0+id,防止其他插件處理
enforce順序 ❌ 不設enforce,依賴預設順序 ✅ 明確設定enforce: 'pre''post'控制執行時機
HMR處理 ❌ 虛擬模組不實作handleHotUpdate ✅ 實作handleHotUpdate回傳受影響模組或空陣列阻止重新整理
apply條件 ❌ 開發專用插件不限制apply ✅ 設定apply: 'serve''build'避免無效執行

報錯排查

錯誤訊息 原因 解決方案
Plugin "${name}" returned invalid transform result transform回傳值格式錯誤 確保回傳{ code: string, map?: any }或null
Could not resolve "virtual:xxx" resolveId未正確處理虛擬模組 在resolveId中匹配虛擬ID並回傳\0前綴ID
Sourcemap is likely to be incorrect transform後sourcemap不匹配 使用MagicString生成正確sourcemap
HMR update failed: module not found handleHotUpdate回傳了不存在的模組 檢查回傳的模組ID是否在模組圖中存在
Plugin conflict: multiple plugins handle "${id}" 多個插件同時處理同一模組 使用enforce或filter控制插件作用範圍
Cannot read properties of undefined (reading 'ws') 在非serve模式下存取server物件 檢查apply配置,確保僅在serve模式使用configureServer
Circular dependency: virtual:xxx 虛擬模組內容中import了自身 檢查load回傳的程式碼,移除自引用
Build timeout in plugin "${name}" 插件中存在長時間同步操作 將同步操作改為非同步,使用Worker或快取
ENOENT: no such file, open "virtual:xxx" 虛擬模組未在load中回傳內容 確保load鉤子匹配resolvedId並回傳程式碼
Unexpected token after transform transform輸出非法JS語法 檢查程式碼替換邏輯,確保輸出合法AST

進階優化技巧

1. 插件快取與增量構建

import type { Plugin } from 'vite'
import { createHash } from 'crypto'
import { readFileSync, writeFileSync, existsSync } from 'fs'

const cacheDir = 'node_modules/.vite-plugin-cache'

export function withCache(
  pluginName: string,
  transformFn: (code: string, id: string) => string | null
): Plugin {
  return {
    name: pluginName,
    transform(code, id) {
      const hash = createHash('md5').update(code).digest('hex').slice(0, 8)
      const cacheFile = `${cacheDir}/${pluginName}/${hash}.js`
      if (existsSync(cacheFile)) {
        return { code: readFileSync(cacheFile, 'utf-8'), map: null }
      }
      const result = transformFn(code, id)
      if (result) {
        writeFileSync(cacheFile, result)
        return { code: result, map: null }
      }
      return null
    }
  }
}

2. 插件組合與管道

import type { Plugin } from 'vite'

export function composePlugins(
  name: string,
  plugins: Array<Pick<Plugin, 'transform' | 'name'>>
): Plugin {
  return {
    name,
    async transform(code, id) {
      let result: string = code
      for (const plugin of plugins) {
        if (!plugin.transform) continue
        const output = await plugin.transform.call(this as any, result, id)
        if (output && typeof output === 'object') {
          result = output.code
        } else if (typeof output === 'string') {
          result = output
        }
      }
      return result === code ? null : { code: result, map: null }
    }
  }
}

3. 插件除錯工具

import type { Plugin } from 'vite'

export function debugPlugin(options: { logTransforms?: boolean } = {}): Plugin {
  const transformTimings: Array<{ name: string; id: string; duration: number }> = []

  return {
    name: 'vite-plugin-debug',
    enforce: 'post',

    transform(code, id) {
      if (!options.logTransforms) return null
      const start = performance.now()
      const duration = performance.now() - start
      transformTimings.push({ name: 'debug', id, duration })
      return null
    },

    buildEnd() {
      const byPlugin = new Map<string, number>()
      for (const t of transformTimings) {
        byPlugin.set(t.name, (byPlugin.get(t.name) ?? 0) + t.duration)
      }
      console.table(Object.fromEntries(byPlugin))
    }
  }
}

對比分析

維度 Vite插件 Webpack Loader/Plugin Rollup插件 esbuild插件
學習曲線 低(Rollup超集) 高(Loader+Plugin兩套API) 中(純Rollup API) 低(但功能受限)
Hook豐富度 高(Rollup+Vite專屬) 極高(全生命週期) 中(核心Hook) 低(僅onLoad/onResolve)
HMR支援 原生支援 需webpack-dev-server 不支援(純打包工具) 不支援
虛擬模組 \0前綴約定 resolve插件 \0前綴約定 namespace機制
開發/構建一致性 中(esbuild vs Rollup差異) 高(同一套Loader) 高(同一套API) 高(同一引擎)
TypeScript 完整型別支援 需額外配置 完整型別支援 有限支援
生態豐富度 快速增長 最成熟 成長中 較少
構建效能 快(esbuild預構建) 極快

總結展望

Vite插件開發的核心在於理解Rollup插件體系與Vite專屬擴展的結合。5個核心模式——基礎結構與Hook、自定義transform、虛擬模組、開發伺服器中介軟體與HMR、生產構建優化——覆蓋了從開發到生產的完整構建鏈路。

2026年的Vite生態已足夠成熟,Rolldown作為Rust重寫的Rollup替代品正在整合中,未來Vite的插件API將保持相容,但底層效能將大幅提升。建議從簡單的transform插件入手,逐步掌握虛擬模組和HMR機制,最終實現完整的構建優化方案。


線上工具推薦

本站提供瀏覽器本地工具,免註冊即可試用 →

#Vite插件开发#Vue3#Rollup插件#构建优化#自定义插件#2026#前端工程