Viteで構築したアプリケーションが成長するにつれ、バンドルサイズは自然と膨らみます。初回読み込みに3秒以上かかるようになると、直帰率の上昇やSEO評価の低下といった実害が生じます。Viteはプロダクションビルドで自動的にMinifyやTree Shakingを行いますが、デフォルト設定のままでは最適化しきれないケースが多くあります。

バンドルサイズの削減は「計測→原因特定→個別対策→圧縮仕上げ」の順に進めるのが鉄則です。闇雲にコードを削るのではなく、ボトルネックを正確に把握してから手を打つことで、最小の労力で最大の効果を得られます。

Viteプロジェクトでバンドルが膨らむ主な原因

未使用コードの残留

import * as components from 'some-library'のようなワイルドカードインポートを使うと、ライブラリの全モジュールがバンドルに含まれます。ESM形式でないライブラリでは、Vite(内部のRollup)がTree Shakingを実行できず、使っていない関数やクラスがそのまま出力ファイルに残ります。

巨大な外部ライブラリの丸ごと読み込み

moment.js(約300KB minified)やlodash(約70KB minified)のように、一部の関数しか使わないのにパッケージ全体を読み込んでいるケースがよく見られます。特にUI コンポーネントライブラリ(Vuetify, MUI, Ant Design等)は、全コンポーネントを一括インポートするとバンドルサイズが数百KBに膨れ上がります。

一括バンドルによる初回転送量の増大

コード分割を行わないと、ユーザーが最初のページを開いた時点でアプリケーション全体のJavaScriptをダウンロードすることになります。管理画面のロジックや、モーダル内でしか使わないライブラリまで初回に読み込まれるため、First Contentful Paint(FCP)が大幅に遅延します。

バンドル構成を可視化して改善対象を見つける

最適化の第一歩は、現在のバンドルに何がどれだけ含まれているかを正確に把握することです。Viteプロジェクトでは主に2つのツールが利用できます。

rollup-plugin-visualizerの導入手順

rollup-plugin-visualizerはRollupエコシステムで最も利用されているバンドル可視化ツールで、Viteでもそのまま動作します。npmの週間ダウンロード数は約239万に達しています(出典: npm)。

npm install --save-dev rollup-plugin-visualizer

vite.config.tsに以下のように設定します。

import { defineConfig } from 'vite'
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
  plugins: [
    visualizer({
      template: 'treemap',   // sunburst, network, list も選択可
      open: true,             // ビルド後に自動でブラウザ表示
      gzipSize: true,         // gzip圧縮後のサイズも表示
      brotliSize: true,       // Brotli圧縮後のサイズも表示
      filename: 'stats.html', // 出力ファイル名
    }),
  ],
})

npx vite buildを実行すると、プロジェクトルートにstats.htmlが生成されます。ブラウザで開くと、各モジュールの占有面積が矩形の大きさで表現されたTreemapが表示されます。

本番デプロイとの分離: stats.htmlが誤ってデプロイされるのを防ぐには、環境変数で分析モードを制御するのが安全です。

import { defineConfig } from 'vite'
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig(({ mode }) => ({
  plugins: [
    mode === 'analyze' &&
      visualizer({
        template: 'treemap',
        open: true,
        gzipSize: true,
        brotliSize: true,
      }),
  ],
}))

この設定ではnpx vite build --mode analyzeと実行した場合のみ可視化ファイルが生成されます。

vite-bundle-analyzerという選択肢

vite-bundle-analyzerはVite専用に開発されたバンドル分析ツールです。最新バージョンは1.3.6で、Rolldownとの連携も実験的にサポートしています(出典: npm)。

npm install --save-dev vite-bundle-analyzer
import { defineConfig } from 'vite'
import { analyzer } from 'vite-bundle-analyzer'

export default defineConfig({
  plugins: [
    analyzer({
      analyzerMode: 'server', // 'static' | 'json' も選択可
      defaultSizes: 'gzip',  // 'stat' | 'brotli'
    }),
  ],
})
項目rollup-plugin-visualizervite-bundle-analyzer
対応バンドラーRollup / Vite / WebpackVite / Rolldown
表示形式Treemap, Sunburst, Network, FlamegraphインタラクティブTreemap
圧縮サイズ表示gzip / Brotligzip / Brotli
CLI対応なしあり
Node.js要件>= 22(v6系)制限なし

rollup-plugin-visualizerのv6系はNode.js 22以上が必要です。プロジェクトのNode.jsバージョンが古い場合は、vite-bundle-analyzerの方が導入しやすいケースがあります。

可視化結果から優先度を判断する方法

Treemapを確認する際のチェックポイントは3つです。

  1. 面積が大きいnode_modulesパッケージ: バンドル全体の50%以上をサードパーティが占めている場合、ライブラリの置き換えや遅延読み込みが最優先です
  2. 同一ライブラリの複数バージョン: 依存関係の競合で同じパッケージが重複バンドルされていないか確認します。npm ls <パッケージ名>でバージョンツリーを確認し、npm dedupeで解消できるケースがあります
  3. 使用頻度の低い大きなモジュール: 管理画面専用のライブラリやPDF生成モジュールなど、一部のユーザーしか使わない機能がメインバンドルに含まれていれば、Dynamic Importで分離する候補になります

Tree Shakingの効果を最大限に引き出す

Tree Shakingは「使われていないexportをバンドルから除去する」最適化です。ViteはプロダクションビルドでRollupのTree Shakingを自動実行しますが、正しく機能するにはいくつかの前提条件があります。

package.jsonのsideEffectsフラグ

Tree Shakingが最も効果を発揮するには、対象パッケージのpackage.jsonsideEffectsフラグが設定されている必要があります。

{
  "name": "my-library",
  "sideEffects": false
}

sideEffects: falseは「このパッケージのどのファイルも、インポートされるだけで副作用を起こさない」という宣言です。この指定があると、Rollupは未使用のexportを安全に除去できます。

CSSファイルをインポートしているモジュールがある場合は、CSSを除外対象から外す必要があります。

{
  "sideEffects": ["*.css", "*.scss"]
}

自分のプロジェクトのpackage.jsonにもsideEffectsを設定しておくと、アプリケーションコード内の未使用モジュールも確実に除去されます。

UIライブラリの名前付きインポート

UIコンポーネントライブラリを使う場合、全コンポーネントを一括インポートするとTree Shakingが効かず、バンドルサイズが大幅に増加します。

// NG: 全コンポーネントがバンドルに含まれる
import * as Components from 'some-ui-library'

// OK: 使用するコンポーネントだけを読み込む
import { Button, TextField } from 'some-ui-library'

Vuetifyの場合はvite-plugin-vuetifyを導入することで、自動的に使用コンポーネントのみをバンドルに含める設定が可能です。

npm install --save-dev vite-plugin-vuetify
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vuetify from 'vite-plugin-vuetify'

export default defineConfig({
  plugins: [
    vue(),
    vuetify({ autoImport: true }),
  ],
})

MUI(Material UI)やAnt Designなど他のUIライブラリでも、同様のViteプラグインやBabelプラグインが提供されています。公式ドキュメントでTree Shaking対応の設定を確認してから導入するのが確実です。

barrel exportが引き起こすTree Shaking阻害

index.tsでまとめてre-exportする「barrel export」パターンは、Tree Shakingの妨げになることがあります。

// src/utils/index.ts(barrel export)
export { formatDate } from './date'
export { formatCurrency } from './currency'
export { generatePDF } from './pdf'  // 100KB以上の重い処理
// src/components/Header.tsx
import { formatDate } from '@/utils'
// formatDateだけ使いたいが、Rollupの解析によっては
// generatePDFまでバンドルされる可能性がある

対策として、重いモジュールはbarrel exportから分離し、直接パスでインポートします。

// 重いモジュールは直接パスで読み込む
import { generatePDF } from '@/utils/pdf'

コード分割で初回読み込みを高速化する

コード分割(Code Splitting)は、アプリケーションを複数のチャンクファイルに分け、必要なタイミングで読み込む手法です。Viteはimport()構文を検出すると自動的に別チャンクとして切り出します。

Dynamic Importによるルートベース分割

ルーティング単位で画面コンポーネントを遅延読み込みするのが、最も効果の大きいコード分割パターンです。

Reactの場合:

import { lazy, Suspense } from 'react'

const Dashboard = lazy(() => import('./pages/Dashboard'))
const Settings = lazy(() => import('./pages/Settings'))
const AdminPanel = lazy(() => import('./pages/AdminPanel'))

function App() {
  return (
    <Suspense fallback={<div>読み込み中...</div>}>
      <Routes>
        <Route path="/" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/admin" element={<AdminPanel />} />
      </Routes>
    </Suspense>
  )
}

Vueの場合:

import { defineAsyncComponent } from 'vue'

const Dashboard = defineAsyncComponent(
  () => import('./pages/Dashboard.vue')
)
const Settings = defineAsyncComponent(
  () => import('./pages/Settings.vue')
)

Viteは分割されたチャンクに対して<link rel="modulepreload">を自動的に付与するため、ユーザーがそのルートに遷移する直前にプリロードが開始されます。

manualChunksでベンダーコードを分離する

build.rollupOptions.output.manualChunksを使うと、特定のモジュールを任意のチャンクにグループ化できます。変更頻度が低いベンダーライブラリを独立チャンクに分離すると、アプリケーションコードだけを更新した際にベンダーチャンクのキャッシュが効き、ユーザーの再ダウンロード量が減ります。

import { defineConfig } from 'vite'

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules/react')) {
            return 'react-vendor'
          }
          if (id.includes('node_modules/@mui')) {
            return 'mui-vendor'
          }
          if (id.includes('node_modules')) {
            return 'vendor'
          }
        },
      },
    },
  },
})

manualChunksを関数で定義する場合、undefinedを返すとRollupのデフォルト分割ロジックに委ねられます。まずシンプルな分割から始め、可視化ツールでチャンク構成を確認しながら段階的に調整するのが安全です。

Vite 8以降の変更点: Vite 8(2025年12月にBeta公開)ではRolldownがデフォルトバンドラーとなり、manualChunksに代わるadvancedChunksオプションが推奨されています(出典: Vite 8 Beta公式)。現時点ではVite 7(2025年6月リリース)が安定版のため、manualChunksの設定で問題ありません。

過度な分割のデメリットとchunkSizeWarningLimit

チャンクを細かく分割しすぎると、HTTPリクエスト数が増加してかえってパフォーマンスが低下します。HTTP/2環境でも、同時接続数やTLSハンドシェイクのオーバーヘッドは無視できません。

build.chunkSizeWarningLimitはデフォルトで500KBに設定されており、これを超えるチャンクが生成されるとビルド時に警告が表示されます。

export default defineConfig({
  build: {
    chunkSizeWarningLimit: 600, // 単位: KB
  },
})

この値を引き上げて警告を消すだけでは根本解決になりません。警告が出た場合は、該当チャンクの中身を可視化ツールで確認し、Dynamic ImportやmanualChunksで適切に分離するのが正しい対処です。

重量級ライブラリを軽量な代替に切り替える

バンドルサイズの大幅な削減には、サードパーティライブラリの見直しが最も即効性があります。可視化ツールで大きな面積を占めているパッケージから順に検討します。

lodashからlodash-es / es-toolkitへ

lodashはCommonJS形式で配布されているため、Tree Shakingが効きません。ESM版のlodash-esに置き換えると、使用している関数だけがバンドルに含まれます。

npm install lodash-es
npm install --save-dev @types/lodash-es  # TypeScript利用時
// Before: lodash全体(約70KB minified)がバンドルに含まれる
import _ from 'lodash'
_.debounce(fn, 300)

// After: debounce関数だけがバンドルされる
import { debounce } from 'lodash-es'
debounce(fn, 300)

さらに軽量な選択肢として、es-toolkitがあります。TypeScriptネイティブで開発されており、lodash互換のAPIを提供しつつバンドルサイズが大幅に小さくなっています。

moment.jsからdayjsへ

moment.jsはminified状態で約300KBあり、ロケールデータが全言語分含まれていることが主な原因です。dayjsはAPIがmoment.jsとほぼ互換でありながら、わずか2KB(minified + gzip)で動作します。

npm install dayjs
npm uninstall moment
// Before
import moment from 'moment'
moment().format('YYYY-MM-DD')

// After
import dayjs from 'dayjs'
dayjs().format('YYYY-MM-DD')

resolve.aliasによる透過的な差し替え

既存コードのimport文を一括で書き換えるのが困難な場合、Viteのresolve.aliasを使って透過的にパッケージを置き換えられます。

import { defineConfig } from 'vite'

export default defineConfig({
  resolve: {
    alias: {
      'moment': 'dayjs',
    },
  },
})

この設定により、コード中のimport moment from 'moment'は自動的にdayjsに解決されます。ただしAPIの互換性が完全でない場合は実行時エラーが発生するため、テストを十分に行ったうえで適用する必要があります。

CSSの最適化

JavaScriptだけでなくCSSもバンドルサイズに大きく影響します。CSSフレームワークを使っている場合、未使用のスタイル定義が数百KB残っているケースは珍しくありません。

build.cssCodeSplitによる自動分割

Viteはbuild.cssCodeSplitオプション(デフォルトtrue)により、非同期チャンクに関連するCSSを自動的に個別ファイルとして切り出します(出典: Vite公式)。

この設定が有効な場合、Dynamic Importで分割されたコンポーネントのCSSは、そのチャンクが読み込まれるタイミングで初めてロードされます。特別な設定なしで機能するため、意図的にfalseに変更しない限りそのまま活用できます。

未使用CSSの除去

CSSフレームワーク(Bootstrap, Bulma等)やユーティリティCSS(Tailwind CSS)を使用している場合、未使用のスタイル定義がバンドルの大部分を占めることがあります。PurgeCSSを使うと、HTMLやJSX/Vueテンプレートで実際に参照されているクラスだけを残し、それ以外を除去できます。

PostCSS経由でPurgeCSSを導入する場合:

npm install --save-dev @fullhuman/postcss-purgecss
// postcss.config.js
module.exports = {
  plugins: [
    require('@fullhuman/postcss-purgecss')({
      content: [
        './index.html',
        './src/**/*.{vue,js,ts,jsx,tsx}',
      ],
      defaultExtractor: (content) =>
        content.match(/[\w-/:]+(?<!:)/g) || [],
    }),
  ],
}

Tailwind CSS v3以降では、tailwind.config.jscontentフィールドで対象ファイルを指定するだけで、ビルド時に未使用クラスが自動的に除去されます。

// tailwind.config.js
module.exports = {
  content: [
    './index.html',
    './src/**/*.{vue,js,ts,jsx,tsx}',
  ],
  // ...
}

CSSミニファイのエンジン選択

Viteはbuild.cssMinifyオプションでCSSの圧縮エンジンを選択できます。デフォルトは'esbuild'ですが、'lightningcss'を指定するとより高い圧縮率を得られる場合があります(出典: Vite公式)。

export default defineConfig({
  build: {
    cssMinify: 'lightningcss',
  },
  css: {
    lightningcss: {
      // Lightning CSSの追加オプション
    },
  },
})

Lightning CSSはRustで実装されたCSSパーサー・トランスフォーマーで、esbuildよりも積極的な最適化(ショートハンドプロパティの結合、重複宣言の除去等)を行います。

画像・アセットの最適化

画像ファイルはバンドル全体の中でも大きな割合を占めやすいアセットです。適切なフォーマット選択と圧縮を行うことで、転送サイズを大幅に削減できます。

build.assetsInlineLimitの調整

Viteはbuild.assetsInlineLimit(デフォルト4096バイト = 4KB)以下のアセットファイルをBase64エンコードしてJavaScriptにインライン化します(出典: Vite公式)。

export default defineConfig({
  build: {
    assetsInlineLimit: 8192, // 8KB以下をインライン化
  },
})

インライン化するとHTTPリクエスト数は減りますが、Base64エンコードにより元のサイズの約33%増しになります。小さなアイコンやSVGはインライン化のメリットが大きい一方、写真やイラストは別ファイルのまま配信する方が効率的です。

vite-plugin-image-optimizerによる画像圧縮

vite-plugin-image-optimizerはSharp.jsとSVGOを内部で使用し、ビルド時に画像を自動圧縮します。PNG, JPEG, WebP, AVIF, SVGに対応しています。

npm install --save-dev vite-plugin-image-optimizer
import { defineConfig } from 'vite'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'

export default defineConfig({
  plugins: [
    ViteImageOptimizer({
      png: { quality: 80 },
      jpeg: { quality: 80 },
      webp: { quality: 80 },
      avif: { quality: 65 },
      svg: {
        plugins: [
          { name: 'removeViewBox', active: false },
          { name: 'removeDimensions', active: true },
        ],
      },
    }),
  ],
})

このプラグインにはキャッシュ機能が備わっており、変更のない画像は再ビルド時にスキップされます。また、最適化後にファイルサイズが元より大きくなる場合は自動的に元のファイルを保持します。

Minifyと転送圧縮で最終サイズを仕上げる

コード分割やTree Shakingで不要なコードを減らした後は、Minifyと転送圧縮で最終的な配信サイズを絞り込みます。

esbuildとterserの使い分け

Viteのbuild.minifyオプションはデフォルトで'esbuild'が使用されます。terserも選択可能ですが、それぞれ特性が異なります。

項目esbuildterser
圧縮速度非常に高速(Go実装)低速(JS実装)
圧縮率やや控えめesbuildより1-2%高い
設定の柔軟性限定的細かい制御が可能
追加インストール不要(Vite同梱)npm install terser が必要
// terserを使う場合
export default defineConfig({
  build: {
    minify: 'terser',
    terserOptions: {
      compress: {
        drop_console: true,  // console.logを除去
        drop_debugger: true, // debugger文を除去
      },
    },
  },
})

多くのプロジェクトではesbuildの速度メリットの方が大きいため、圧縮率の微差のためにterserへ切り替える必要はありません。ただしconsole.logの自動除去など細かい制御が必要な場合はterserが適しています。

esbuildでもdefineオプションでconsole.logを除去できます。

export default defineConfig({
  esbuild: {
    drop: ['console', 'debugger'],
  },
})

gzip / Brotli事前圧縮

Webサーバーのリアルタイムgzipだけでなく、ビルド時にあらかじめ圧縮ファイルを生成しておくと、サーバーのCPU負荷を軽減しつつ最高の圧縮率を得られます。

vite-plugin-compression2はgzipとBrotliの両方に対応しており、アクティブにメンテナンスされています。Brotliはgzipに比べて10-15%高い圧縮率を実現でき、すべてのモダンブラウザで対応済みです。

npm install --save-dev vite-plugin-compression2
import { defineConfig } from 'vite'
import { compression } from 'vite-plugin-compression2'

export default defineConfig({
  plugins: [
    compression({
      algorithm: 'gzip',
      threshold: 1024,  // 1KB以上のファイルのみ圧縮
    }),
    compression({
      algorithm: 'brotliCompress',
      threshold: 1024,
    }),
  ],
})

Nginx等のWebサーバー側でgzip_static on;brotli_static on;を設定すると、事前生成された圧縮ファイルが自動的に配信されます。

build.targetの最適化

build.targetはトランスパイルの出力先ブラウザバージョンを指定します。Vite 7ではデフォルトが'baseline-widely-available'(Chrome 107, Firefox 104, Safari 16相当)に変更されました(出典: Vite 7公式)。

export default defineConfig({
  build: {
    target: 'esnext', // 最新構文をそのまま出力
  },
})

'esnext'を指定すると、オプショナルチェイニングやnullish coalescingなど最新のJavaScript構文がそのまま出力され、Polyfillやトランスパイルによるコード増加を防げます。ただしサポート対象ブラウザの範囲が狭まるため、プロジェクトのブラウザサポート方針と照らし合わせて判断してください。

バンドルサイズ関連のViteビルドオプション早見表

Viteのbuildセクションにはバンドルサイズに影響するオプションが多数あります。以下は主要なオプションの一覧です(出典: Vite公式ビルドオプション)。

オプションデフォルト値説明
build.minify'esbuild'JS圧縮エンジン。'terser'も指定可
build.cssMinify'esbuild'CSS圧縮エンジン。'lightningcss'も選択可
build.cssCodeSplittrue非同期チャンクのCSSを個別ファイルに分割
build.target'baseline-widely-available'トランスパイルの出力ターゲット
build.assetsInlineLimit4096インライン化する最大ファイルサイズ(バイト)
build.chunkSizeWarningLimit500チャンクサイズ警告の閾値(KB)
build.sourcemapfalseソースマップの生成。trueにするとファイルサイズ増加
build.reportCompressedSizetruegzip圧縮後サイズのレポート表示
build.rollupOptions{}Rollupの詳細設定(manualChunks等)

今後の展望: Rolldownによるビルドパイプラインの進化

Vite 8 Beta(2025年12月公開)では、RustベースのバンドラーRolldownがesbuild+Rollupの二重構成を置き換えるデフォルトバンドラーとして導入されています(出典: Vite 8 Beta公式)。

公式ブログによると、Rolldownはビルド速度がRollupの10〜30倍に向上しており、実例としてLinearのビルド時間が46秒から6秒に短縮された事例や、Beehiivで64%のビルド時間短縮が報告されています。

現時点のVite 7環境でもRolldownを試すことは可能です。package.jsonに以下のように設定します。

{
  "devDependencies": {
    "vite": "npm:rolldown-vite@latest"
  }
}

Rolldownではチャンク分割のAPIが変更されており、manualChunksに代わってadvancedChunksが推奨されています。Vite 8の正式リリース後に移行を検討するとスムーズです。

まとめ: バンドルサイズ削減チェックリスト

Viteプロジェクトのバンドルサイズを削減するには、計測から始めて段階的に最適化を適用するのが効果的です。以下のチェックリストを参考に、自分のプロジェクトで該当する項目から取り組んでみてください。

分析フェーズ

  • rollup-plugin-visualizerまたはvite-bundle-analyzerでバンドル構成を可視化した
  • バンドル全体に占めるnode_modulesの割合を確認した
  • 重複パッケージがないかnpm lsで確認した

コード最適化フェーズ

  • ワイルドカードインポートを名前付きインポートに変更した
  • package.jsonにsideEffectsフラグを設定した
  • barrel exportの問題がないか確認した
  • ルートベースのDynamic Importを導入した
  • manualChunksでベンダーチャンクを分離した

ライブラリ最適化フェーズ

  • lodashをlodash-esまたはes-toolkitに置き換えた
  • moment.jsをdayjsに移行した
  • UIライブラリのTree Shaking設定を有効にした

CSS・アセット最適化フェーズ

  • 未使用CSSをPurgeCSSまたはTailwind CSSのpurge機能で除去した
  • 画像をvite-plugin-image-optimizerで圧縮した
  • build.assetsInlineLimitを適切に設定した

圧縮フェーズ

  • build.minifyの設定を確認した(esbuild or terser)
  • vite-plugin-compression2でgzip/Brotli事前圧縮を設定した
  • build.targetをプロジェクトのサポート範囲に合わせて最適化した
  • 本番ビルドでconsole.logを除去する設定を追加した

各対策は独立して適用でき、1つずつ効果を計測しながら進められます。可視化ツールでBefore/Afterを確認しながら進めると、どの対策が最も効果的だったか明確になります。