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つの主要課題

  1. バンドルサイズの制御不能: サードパーティ依存関係のオンデマンドインポート未実施、重複バンドル、Tree Shaking未設定による産物サイズの継続的膨張
  2. Tree Shakingが機能しない: CommonJSモジュールの使用、sideEffects未設定、動的インポートによる静的解析の失敗
  3. コード分割戦略が不明確: manualChunksの不適切な設定によるチャンク断片化または共有依存関係の重複バンドル
  4. CDN外部化設定エラー: vite-plugin-cdn-importの設定ミス、CDNリソース読み込み失敗、バージョン不一致
  5. 本番環境での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%短縮できます。


おすすめツール

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

#Vue3#Vite#构建优化#打包体积#代码分割#2026#性能调优