Notes / Web技術

Three.jsを遅延ロードする。動的インポートとrequestIdleCallbackで720KBを3.6KBに

コーポレートサイトの背景にThree.jsを置いたら、モバイルの初期ロードが10秒近くかかりました。原因はトップレベルのインポートでThree.js全体が初期チャンクに同梱されていたこと。動的インポートとrequestIdleCallbackで別チャンクに分ける手順。

Three.js bundle splitting performance mobile

コーポレートサイトの背景に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と同じチャンクとしてダウンロードされます。

実測:

チャンクrawgzip推定
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

修正後のチャンク:

チャンクrawgzip推定タイミング
WireframeBackground.HASH.js3.6KB<1KB初期
three.module.HASH.js715KB~190KBアイドル (描画完了後)

初期チャンクからThree.jsが完全に外れて、トップページのモバイル初期ロードは数秒以内に短縮されました。背景のワイヤーフレームは数秒遅れで静かに現れますが、UXとしての違和感はありません。


サードパーティ製の大きなライブラリ(Three.js、Chart.js、動画プレイヤー、重いエディタ)を入れる時、どのチャンクに入るかを毎回意識します。静的インポートは初期ロード対象の依存グラフに取り込まれる。これだけ覚えておけば、バンドル分割の判断は持てます。

Webパフォーマンス改善のご相談はお問い合わせから、業務領域の詳細はServicesへ。