Vite manualChunks で Three.js を分離して LCP を改善する|requestIdleCallback 遅延ロード実装
manualChunks: { three: ['three'] } で分離し、requestIdleCallback 内で動的 import してください。Safari は setTimeout フォールバックが必須です。
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
three: ['three'],
},
},
},
},
});
設定手順
vite.config.tsのbuild.rollupOptions.output.manualChunksにthree: ['three']を追加する- Three.js の初期化コードをエントリーファイルの静的
importから 動的import()に変更する requestIdleCallback(Safari 未対応のためsetTimeoutフォールバック付き)で動的 import をラップする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メモリを使い切りタブがクラッシュします。