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 | resolveId、load、transformなどのビルドライフサイクルフック |
| transform | モジュールのソースコードを変換、変更後のcodeとsourcemapを返す |
| load | カスタムモジュール読み込みロジック、デフォルトのファイル読み取りの代わりにコード内容を返せる |
| resolveId | カスタムモジュール解決、importパスを傍受して仮想モジュールや実際のファイルにマッピング |
| configureServer | Vite開発サーバーの設定、ミドルウェアとカスタムリクエスト処理を追加 |
| buildStart | ビルド開始時にトリガー、リソースの初期化と設定の読み込みに使用 |
| buildEnd | ビルド終了時にトリガー、クリーンアップとレポート生成に使用 |
| HMR | ホットモジュールリプレイスメント、開発時にページ全体のリロードなしでモジュールを部分的に更新 |
5つの主要課題の深掘り分析
課題1:プラグインの実行順序
Viteプラグインはenforce(pre/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の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をオンラインでフォーマット
ブラウザローカルツールを無料で試す →