Gemcook Tech Blog
📜

Reactで仮想スクロールを独自実装する

2021/05/23に公開

はじめに

仮想スクロールとは巨大なリストを高速に表示するための実装テクニックのひとつです。
React で仮想スクロールを実装する場合は react-window を使用するケースが多いと思いますが、本記事では仮想スクロールを独自実装することで、その仕組みについて理解を深めて行きたいと思います。

今回のサンプルコードはこちら
https://github.com/sonishimura/react-virtual-scroll-example

仮想スクロールの仕組み

平たく説明すると、現在見えているリストアイテムのみを描画する方法です。
この記事の解説が非常に分かりやすいです。

https://qiita.com/neer_chan/items/5ff1a82ed2fe121026d5

この記事でやること

  • シンプルな仮想スクロールの実装

この記事でやらないこと

  • 無限ローディングの実装
  • 異なる高さを持つリストアイテムの実装

実装

仮想スクロールのための List コンポーネントを作成していきます。
まずはスクロールさせるための DOM 要素を作成していきます。

List.tsx
import React from "react";
import { DATA } from "./data";

const items = DATA;
const itemHeight = 50;
const containerHeight = 500;
const containerWidth = 500;

const List: React.FC = () => {
  return (
    <div
      style={{
        width: containerWidth,
        height: containerHeight,
        overflowY: "scroll",
        border: "1px solid gray",
      }}
    >
      <div style={{ height: items.length * itemHeight }}>
        <ul
          style={{
            margin: 0,
            padding: 0,
            listStyle: "none",
          }}
        >
          {items.map((item) => (
            <li
              key={item}
              style={{
                height: itemHeight,
                display: "flex",
                justifyContent: "center",
                alignItems: "center",
              }}
            >
              {item}
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
};

export default List;

1 階層目の div はスクロールさせるための要素です。
2 階層目の div は事前に計算したリスト全体の高さを持っておくことで、1 階層目の div がスクロールできるようにします。
3 階層目の ul がリスト要素です。この要素の配置を移動させることで、仮想スクロールを実現します。

しかし、現状では初回描画時に全てのアイテムが表示される実装になっています。
そのため、次はレンダリングするリストアイテムを制御する Hooks を作成します。
この Hooks には以下の役割を持たせます。

  • 先頭に表示するリストアイテムの Index の状態管理
  • 表示するリストアイテムを計算するスクロールイベントハンドラーを返却する
  • 表示するべきリストを返却する
useVirtualScroll.ts
import { useCallback, useMemo, useState } from "react";

// 余白が発生しないように画面外に余分にアイテムを表示しておく
const EXTRA_ITEM_COUNT = 3;

type Args<Item> = {
  containerHeight: number;
  itemHeight: number;
  items: Item[];
};

type ReturnItems<Item> = {
  startIndex: number;
  handleScroll: React.UIEventHandler<HTMLDivElement>;
  displayingItems: Item[];
};

export const useVirtualScroll = <Item extends unknown>({
  containerHeight,
  itemHeight,
  items,
}: Args<Item>): ReturnItems<Item> => {
  const [startIndex, setStartIndex] = useState<number>(0);
  const maxDisplayCount = Math.floor(
    containerHeight / itemHeight + EXTRA_ITEM_COUNT
  );

  const handleScroll: React.UIEventHandler<HTMLDivElement> = useCallback(
    (e) => {
      const { scrollTop } = e.currentTarget;
      const nextStartIndex = Math.floor(scrollTop / itemHeight);
      setStartIndex(nextStartIndex);
    },
    [itemHeight]
  );

  const displayingItems = useMemo(
    () => items.slice(startIndex, startIndex + maxDisplayCount),
    [startIndex, maxDisplayCount]
  );

  return { handleScroll, displayingItems, startIndex };
};

useVirtualScroll ではリストの高さとリストアイテムの高さから表示するべきアイテム数を割り出し、スクロールイベント時に startIndex の状態を更新することで、表示するリストアイテムを制御します。
Hooks が完成したので、先程の List コンポーネントから呼び出すようにします。

List.tsx
 import React from "react";
 import { DATA } from "./data";
+ import { useVirtualScroll } from "./useVirtualScroll";

 const items = DATA;
 const itemHeight = 50;
 const containerHeight = 500;
 const containerWidth = 500;

 const List: React.FC = () => {
+  const { displayingItems, handleScroll, startIndex } = useVirtualScroll({
+    containerHeight,
+    itemHeight,
+    items,
+  });

   return (
     <div
+      onScroll={handleScroll}
       style={{
         width: containerWidth,
         height: containerHeight,
         overflowY: "scroll",
         border: "1px solid gray",
       }}
     >
       <div style={{ height: items.length * itemHeight }}>
         <ul
           style={{
             margin: 0,
             padding: 0,
             listStyle: "none",
+            position: "relative",
+            top: startIndex * itemHeight,
           }}
         >
+         {displayingItems.map((item) => (
-         {items.map((item) => (
            <li
              key={item}
              style={{
                height: itemHeight,
                display: "flex",
                justifyContent: "center",
                alignItems: "center",
              }}
            >
              {item}
            </li>
           ))}
         </ul>
       </div>
     </div>
   );
 };

 export default List;

1 階層目の div にはスクロールイベントをアタッチします。
3 階層目の ul 要素にposition: "relative", top: startIndex * itemHeightを指定することでスクロール位置に合わせてリストの位置を移動させています。
こうすることで、全件を表示しているように見せながら実際には見えている範囲のみ描画しているという状態を実現しています。

以上で仮想スクロールの実装は完了です。
実際に動かしてみると、高速な初回描画・メモリー消費の軽減が達成できていることがわかります。

まとめ

仮想スクロールは使用できる用途に制限もありますが、使用できる場合非常に強力なパフォーマンス改善になります。
今回は仕組みを理解するために使用しませんでしたが、react-windowは異なる高さを持つリストアイテムのサポートや、無限ローディング機能などがあり非常に強力な仮想スクロールライブラリです。
React で巨大なリストを表示する必要がある場合は使用を検討すると良いかなと思います。

GitHubで編集を提案
Gemcook Tech Blog
Gemcook Tech Blog

Discussion