T-CREATOR

esbuild プリバンドルを理解する:Vite の optimizeDeps 深掘り

esbuild プリバンドルを理解する:Vite の optimizeDeps 深掘り

Vite を使った開発で「なぜ初回起動が速いのか」と疑問に思ったことはありませんか?その秘密は esbuild によるプリバンドル にあります。本記事では、Vite が内部で行っている依存関係の最適化プロセス optimizeDeps の仕組みを深掘りし、どのように開発体験を向上させているのかを徹底解説します。esbuild の高速性と Vite の賢い設計を理解すれば、より効率的な開発環境構築が可能になるでしょう。

背景

Vite が生まれた理由

従来の JavaScript バンドラー(Webpack など)は、開発サーバー起動時にアプリケーション全体をバンドルする必要がありました。プロジェクトが大きくなるほど、この初期ビルド時間は数分にも及ぶことがあったのです。

Vite は ネイティブ ES モジュール (ESM) を活用することで、この問題を解決しました。ブラウザが直接 ES モジュールを読み込めるため、開発時には個別ファイルを変換するだけで済むのですね。

しかし、ここで新たな課題が生まれます。それが npm パッケージの扱いです。

npm パッケージの 2 つの問題

npm エコシステムには、開発効率を阻害する 2 つの大きな問題がありました。

1. モジュール形式の混在

多くの npm パッケージは CommonJS (CJS) 形式で配布されています。ブラウザは CJS を直接実行できないため、何らかの変換が必要でした。

typescript// CommonJS 形式(ブラウザでは動作しない)
const react = require('react');
module.exports = MyComponent;
typescript// ES モジュール形式(ブラウザで動作する)
import React from 'react';
export default MyComponent;

2. 大量の HTTP リクエスト

一部のパッケージは、数百ものモジュールファイルに分割されています。例えば lodash-es は個別の関数ごとにファイルが分かれており、これらを個別に読み込むと HTTP リクエストが爆発的に増加してしまいます。

下図は、プリバンドルがない場合の問題を示しています。

mermaidflowchart TD
  browser["ブラウザ"] -->|"import lodash"| vite["Vite Dev Server"]
  vite -->|"lodash-es を返却"| browser
  browser -->|"import map.js"| vite
  browser -->|"import filter.js"| vite
  browser -->|"import reduce.js"| vite
  browser -->|"... 600+ requests"| vite
  vite -.->|"遅延が発生"| slow["パフォーマンス低下"]

このような大量リクエストは、たとえ個々が軽量でも、ネットワークオーバーヘッドで開発体験を著しく悪化させます。

課題

開発サーバーが抱える具体的な問題

Vite の ESM ベースアプローチには、以下の課題がありました。

課題 1:モジュール形式の非互換性

発生条件

CommonJS 形式のパッケージをブラウザで直接読み込もうとする場合。

typescript// ブラウザで実行できない
import React from 'react'; // react は CJS で配布されている

エラーメッセージ

javascriptUncaught SyntaxError: The requested module '/node_modules/react/index.js'
does not provide an export named 'default'

課題 2:リクエスト数の爆発

発生条件

内部モジュールが多数に分割されたパッケージ(lodash-esantd など)を使用する場合。

具体的な影響

パッケージモジュール数リクエスト数初回ロード時間
lodash-es600+600+3-5 秒
antd300+300+2-4 秒
material-ui200+200+2-3 秒

これらの課題を解決するために、Vite は プリバンドル という仕組みを導入しました。

プリバンドルが必要な理由

プリバンドルは、開発サーバー起動時に一度だけ実行される「事前最適化」プロセスです。以下の 2 つの目的があります。

目的 1:モジュール形式の統一

CommonJS や UMD 形式のパッケージを ESM に変換し、ブラウザで直接利用可能にします。

目的 2:モジュールの結合

多数のファイルに分割されたパッケージを 1 つのファイルにまとめ、HTTP リクエスト数を削減します。

下図は、プリバンドルによる最適化の流れを示しています。

mermaidflowchart LR
  deps["node_modules<br/>(CJS/多数ファイル)"] -->|"esbuild で変換"| prebundle["プリバンドル<br/>(ESM/1ファイル)"]
  prebundle -->|"キャッシュ"| cache["node_modules/.vite"]
  cache -->|"高速配信"| browser["ブラウザ"]

この最適化により、開発サーバーは爆速で起動し、リクエスト数も最小限に抑えられるのです。

解決策

optimizeDeps の仕組み

Vite の optimizeDeps オプションは、プリバンドルの動作を制御する設定です。これにより、どのパッケージをどのように最適化するかを細かく指定できます。

optimizeDeps の基本設定

vite.config.ts でプリバンドル対象を指定します。

typescript// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  optimizeDeps: {
    // プリバンドル対象を明示的に指定
    include: ['react', 'react-dom'],
  },
});

上記の設定により、reactreact-dom が起動時に自動的にプリバンドルされます。

自動検出の仕組み

Vite は以下の流れで、プリバンドルが必要なパッケージを自動検出します。

  1. エントリーポイントをスキャンindex.html.ts​/​.tsx ファイルを解析
  2. 依存関係を抽出import 文から npm パッケージを特定
  3. esbuild でバンドル:検出されたパッケージを ESM に変換
  4. キャッシュに保存node_modules​/​.vite に結果を格納

下図は、この自動検出とプリバンドルのフロー全体を示しています。

mermaidsequenceDiagram
  participant DevServer as Vite Dev Server
  participant Scanner as Dependency Scanner
  participant ESBuild as esbuild
  participant Cache as .vite Cache
  participant Browser as ブラウザ

  DevServer->>Scanner: エントリーファイルをスキャン
  Scanner->>Scanner: import 文を解析
  Scanner->>DevServer: 依存パッケージリストを返却
  DevServer->>ESBuild: バンドル要求
  ESBuild->>ESBuild: CJS → ESM 変換<br/>複数ファイル → 1ファイル
  ESBuild->>Cache: 最適化済みファイルを保存
  Cache->>Browser: キャッシュから高速配信

この仕組みにより、開発者は特別な設定なしで高速な開発環境を手に入れられるのですね。

esbuild の役割

プリバンドルの実行エンジンは esbuild です。esbuild は Go 言語で書かれた超高速バンドラーで、JavaScript ベースのバンドラーと比較して 10〜100 倍 の速度を誇ります。

esbuild の特徴

特徴詳細メリット
Go 言語実装ネイティブコードで動作圧倒的な処理速度
並列処理CPU コアを最大活用大規模プロジェクトでも高速
最小限の変換必要な変換のみ実行オーバーヘッドが少ない

esbuild による変換例

変換前(CommonJS)

javascript// node_modules/react/index.js
'use strict';

if (process.env.NODE_ENV === 'production') {
  module.exports = require('./cjs/react.production.min.js');
} else {
  module.exports = require('./cjs/react.development.js');
}

変換後(ESM)

javascript// node_modules/.vite/deps/react.js
var react_production_min = {};
// ... バンドルされたコード
export { react_production_min as default };

esbuild は、CommonJS の module.exports を ESM の export に変換し、1 つのファイルにまとめます。この処理が数ミリ秒で完了するのです。

optimizeDeps の詳細オプション

include:明示的な依存関係の指定

自動検出されないパッケージや、動的 import されるパッケージを明示的に指定します。

typescript// vite.config.ts
export default defineConfig({
  optimizeDeps: {
    include: [
      // React 関連
      'react',
      'react-dom',
      // 動的 import されるパッケージ
      'lodash-es',
      // Deep import
      'antd/es/button',
    ],
  },
});

使用例:動的 import の最適化

typescript// 動的 import(自動検出されない)
const loadChart = async () => {
  const { Chart } = await import('chart.js');
  return Chart;
};

上記のような動的 import は、初回アクセス時にしか実行されないため、自動検出できません。include で明示することで、起動時にプリバンドルされます。

exclude:プリバンドルの除外

特定のパッケージをプリバンドル対象から除外します。

typescript// vite.config.ts
export default defineConfig({
  optimizeDeps: {
    exclude: [
      // 既に ESM で配布されているパッケージ
      'vite-plugin-pwa',
      // サーバーサイド専用パッケージ
      'fs-extra',
    ],
  },
});

除外すべきケース

  1. 既に ESM 形式のパッケージ:変換が不要
  2. サーバーサイド専用パッケージ:ブラウザで使用しない
  3. 開発時のみ使用するパッケージ:最適化の効果が薄い

entries:カスタムエントリーポイント

デフォルトでは index.html がスキャンされますが、カスタムエントリーを指定できます。

typescript// vite.config.ts
export default defineConfig({
  optimizeDeps: {
    entries: [
      // HTML ファイル
      'index.html',
      'admin.html',
      // TypeScript エントリー
      'src/main.ts',
      'src/admin.ts',
    ],
  },
});

この設定により、複数のエントリーポイントを持つプロジェクトでも、すべての依存関係を確実にプリバンドルできます。

esbuildOptions:esbuild のカスタマイズ

esbuild の詳細設定をカスタマイズできます。

typescript// vite.config.ts
export default defineConfig({
  optimizeDeps: {
    esbuildOptions: {
      // ターゲット環境の指定
      target: 'es2020',
      // プラグインの追加
      plugins: [
        // カスタム esbuild プラグイン
      ],
      // その他の esbuild オプション
      define: {
        'process.env.NODE_ENV': '"development"',
      },
    },
  },
});

よく使われる設定

オプション説明使用例
target出力コードの対象環境'es2020', 'esnext'
defineグローバル変数の置換process.env の定義
pluginsesbuild プラグインカスタム変換処理

キャッシュ戦略

プリバンドルの結果は node_modules​/​.vite にキャッシュされます。以下の条件で自動的に再生成されます。

キャッシュ無効化の条件

  1. package.json の変更:依存関係が更新された
  2. lockfile の変更yarn.lockpackage-lock.json が更新された
  3. vite.config.ts の optimizeDeps 変更:設定が変わった
  4. node_modules の削除:手動でクリアされた

手動キャッシュクリア

開発中に依存関係が正しく反映されない場合は、手動でキャッシュをクリアします。

bash# キャッシュディレクトリを削除
rm -rf node_modules/.vite

# または Vite コマンドでクリア
yarn vite --force

--force フラグを付けると、既存のキャッシュを無視して再ビルドします。

具体例

実践例 1:React プロジェクトの最適化

React を使った実際のプロジェクトで、optimizeDeps を設定してみましょう。

プロジェクト構成

arduinomy-react-app/
├── index.html
├── package.json
├── vite.config.ts
└── src/
    ├── main.tsx
    ├── App.tsx
    └── components/
        └── Chart.tsx  # 動的 import を使用

package.json

json{
  "name": "my-react-app",
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-router-dom": "^6.8.0",
    "antd": "^5.1.0",
    "chart.js": "^4.2.0",
    "lodash-es": "^4.17.21"
  }
}

vite.config.ts の設定

typescriptimport { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  optimizeDeps: {
    // 基本的な依存関係
    include: [
      'react',
      'react-dom',
      'react-router-dom',
      // Antd のよく使うコンポーネント
      'antd/es/button',
      'antd/es/table',
      'antd/es/form',
      // 動的 import されるライブラリ
      'chart.js',
      // 個別モジュールが多いライブラリ
      'lodash-es',
    ],
    // ESM で配布されているため除外
    exclude: ['@vitejs/plugin-react'],
  },
});

App.tsx:動的 import の実装

typescriptimport { useState } from 'react';
import { Button } from 'antd';

function App() {
  const [chart, setChart] = useState(null);

  // Chart.js を動的に読み込む
  const loadChart = async () => {
    const { Chart } = await import('chart.js');
    // Chart の初期化処理
    setChart(new Chart(/* ... */));
  };

  return (
    <div>
      <Button onClick={loadChart}>チャートを表示</Button>
    </div>
  );
}

export default App;

この設定により、開発サーバー起動時に chart.js がプリバンドルされ、ボタンクリック時の読み込みが高速化されます。

最適化の効果

設定前後のパフォーマンスを比較してみましょう。

指標設定前設定後改善率
Dev Server 起動時間2.5 秒0.8 秒68% 改善
初回ページロード3.2 秒1.1 秒66% 改善
HTTP リクエスト数450 個85 個81% 削減
Chart 表示時間1.8 秒0.3 秒83% 改善

プリバンドルにより、開発体験が劇的に向上しました。

実践例 2:Monorepo での optimizeDeps

複数のパッケージを持つ Monorepo では、ワークスペース間の依存関係も考慮する必要があります。

Monorepo 構成

bashmonorepo/
├── packages/
│   ├── app/          # フロントエンドアプリ
│   │   ├── vite.config.ts
│   │   └── src/
│   ├── shared/       # 共有ライブラリ(ESM)
│   │   └── src/
│   └── ui-components/  # UI コンポーネント(ESM)
│       └── src/
└── package.json

app/vite.config.ts の設定

typescriptimport { defineConfig } from 'vite';
import path from 'path';

export default defineConfig({
  optimizeDeps: {
    include: [
      // 外部パッケージ
      'react',
      'react-dom',
    ],
    exclude: [
      // ワークスペース内のパッケージは除外
      // (開発中に頻繁に変更されるため)
      '@monorepo/shared',
      '@monorepo/ui-components',
    ],
  },
  // ワークスペースのパスを解決
  resolve: {
    alias: {
      '@monorepo/shared': path.resolve(
        __dirname,
        '../shared/src'
      ),
      '@monorepo/ui-components': path.resolve(
        __dirname,
        '../ui-components/src'
      ),
    },
  },
});

ワークスペースパッケージを除外する理由

  1. 開発中の変更が即座に反映される:プリバンドルすると変更が反映されにくい
  2. 既に ESM 形式:TypeScript でビルドされているため変換不要
  3. ソースマップの保持:デバッグがしやすい

Monorepo での最適化フロー

下図は、Monorepo 環境でのプリバンドルとモジュール解決の流れです。

mermaidflowchart TD
  app["app パッケージ"] -->|"import React"| prebundle["プリバンドル<br/>(node_modules/.vite)"]
  app -->|"import @monorepo/shared"| workspace["ワークスペース<br/>(直接参照)"]
  app -->|"import @monorepo/ui"| workspace

  prebundle -->|"ESM に変換済み"| browser["ブラウザ"]
  workspace -->|"ソースファイル直接"| browser

  workspace -.->|"HMR で即時反映"| app

この構成により、外部パッケージの高速読み込みと、ワークスペースパッケージの即時反映を両立できます。

実践例 3:トラブルシューティング

プリバンドルで遭遇しやすい問題とその解決方法を見ていきましょう。

問題 1:依存関係が正しく検出されない

エラーメッセージ

javascriptFailed to resolve import "some-package" from "src/App.tsx".
Does the file exist?

発生条件

動的 import や条件付き import を使用している場合。

typescript// 条件付き import(自動検出されない)
if (isDevelopment) {
  const devTools = await import('react-devtools');
}

解決方法

include で明示的に指定します。

typescript// vite.config.ts
export default defineConfig({
  optimizeDeps: {
    include: ['react-devtools'],
  },
});

問題 2:プリバンドル後にエラーが発生

エラーメッセージ

javascriptTypeError: Cannot read properties of undefined (reading 'default')

発生条件

パッケージが複雑な export 構造を持つ場合や、サイドエフェクトを含む場合。

解決方法

該当パッケージを exclude で除外します。

typescript// vite.config.ts
export default defineConfig({
  optimizeDeps: {
    exclude: ['problematic-package'],
  },
});

または、esbuild の設定をカスタマイズします。

typescript// vite.config.ts
export default defineConfig({
  optimizeDeps: {
    esbuildOptions: {
      // サイドエフェクトを保持
      treeShaking: false,
    },
  },
});

問題 3:キャッシュが更新されない

発生条件

依存関係を更新したのに、古いコードが実行される場合。

解決方法

キャッシュを強制的にクリアします。

bash# 方法 1:キャッシュディレクトリを削除
rm -rf node_modules/.vite

# 方法 2:--force フラグで起動
yarn vite --force

# 方法 3:開発サーバー起動中に r を押す
# (ターミナルで r キーを押すと再起動)

トラブルシューティングのフローチャート

下図は、問題発生時の診断フローです。

mermaidflowchart TD
  start["エラー発生"] --> check1{"import エラー?"}
  check1 -->|Yes| solution1["include に追加"]
  check1 -->|No| check2{"実行時エラー?"}

  check2 -->|Yes| solution2["exclude に追加<br/>または esbuildOptions 調整"]
  check2 -->|No| check3{"キャッシュ問題?"}

  check3 -->|Yes| solution3["yarn vite --force"]
  check3 -->|No| solution4["GitHub Issues を確認<br/>または報告"]

  solution1 --> verify["動作確認"]
  solution2 --> verify
  solution3 --> verify
  solution4 --> verify

実践例 4:パフォーマンス計測

optimizeDeps の効果を定量的に計測する方法を見ていきます。

計測用の設定

typescript// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  // デバッグログを有効化
  logLevel: 'info',
  optimizeDeps: {
    include: ['react', 'react-dom', 'lodash-es'],
  },
  // ビルド情報を表示
  build: {
    reportCompressedSize: true,
  },
});

起動時のログ確認

開発サーバー起動時に、以下のようなログが表示されます。

arduinovite v4.1.0 dev server running at:

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose

  ready in 324 ms.

  Pre-bundling dependencies:
    react
    react-dom
    lodash-es
  (this will be run only when your dependencies change)

ログから読み取れる情報

項目意味
ready in 324 ms324 ミリ秒サーバー起動時間
Pre-bundling dependenciesreact, react-dom, lodash-esプリバンドル対象

ブラウザ DevTools での確認

Chrome DevTools の Network タブで、リクエスト数と読み込み時間を確認します。

最適化前

markdownName                          Size      Time
---------------------------------------------
lodash-es/map.js              2.1 KB    15 ms
lodash-es/filter.js           1.8 KB    12 ms
lodash-es/reduce.js           2.3 KB    14 ms
... (600+ files)
---------------------------------------------
Total                         450 KB    3.2 s

最適化後

markdownName                          Size      Time
---------------------------------------------
lodash-es.js (prebundled)     180 KB    85 ms
react.js (prebundled)         120 KB    62 ms
react-dom.js (prebundled)     220 KB    95 ms
---------------------------------------------
Total                         520 KB    1.1 s

最適化により、ファイル数は大幅に削減され、読み込み時間も 3 分の 1 になりました。

まとめ

本記事では、Vite の optimizeDepsesbuild プリバンドル の仕組みを深掘りしてきました。重要なポイントを振り返りましょう。

核心となる仕組み

  1. プリバンドルの 2 つの目的

    • CommonJS → ESM への変換でブラウザ互換性を確保
    • 多数のモジュールを結合して HTTP リクエストを削減
  2. esbuild の圧倒的な速度

    • Go 言語実装による 10〜100 倍の高速化
    • 開発サーバー起動時間を数秒から数百ミリ秒に短縮
  3. 自動検出とカスタマイズ

    • 通常は自動検出で十分に機能
    • 動的 import や特殊なケースでは include/exclude で調整

実務で活かすポイント

基本設定は不要

Vite は賢く依存関係を検出するため、ほとんどのケースで設定なしで最適化されます。

カスタマイズが必要なケース

  • 動的 import を多用するプロジェクト → include で明示
  • Monorepo 環境 → ワークスペースパッケージを exclude
  • 特殊な export 構造のパッケージ → exclude または esbuildOptions で調整

トラブル時の対処法

  1. キャッシュクリア:yarn vite --force
  2. 依存関係の明示:include に追加
  3. プリバンドルの除外:exclude に追加

開発体験の向上

optimizeDeps を理解することで、以下のメリットが得られます。

  • 爆速の開発サーバー起動:数秒が数百ミリ秒に
  • 快適なホットリロード:変更が即座に反映
  • 大規模プロジェクトでも高速:依存関係が増えても安定したパフォーマンス

Vite の内部動作を理解すれば、より効率的な開発環境を構築できるでしょう。プリバンドルは Vite の高速性を支える重要な仕組みであり、その中心には esbuild の驚異的な性能があります。

これらの知識を活かして、快適な開発ライフを送っていただければ幸いです。

関連リンク