Vue 3 Viteプラグイン開発:カスタムビルド最適化プラグインの5つのコアパターン

前端工程

ビルド最適化のペインポイント:なぜカスタムViteプラグインが必要なのか

2026年、ViteはVue 3プロジェクトの標準ビルドツールとなりましたが、すぐに使える最適化がビジネス要件を常に満たすとは限りません。バンドルサイズの肥大化、コールドスタートの遅さ、カスタムコード変換の手段不足、RollupプラグインAPIの急な学習曲線 — これらの問題により、開発者はビルド最適化に二の足を踏んでいます。ViteプラグインはRollupプラグイン体系を拡張したもので、そのコアパターンを習得すれば、ビルドパイプラインのパフォーマンスボトルネックを正確に解決できます。

ペインポイント 具体的な表れ 影響
バンドルサイズが大きい サードパーティライブラリの未Tree-shake、重複依存、未圧縮リソース 初回描画が3秒超
ビルドが遅い フルtransform、キャッシュなし、同期的処理 CI/CDパイプラインのタイムアウト
カスタム要件を満たせない フレームワーク組み込みプラグインがビジネスシナリオをカバーしない 手動でのビルドハック
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.logvite --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の2つの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エコシステムは十分に成熟しています。Rustで書き直されたRollup代替であるRolldownが統合中であり、将来的にViteのプラグインAPIは互換性を保ちつつ、基盤のパフォーマンスが大幅に向上します。シンプルなtransformプラグインから始め、徐々に仮想モジュールとHMRメカニズムを習得し、最終的に完全なビルド最適化ソリューションを実現することをお勧めします。


オンラインツールおすすめ

  • Vite Playground — オンラインでViteビルドフローとプラグイン効果を体験
  • Rollup REPL — オンラインでRollupプラグインのコンパイル出力をデバッグ
  • AST Explorer — コードASTを視覚化し、transform開発を支援
  • Bundlephobia — バンドルサイズを分析し、最適化効果を評価
  • ToolsKu JSONフォーマッター — ビルド成果物JSONをオンラインでフォーマット

ブラウザローカルツールを無料で試す →

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