システム開発
投稿日:

60fpsで快適表示!「仮想スクロール」を使った実装法(ReactとVue共通編)

60fpsで快適表示!「仮想スクロール」を使った実装法(ReactとVue共通編)

こんにちは、株式会社ファストコーディングのフルスタックエンジニア、独身貴族Fireです。

フロントエンドの開発で重要なのは「使いやすさ」と「表示スピード」、この2つに尽きます。「使いやすさ」はデザインや利用者の利用シーンも考慮されますが、スピードについてはどんな時でも早いことに越したことはありません。特に大量のデータを素早く表示することは、UIUXやデザイン以前に重視されるポイントです。私たちのプロジェクトでも、数千件のデータをスムーズに表示する必要があったので、この記事ではその解決策”仮想スクロール”の具体的な実装法をReactやVueを用いて解説します。

通常の方法ではパフォーマンスが低下する理由

以前は、長いリストを表示するときに幾度もスクロールしつつAjax読み込みする方法が採られていましたが、これはブラウザのメモリを過剰に消費することが問題でした。特に1,000件以上のデータになると、パフォーマンスが悪化するのは避けられません。それに、モバイル環境ではカクつくことさえあります。

仮想スクロールのポイント

今回は「仮想スクロール」と呼ばれる手法を紹介します。表示される範囲内だけDOMを生成し、見えない部分は生成しないことでシステムへの負担を軽くします。これのおかげで、スクロールがスムーズでおよそ60fps程度のふつうの表示が実現できます。

1. 見える部分だけを計算

まず重要になるのは、ユーザーがどの位置までスクロールしているかを計算する部分です。これが基準になり、どのアイテムを表示するかが決まります。Reactの場合は以下のように計算します。

const { start, end, offsetY } = useMemo(() => {
  const startIdx = Math.floor(scrollTop / rowHeight);
  const visibleCount = Math.ceil(height / rowHeight);
  const from = Math.max(0, startIdx - overscan);
  const to = Math.min(items.length, startIdx + visibleCount + overscan);
  const y = from * rowHeight;
  return { start: from, end: to, offsetY: y };
}, [scrollTop, height, rowHeight, overscan, items.length]);

ここで求めた開始と終了は、配列から切り出す見かけ上のアイテム範囲を意味します。offsetY は、切り出した小さな断片を本来の位置に見せるための基準で、親ラッパーを transform: translateY(...) で縦に移動させるとレイアウト負荷を抑えられます。

2. スクロールの取り込みは一フレーム一回に抑える

イベントは高頻度で発火しますが、状態更新を乱発すると体感が荒れます。ブラウザの描画サイクルに合わせて、1フレームあたり一度だけ取り込むのが安全です。

useEffect(() => {
  const el = viewportRef.current!;
  let rafId = 0;
  const onScroll = () => {
    if (rafId) return;
    rafId = requestAnimationFrame(() => {
      setScrollTop(el.scrollTop);
      rafId = 0;
    });
  };
  el.addEventListener("scroll", onScroll, { passive: true });
  return () => {
    el.removeEventListener("scroll", onScroll);
    if (rafId) cancelAnimationFrame(rafId);
  };
}, []);

この形なら、スクロール中でも余計な再計算が積み上がらず、体感的には軽めのままでDOMの更新がでいます。

DOMを作り直さず中身だけ差し替えるためのプール設計

可視行=数十件程度という前提でも、毎回の差し替えで再マウントが起きるやっぱり画面がチラチラします。そこで、画面に入る行数とその上下を少しバッファとして持たせた、合計した固定サイズのスロットを用意し、key をスロット番号で固定します。各スロットは transform で縦方向に配置し、中身だけを差し替えます。

{Array.from({ length: poolSize }, (_, slot) => {
  const item = slice[slot];
  const top = slot * rowHeight;
  return (
    <RowSlot
      key={slot}      // スロット番号を固定
      item={item}
      top={top}
      height={rowHeight}
    />
  );
})}

この方式なら、スクロールしてもDOMノード自体は変わらず、テキストなどの内容だけが入れ替わります。フォーカスや行選択の状態をスロット側で持てるため、インタラクションの破綻も抑えられます。

IntersectionObserverで末尾を監視して先読みする

表示が末尾に近づいたときに、十分な余裕をもって次のページを取りに行くと、連続してページがあるように見えるようになります。スクロールコンテナを root に指定し、末尾のスロットが可視になったらフェッチを呼びます。重複リクエストは AbortController で抑制します。

useEffect(() => {
  if (!rootEl || !sentinelEl) return;
  const io = new IntersectionObserver((entries) => {
    if (entries[0].isIntersecting) onReach();
  }, { root: rootEl, rootMargin: "600px 0px" });
  io.observe(sentinelEl);
  return () => io.disconnect();
}, [rootEl, sentinelEl, onReach]);

rootMargin をやや大きめに取れば、ユーザーが末尾に着くより前に通信を走らせられます。

サンプルコード(React)

サンプルコード(Vue.js)

※ダミーデータ生成のためfakeapiを埋め込んでおります。

実際のプロジェクトでの効果

ある通販サイトの管理画面にこの技術を取り入れたところ、3,000件を超える商品情報を表示しながらも60fpsを維持できました。ユーザーからは「反応が早い」という感想を多くいただき、実際ページ内のクリック率は大幅に伸びました。

おわりに

仮想スクロールは大量データを扱う上で非常に有効な解決策です。60fpsを目指すことで、プロジェクト全体のパフォーマンスが向上します。次のプロジェクトでぜひ試してみてください。仮想スクロールの実装についてのご相談は、こちらからお気軽にどうぞ。