Vite manualChunks で Three.js を分離して LCP を改善する|requestIdleCallback 遅延ロード実装

Vite Three.js パフォーマンス バンドル最適化 フロントエンド
結論

manualChunks: { three: ['three'] } で分離し、requestIdleCallback 内で動的 import してください。Safari は setTimeout フォールバックが必須です。

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          three: ['three'],
        },
      },
    },
  },
});

設定手順

  1. vite.config.tsbuild.rollupOptions.output.manualChunksthree: ['three'] を追加する
  2. Three.js の初期化コードをエントリーファイルの静的 import から 動的 import() に変更する
  3. requestIdleCallback(Safari 未対応のため setTimeout フォールバック付き)で動的 import をラップする
  4. rollup-plugin-visualizer でバンドルを可視化し、three チャンクがメインバンドルから分離されているか確認する

なぜ静的 import のままでは manualChunks が効かないか

Three.js は minified 状態で約 650KB のサイズを持ちます。エントリーファイル(main.ts 等)で import * as THREE from 'three' と静的インポートすると、Rollup はそのモジュールをエントリーチャンクの依存として解析し、manualChunks の設定があってもエントリーチャンクに含めてしまいます。

// ❌ 静的 import:manualChunks を設定しても分離されない
import * as THREE from 'three';

const scene = new THREE.Scene();
// ✅ 動的 import:manualChunks + 遅延ロードが機能する
const initScene = async () => {
  const THREE = await import('three');
  const scene = new THREE.Scene();
  // ...
};

Rollup は動的 import() を見つけたときにコード分割ポイントとして認識し、manualChunks に従って独立ファイルを生成します。

requestIdleCallback で遅延初期化する実装

3Dシーンはページのクリティカルパスでないことがほとんどです。ブラウザのアイドル時間に初期化することで LCP・TTI への影響をゼロにできます。

// src/scripts/init-three-scene.ts

const loadThreeScene = async () => {
  const [{ default: * as THREE }, { initScene }] = await Promise.all([
    import('three'),
    import('./three-scene-setup'),
  ]);
  initScene(THREE);
};

export function mountThreeScene(container: HTMLElement) {
  if ('requestIdleCallback' in window) {
    // Chrome / Firefox: ブラウザがアイドルになったら初期化
    requestIdleCallback(
      () => loadThreeScene(),
      { timeout: 3000 } // 最大 3 秒待ったら強制実行
    );
  } else {
    // Safari フォールバック(requestIdleCallback 未サポート)
    setTimeout(() => loadThreeScene(), 200);
  }
}

timeout: 3000 オプションを指定することで、ブラウザが忙しい状態が続いても 3 秒後には強制的に実行されます。

manualChunks の失敗パターン

パターン1:ライブラリが過剰分割されてウォーターフォールになる

すべての依存関係を manualChunks で個別に分離すると、初回ロード時に大量の小さなリクエストが並走し、かえって遅くなります。

// ❌ 過剰分割の例(HTTP/2 でも chunk が多すぎると遅い)
manualChunks: {
  three: ['three'],
  gsap: ['gsap'],
  lodash: ['lodash'],
  react: ['react'],
  'react-dom': ['react-dom'],
  zustand: ['zustand'],
  // ... 20 個以上
}
// ✅ 大きい依存のみ分離(目安: 100KB 以上の依存)
manualChunks: {
  three: ['three'],        // ~650KB
  vendor: ['react', 'react-dom'], // まとめて vendor チャンクに
}

パターン2:Three.js の examples を別途 import していると分離されない

three/examples/jsm/controls/OrbitControls.js などのサブパスは three と別モジュールとして解決されます。

// ❌ examples を別途書かないと分離されない
manualChunks: {
  three: ['three'], // three/examples は含まれない
}

// ✅ examples も同じチャンクに含める
manualChunks: {
  three: ['three', 'three/examples/jsm/controls/OrbitControls.js'],
}

または、Three.js 関連のすべてのモジュールを動的 import した単一ファイル(three-scene-setup.ts)にまとめ、そのファイルへの動的 import 一本だけにするとシンプルです。

バンドル確認:rollup-plugin-visualizer

pnpm add -D rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [
    visualizer({ open: true, filename: 'dist/stats.html' }),
  ],
  build: {
    rollupOptions: {
      output: {
        manualChunks: { three: ['three'] },
      },
    },
  },
});

npm run build 後に dist/stats.html が自動で開き、各チャンクのサイズが円グラフで表示されます。three チャンクがメインバンドルから独立しているかを視覚的に確認してください。

Failure Boundary:GPU メモリリーク

Three.js はジオメトリ・マテリアル・テクスチャを .dispose() しないと GPU メモリがリークします。コンポーネントのアンマウント時に必ず解放してください。

function disposeScene(scene: THREE.Scene, renderer: THREE.WebGLRenderer) {
  scene.traverse((obj) => {
    if (obj instanceof THREE.Mesh) {
      obj.geometry.dispose();
      if (Array.isArray(obj.material)) {
        obj.material.forEach((m) => m.dispose());
      } else {
        obj.material.dispose();
      }
    }
  });
  renderer.dispose();
}

シングルページアプリでルート遷移のたびに 3D シーンを生成・破棄する場合、dispose() を忘れると数回の遷移でGPUメモリを使い切りタブがクラッシュします。