📜

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で編集を提案

Discussion