Vue3+Viteビルド最適化:バンドルサイズから読み込みパフォーマンスまでの7つの主要戦略
Vue3プロジェクトのビルド後3.8MB、初回読み込み8秒
あなたはVue3+Viteで中規模バックオフィスシステムを開発しました。開発時のホットリロードは爆速ですが、いざデプロイすると:ビルド産物3.8MB、初回ペイント8秒、Lighthouseパフォーマンススコア42点。様々な最適化手法を試しましたが、Tree Shakingが効いていないようで、コード分割後にかえって遅くなり、CDN外部化の設定でエラーが山ほど出ます。2026年、Vue3+Viteビルド最適化は成熟したベストプラクティス体系を形成しています。Rollup設定からコード分割、Tree ShakingからCDN外部化まで、7つの主要戦略でバンドルサイズを70%削減し、初回読み込み時間を60%短縮できます。
本記事では、Viteの基盤となるビルド原理から出発し、7つの主要最適化戦略の完全な実践を解説します。バンドルサイズから読み込みパフォーマンスまで、開発から本番まで全パイプラインを最適化します。
Vue3+Viteビルド最適化コア概念
| 概念 | 説明 |
|---|---|
| Tree Shaking | ESM静的解析に基づき、未使用コードを削除する最適化技術 |
| Code Splitting | コードをルートや機能ごとに複数チャンクに分割し、オンデマンドで読み込む |
| Lazy Loading | ルートレベルまたはコンポーネントレベルの遅延読み込み、初回は必要なコードのみ読み込む |
| CDN外部化(Externalization) | 大きなライブラリをCDNから読み込み、バンドルに含めない |
| 圧縮(Compression) | ビルド産物をgzip/brotli圧縮し、転送サイズを削減 |
| Source Map | ソースマッピングファイル。本番環境デバッグに使用。戦略的な取り扱いが必要 |
| Rollup | Viteの本番ビルドで内部使用されるバンドラー |
| manualChunks | Rollupのコード分割戦略を手動設定するオプション |
| sideEffects | package.jsonフィールド。モジュールに副作用があるかをマークし、Tree Shakingに影響 |
Viteビルドパイプライン
開発モード:
ソース → esbuild依存関係プレバンドル → ブラウザESMオンデマンド読み込み → 即時HMR
本番ビルド:
ソース → Rollup解析 → Tree Shaking → コード分割 → 圧縮 → 産物出力
問題分析:Vue3+Viteビルド最適化の5つの主要課題
- バンドルサイズの制御不能: サードパーティ依存関係のオンデマンドインポート未実施、重複バンドル、Tree Shaking未設定による産物サイズの継続的膨張
- Tree Shakingが機能しない: CommonJSモジュールの使用、sideEffects未設定、動的インポートによる静的解析の失敗
- コード分割戦略が不明確: manualChunksの不適切な設定によるチャンク断片化または共有依存関係の重複バンドル
- CDN外部化設定エラー: vite-plugin-cdn-importの設定ミス、CDNリソース読み込み失敗、バージョン不一致
- 本番環境でのSource Map漏洩: source mapのCDNへの誤アップロード、ソースコード情報の露出によるセキュリティリスク
ステップバイステップ:7つの主要最適化戦略
戦略1:Vite基本設定最適化
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
build: {
target: 'es2020',
outDir: 'dist',
assetsDir: 'assets',
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ['console.log', 'console.info'],
},
},
cssCodeSplit: true,
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
},
output: {
chunkFileNames: 'assets/js/[name]-[hash].js',
entryFileNames: 'assets/js/[name]-[hash].js',
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
compact: true,
},
},
reportCompressedSize: false,
chunkSizeWarningLimit: 500,
},
})
戦略2:コード分割戦略
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('vue') || id.includes('@vue')) {
return 'vendor-vue'
}
if (id.includes('element-plus')) {
return 'vendor-element'
}
if (id.includes('echarts')) {
return 'vendor-echarts'
}
if (id.includes('lodash')) {
return 'vendor-lodash'
}
if (id.includes('axios')) {
return 'vendor-axios'
}
return 'vendor-other'
}
},
},
},
},
})
ルートレベルコード分割:
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue'),
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
},
{
path: '/user',
name: 'User',
component: () => import('@/views/user/UserLayout.vue'),
children: [
{
path: 'profile',
name: 'UserProfile',
component: () => import('@/views/user/Profile.vue'),
},
{
path: 'settings',
name: 'UserSettings',
component: () => import('@/views/user/Settings.vue'),
},
],
},
{
path: '/admin',
name: 'Admin',
component: () => import('@/views/admin/AdminLayout.vue'),
children: [
{
path: 'users',
name: 'AdminUsers',
component: () => import('@/views/admin/UserManage.vue'),
},
{
path: 'roles',
name: 'AdminRoles',
component: () => import('@/views/admin/RoleManage.vue'),
},
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router
戦略3:Tree Shaking深層最適化
package.json設定:
{
"name": "vue3-vite-app",
"version": "1.0.0",
"sideEffects": [
"*.css",
"*.scss",
"*.less",
"*.vue"
]
}
Element Plusのオンデマンドインポート:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
dts: 'src/auto-imports.d.ts',
}),
Components({
resolvers: [ElementPlusResolver()],
dts: 'src/components.d.ts',
}),
],
})
lodashのオンデマンドインポート:
import { debounce, throttle, cloneDeep, merge } from 'lodash-es'
const handleSearch = debounce((keyword: string) => {
console.log('searching:', keyword)
}, 300)
const handleScroll = throttle(() => {
console.log('scrolling')
}, 100)
const clonedData = cloneDeep(originalData)
const mergedConfig = merge(defaultConfig, userConfig)
戦略4:ルートとコンポーネントの遅延読み込み
コンポーネントレベルの遅延読み込み:
<template>
<div class="page-container">
<h1>Dashboard</h1>
<div class="chart-section">
<LazyChart v-if="showChart" :data="chartData" />
</div>
<div class="table-section">
<LazyTable v-if="showTable" :rows="tableData" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, defineAsyncComponent } from 'vue'
const LazyChart = defineAsyncComponent(() =>
import('@/components/Chart.vue')
)
const LazyTable = defineAsyncComponent(() =>
import('@/components/DataTable.vue')
)
const showChart = ref(false)
const showTable = ref(false)
const chartData = ref([])
const tableData = ref([])
setTimeout(() => {
showChart.value = true
}, 300)
setTimeout(() => {
showTable.value = true
}, 600)
</script>
Suspenseと遅延読み込みの組み合わせ:
<template>
<Suspense>
<template #default>
<LazyDashboard />
</template>
<template #fallback>
<div class="loading-skeleton">
<div class="skeleton-line" />
<div class="skeleton-line" />
<div class="skeleton-line short" />
</div>
</template>
</Suspense>
</template>
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
const LazyDashboard = defineAsyncComponent(() =>
import('@/views/Dashboard.vue')
)
</script>
戦略5:CDN外部化
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import cdn from 'vite-plugin-cdn-import'
export default defineConfig({
plugins: [
vue(),
cdn({
modules: [
{
name: 'vue',
var: 'Vue',
path: 'https://unpkg.com/vue@3.5.13/dist/vue.global.prod.js',
},
{
name: 'vue-router',
var: 'VueRouter',
path: 'https://unpkg.com/vue-router@4.4.5/dist/vue-router.global.prod.js',
},
{
name: 'pinia',
var: 'Pinia',
path: 'https://unpkg.com/pinia@2.2.6/dist/pinia.iife.prod.js',
},
{
name: 'axios',
var: 'Axios',
path: 'https://unpkg.com/axios@1.7.7/dist/axios.min.js',
},
{
name: 'echarts',
var: 'echarts',
path: 'https://unpkg.com/echarts@5.5.1/dist/echarts.min.js',
},
],
}),
],
})
カスタムCDN設定:
import { defineConfig, type Plugin } from 'vite'
import vue from '@vitejs/plugin-vue'
function customCdnPlugin(): Plugin {
const cdnBase = 'https://cdn.yourcompany.com/libs'
const externals: Record<string, string> = {
vue: 'Vue',
'vue-router': 'VueRouter',
pinia: 'Pinia',
axios: 'Axios',
echarts: 'echarts',
}
const versions: Record<string, string> = {
vue: '3.5.13',
'vue-router': '4.4.5',
pinia: '2.2.6',
axios: '1.7.7',
echarts: '5.5.1',
}
return {
name: 'custom-cdn-plugin',
enforce: 'pre',
resolveId(id) {
if (externals[id]) {
return { id: externals[id], external: true }
}
return null
},
transformIndexHtml(html) {
const scripts = Object.entries(externals)
.map(([name, varName]) => {
const version = versions[name]
return `<script src="${cdnBase}/${name}@${version}/index.prod.js"></script>`
})
.join('\n')
return html.replace('</head>', `${scripts}\n</head>`)
},
}
}
export default defineConfig({
plugins: [vue(), customCdnPlugin()],
build: {
rollupOptions: {
external: Object.keys({
vue: 'Vue',
'vue-router': 'VueRouter',
pinia: 'Pinia',
axios: 'Axios',
echarts: 'echarts',
}),
output: {
globals: {
vue: 'Vue',
'vue-router': 'VueRouter',
pinia: 'Pinia',
axios: 'Axios',
echarts: 'echarts',
},
},
},
},
})
戦略6:圧縮最適化
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import viteCompression from 'vite-plugin-compression'
export default defineConfig({
plugins: [
vue(),
viteCompression({
algorithm: 'gzip',
ext: '.gz',
threshold: 1024,
deleteOriginFile: false,
}),
viteCompression({
algorithm: 'brotliCompress',
ext: '.br',
threshold: 1024,
deleteOriginFile: false,
}),
],
build: {
cssMinify: 'lightningcss',
},
})
Nginx gzip設定:
server {
listen 80;
server_name your-app.com;
gzip on;
gzip_static on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/json
application/xml
application/rss+xml
image/svg+xml;
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
}
戦略7:Source Map最適化
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig(({ mode }) => {
const isProduction = mode === 'production'
return {
plugins: [vue()],
build: {
sourcemap: isProduction ? 'hidden' : true,
},
}
})
Source Mapアップロードスクリプト:
import { execSync } from 'child_process'
import { readFileSync, readdirSync, statSync } from 'fs'
import { join, extname } from 'path'
import https from 'https'
interface SourceMapUploaderConfig {
distDir: string
release: string
apiEndpoint: string
authToken: string
}
function uploadSourceMaps(config: SourceMapUploaderConfig): void {
const { distDir, release, apiEndpoint, authToken } = config
function findSourceMaps(dir: string): string[] {
const results: string[] = []
const entries = readdirSync(dir)
for (const entry of entries) {
const fullPath = join(dir, entry)
const stat = statSync(fullPath)
if (stat.isDirectory()) {
results.push(...findSourceMaps(fullPath))
} else if (extname(entry) === '.map') {
results.push(fullPath)
}
}
return results
}
const sourceMaps = findSourceMaps(distDir)
console.log(`Found ${sourceMaps.length} source maps`)
for (const mapFile of sourceMaps) {
const content = readFileSync(mapFile, 'utf-8')
const jsFile = mapFile.replace('.map', '')
const data = JSON.stringify({
release,
url: jsFile.replace(distDir, ''),
sourcemap: content,
})
const options = {
hostname: new URL(apiEndpoint).hostname,
path: new URL(apiEndpoint).pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data),
Authorization: `Bearer ${authToken}`,
},
}
const req = https.request(options, (res) => {
console.log(`Uploaded ${mapFile}: ${res.statusCode}`)
})
req.on('error', (error) => {
console.error(`Failed to upload ${mapFile}:`, error.message)
})
req.write(data)
req.end()
}
}
const release = execSync('git rev-parse --short HEAD').toString().trim()
uploadSourceMaps({
distDir: './dist',
release,
apiEndpoint: 'https://sentry.io/api/0/projects/your-org/your-project/releases/',
authToken: process.env.SENTRY_AUTH_TOKEN || '',
})
よくある落とし穴:5つの罠
罠1:CommonJSモジュールがTree Shakingを無効にする
❌ 間違った書き方:
import _ from 'lodash'
const data = _.cloneDeep(originalData)
✅ 正しい書き方:
import { cloneDeep } from 'lodash-es'
const data = cloneDeep(originalData)
罠2:manualChunks設定が循環依存を引き起こす
❌ 間違った書き方:
manualChunks(id) {
if (id.includes('node_modules')) {
return 'vendor'
}
}
✅ 正しい書き方:
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('vue') || id.includes('@vue')) {
return 'vendor-vue'
}
if (id.includes('element-plus')) {
return 'vendor-element'
}
return 'vendor-other'
}
}
罠3:CDN外部化が開発環境を壊す
❌ 間違った書き方:
import cdn from 'vite-plugin-cdn-import'
export default defineConfig({
plugins: [cdn({ modules: [...] })],
})
✅ 正しい書き方:
import cdn from 'vite-plugin-cdn-import'
export default defineConfig(({ mode }) => ({
plugins: [
mode === 'production' ? cdn({ modules: [...] }) : null,
].filter(Boolean),
}))
罠4:動的インポートでマジックコメント未使用
❌ 間違った書き方:
component: () => import('@/views/Dashboard.vue')
✅ 正しい書き方:
component: () => import(
'@/views/Dashboard.vue'
)
webpackChunkName互換書き方:
component: () => import(
'@/views/Dashboard.vue'
)
罠5:Source Mapを本番環境に直接デプロイ
❌ 間違った書き方:
export default defineConfig({
build: {
sourcemap: true,
},
})
✅ 正しい書き方:
export default defineConfig(({ mode }) => ({
build: {
sourcemap: mode === 'production' ? 'hidden' : true,
},
}))
エラートラブルシューティング表
| エラーメッセージ | 原因 | 解決策 |
|---|---|---|
Rollup failed to resolve import "xxx" |
CDN外部化後にモジュールがglobalsに正しく設定されていない | rollupOptions.output.globalsにモジュールのグローバル変数名が含まれているか確認 |
Chunk size limit warning: xxx exceeds 500KB |
単一チャンクが警告閾値を超えている | manualChunks分割戦略を最適化、またはchunkSizeWarningLimitを調整 |
Tree Shaking not working for CJS module |
CommonJSモジュールは静的解析をサポートしていない | lodashの代わりにlodash-esを使用、依存関係がESM版を提供しているか確認 |
Uncaught TypeError: xxx is not a function |
CDN読み込み失敗またはバージョン不一致 | CDN URLのアクセス可能性を確認、グローバル変数名がCDNエクスポートと一致するか確認 |
Source map error: JSON.parse |
source mapファイルが破損または形式エラー | ビルド産物を再構築、sourcemap設定が正しいか確認 |
Dynamic import() not supported |
ビルドターゲットが低すぎて動的インポートをサポートしていない | build.targetを'es2020'以上に設定 |
CSS code split causing FOUC |
CSS コード分割によりスタイル未適用のフラッシュが発生 | クリティカルCSSをインライン化、またはcssCodeSplitをfalseに設定 |
vite-plugin-compression: brotli not supported |
Node.jsバージョンが低すぎてbrotliをサポートしていない | Node.jsをv11.7.0+にアップグレード、またはgzipのみ使用 |
manualChunks circular dependency |
manualChunks分割が循環参照を引き起こしている | 相互依存パッケージを異なるチャンクに分割しない、細粒度分割を使用 |
CDN script load timeout |
CDNリソース読み込みタイムアウト | CDNフォールバックローカルリソースを設定、自己ホストCDNまたはpreconnectを使用 |
高度な最適化テクニック
テクニック1:Rollup Plugin Visualizerでバンドルサイズ分析
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
vue(),
visualizer({
filename: './dist/stats.html',
open: true,
gzipSize: true,
brotliSize: true,
template: 'treemap',
}),
],
})
分析完了後、dist/stats.htmlを開くと各モジュールのサイズ比率を視覚的に確認でき、ボトルネックを正確に特定できます。
テクニック2:プリロードとプリフェッチ戦略
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import type { Plugin } from 'vite'
function prefetchPlugin(): Plugin {
return {
name: 'prefetch-plugin',
transformIndexHtml(html) {
return html.replace(
'</head>',
`<link rel="dns-prefetch" href="//cdn.yourcompany.com">
<link rel="preconnect" href="//cdn.yourcompany.com" crossorigin>
<link rel="prefetch" href="/assets/js/vendor-vue" as="script">
</head>`
)
},
}
}
export default defineConfig({
plugins: [vue(), prefetchPlugin()],
})
Vue Routerプリフェッチ:
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue'),
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { prefetch: true },
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to, _from, next) => {
if (to.meta.prefetch) {
const link = document.createElement('link')
link.rel = 'prefetch'
link.href = to.matched[0]?.components
? '/assets/js/dashboard-chunk.js'
: ''
if (link.href) document.head.appendChild(link)
}
next()
})
export default router
テクニック3:Viteビルドキャッシュとインクリメンタルビルド
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
cacheDir: 'node_modules/.vite',
build: {
rollupOptions: {
cache: true,
},
},
optimizeDeps: {
include: [
'vue',
'vue-router',
'pinia',
'axios',
'lodash-es',
'@vueuse/core',
],
exclude: ['@iconify-icons/ep'],
},
})
CI/CDキャッシュ設定:
name: Build and Deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Cache Vite
uses: actions/cache@v4
with:
path: |
node_modules/.vite
node_modules/.cache
key: vite-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
vite-${{ runner.os }}-
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
ビルドツール比較分析
| 特徴 | Vite | Webpack | Rollup | esbuild | Turbopack |
|---|---|---|---|---|---|
| 開発起動速度 | 極速(ネイティブESM) | 遅い(フルバンドル) | 該当なし | 極速 | 極速 |
| HMR速度 | 極速 | 遅い | 該当なし | 高速 | 極速 |
| 本番ビルド | Rollup | Webpack | Rollup | 統合必要 | SWC |
| Tree Shaking | 優秀(ネイティブESM) | 良好 | 優秀(ネイティブ) | 基本 | 基本 |
| コード分割 | 優秀 | 優秀 | 良好 | 非対応 | 基本 |
| プラグインエコシステム | 豊富(Rollup互換) | 最も豊富 | 豊富 | 限定的 | 限定的 |
| 設定の複雑さ | 低い | 高い | 中程度 | 低い | 低い |
| ビルドキャッシュ | 内蔵 | プラグイン必要 | プラグイン必要 | 内蔵 | 内蔵 |
| Vue3サポート | 公式推奨 | ローダー必要 | プラグイン必要 | 設定必要 | 実験的 |
| ユースケース | モダンフロントエンドプロジェクト | 複雑なエンタープライズアプリ | ライブラリ開発 | 超高速ビルド | Next.js |
まとめ
Vue3+Viteビルド最適化は一度きりの作業ではなく、継続的なイテレーションプロセスです。 Rollup設定からコード分割、Tree ShakingからCDN外部化まで、7つの主要戦略がビルド最適化の全パイプラインをカバーしています。覚えておいてください:最適化の前に分析を—rollup-plugin-visualizerでボトルネックを見つけましょう;圧縮の前に分割を—適切なコード分割は盲目的な圧縮より効果的です;CDNの前にローカルを—CDN外部化が開発体験に影響しないことを確認しましょう。2026年、Viteエコシステムは十分に成熟しています。これらの戦略を活用すれば、プロジェクトのバンドルサイズを70%削減し、初回読み込み時間を60%短縮できます。
おすすめツール
- JSONフォーマッター - ビルド設定ファイルをフォーマットし、JSON構文エラーをデバッグ
- Base64エンコーダー - Source Mapやインラインリソースのエンコード
- ハッシュ計算ツール - ファイルハッシュを計算し、ビルド産物の整合性を検証
ブラウザローカルツールを無料で試す →