コーポレートサイトの背景にThree.jsでワイヤーフレームを置こうとしました。デスクトップでは何の問題もないのに、モバイルで開くと初期ロードに10秒近くかかります。プログレスバーが半分で止まり、しばらくしてようやく完了。下層ページは1秒で開くのに、トップだけ妙に遅い。
原因は単純で、Three.jsをトップレベルでインポートしていたことでした。
症状: トップページのモバイル初期ロードが10秒
あるReactコンポーネントで、こう書いていました。
import { Scene, PerspectiveCamera, WebGLRenderer, IcosahedronGeometry, ... } from 'three';
export default function WireframeBackground() {
useEffect(() => {
const scene = new Scene();
// ...
}, []);
return <div ref={containerRef} />;
}
デスクトップでは問題なし。iPhone Braveで開くと、ページが描画されるまで8〜10秒。プログレスバーが半分で止まり、その後に一気に完了します。下層ページ(このコンポーネントを使わないページ)は同じ条件でも1秒以内。違いはThree.jsの有無だけ。
原因: 静的インポートが初期ロード対象に取り込まれる
Vite と Rollup は、静的 import で参照されたモジュールを初期ロード対象の依存グラフに取り込みます。WireframeBackgroundを client:only="react" でReact Islandとしてロードしていても、そのコンポーネントが import { ... } from 'three' していれば、Three.jsはWireframeBackgroundと同じチャンクとしてダウンロードされます。
実測:
| チャンク | raw | gzip推定 |
|---|---|---|
| WireframeBackground (Three.js同梱、修正前) | 720KB | ~190KB |
ファイル名は WireframeBackground.HASH.js の1ファイル。けれど中身の大半はThree.jsのコードです。これをモバイルの遅いCPUでパースして初期化すると、メインスレッドが長時間ブロックされます。
解決: 動的インポートで別チャンクに分ける
修正は機械的です。トップレベルの import を、関数内の動的 import() に置き換えます。
useEffect(() => {
let mounted = true;
let cleanup: (() => void) | null = null;
const start = () => {
void import('three').then((three) => {
if (!mounted || !container) return;
const { Scene, PerspectiveCamera, WebGLRenderer, ... } = three;
// ...初期化...
cleanup = () => { /* scene/geometry/renderer dispose */ };
});
};
start();
return () => {
mounted = false;
cleanup?.();
};
}, []);
ポイントは2つ。動的 import('three') は、Vite と Rollupに「この依存は別チャンク」と指示します。結果、Three.jsは three.module.HASH.js (715KB) という独立チャンクに切り出され、WireframeBackground本体は3.6KBの小さなチャンクに。もう一つは mounted フラグと cleanup 関数。動的インポートが完了する前にコンポーネントが unmount された競合を防ぎます。Promiseが resolve した時点で mounted=false なら、早期return。
requestIdleCallbackでアイドル初期化
別チャンクに分けただけだと、ロード後すぐにThree.jsを初期化してしまい、初期レンダリングと競合します。requestIdleCallback を使い、メインスレッドが空いてから初期化を始めます。
const w = window as Window & {
requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number;
};
if (typeof w.requestIdleCallback === 'function') {
w.requestIdleCallback(start, { timeout: 1500 });
} else {
setTimeout(start, 200);
}
requestIdleCallback は、メインスレッドのアイドル時間にコールバックを呼ぶ仕組み。timeout を渡しておくと、「アイドルが訪れないまま指定時間が過ぎたら、次の機会に実行候補に入る」挙動になります。厳密な発火時刻保証ではなく、最大1.5秒を目安に遅らせる、という指定です。未対応環境では setTimeout でフォールバック。これでThree.jsのロードと初期化が、最初の描画が終わってから走るようになります。
scrollY clampでiOS rubber-band保護
ついでに、iOS WebKitの rubber-band 対策も入れます。スクロール連動で何かを動かすコンポーネントは、scrollYが負値になる瞬間(rubber-band中)に意図せぬ挙動を起こします。
let scrollY = Math.max(0, window.scrollY);
window.addEventListener('scroll', () => {
scrollY = Math.max(0, window.scrollY);
}, { passive: true });
これだけで、「スクロール位置が負値になる瞬間に scale や rotation が一瞬戻る」現象を防げます。
結果: 720KBから3.6KB
修正後のチャンク:
| チャンク | raw | gzip推定 | タイミング |
|---|---|---|---|
| WireframeBackground.HASH.js | 3.6KB | <1KB | 初期 |
| three.module.HASH.js | 715KB | ~190KB | アイドル (描画完了後) |
初期チャンクからThree.jsが完全に外れて、トップページのモバイル初期ロードは数秒以内に短縮されました。背景のワイヤーフレームは数秒遅れで静かに現れますが、UXとしての違和感はありません。
サードパーティ製の大きなライブラリ(Three.js、Chart.js、動画プレイヤー、重いエディタ)を入れる時、どのチャンクに入るかを毎回意識します。静的インポートは初期ロード対象の依存グラフに取り込まれる。これだけ覚えておけば、バンドル分割の判断は持てます。