【React】IntersectionObserverを使った巨大なリストのパフォーマンス改善

この記事は、 KENTEM TechBlog アドベントカレンダー2024 18日目、12月18日の記事です。

 こんにちは。私は、KENTEMでWEBフロントエンドエンジニアをしています。
 私の所属プロジェクトでは、Reactを採用しています。開発中、リスト内のアイテム数が多くなるにつれパフォーマンスが悪くなるという課題がありました。この記事では、IntersectionObserverを使って、巨大なリストのパフォーマンス改善を行う方法を紹介させていただきます。

どのようなリストか

 まず、今回パフォーマンス改善を行ったリストの要件の一部を紹介します。

  • FlexBox型のリスト
  • アイテム数は数千個になることもある
  • アイテムの大きさをユーザーが任意に変えることができる
  • リストの幅をユーザーが任意に変えることができる
  • ウィンドウサイズの変化により、リストの幅が変化する
    (ウィンドウサイズの〇〇%がリストの幅と設定されている)
  • クリックすると、アイテムを選択することができる
  • 選択したアイテムをドラッグ&ドロップできる

    下記の画像のようなリストをイメージしていただければと思います。

    リストのイメージ

パフォーマンスの課題

 リスト内のアイテムが数千個になると、アイテムの選択操作やドラッグ&ドロップ操作等が重くなってしまいました。具体的には、アイテムの選択操作だけで最低1~2秒はかかっていました。ドラッグ操作に至っては、ドラッグが始まるまでに2~3秒のラグがありました。全体的にもっさりとした動きになってしまい、快適なユーザー体験とは言い難い状況でした。

Windowingは銀の弾丸ではなかった

 巨大なリストのパフォーマンス改善方法を調べたところ、私はWindowingという技術を見つけました。
 Windowingとは、画面に描画されている部分だけをレンダリングさせることで、レンダリングコストを下げる技術です。有名なライブラリとしては、react-windowreact-virtuosoが挙げられます。
 これだ!と感じ、早速導入してみたのですが理想の動きの実現は難しいということが判明しました。というのも、上記のようなライブラリを使用するには、リスト・アイテムのサイズが固定サイズであることが望ましかったためです。*1以下は、react-windowのデモコードになります。*2Gridコンポーネントにリスト・アイテムの高さを渡していることが分かります。

import React from 'react';
import ReactDOM from 'react-dom';
import { FixedSizeGrid as Grid } from 'react-window';

import './styles.css';

const Cell = ({ columnIndex, rowIndex, style }) => (
  <div
    className={
      columnIndex % 2
        ? rowIndex % 2 === 0
          ? 'GridItemOdd'
          : 'GridItemEven'
        : rowIndex % 2
        ? 'GridItemOdd'
        : 'GridItemEven'
    }
    style={style}
  >
    r{rowIndex}, c{columnIndex}
  </div>
);

const Example = () => (
  <Grid
    className="Grid"
    columnCount={1000}
    columnWidth={100}
    height={150}
    rowCount={1000}
    rowHeight={35}
    width={300}
  >
    {Cell}
  </Grid>
);

ReactDOM.render(<Example />, document.getElementById('root'));

 ただ、今回のリストは1.で記載している通り、リスト・アイテムともに動的サイズとなっています。その場合、リスト・アイテムサイズが変わるたびにpropsの値を変更しなければなりません。全てのリサイズイベントに対応しようとするのは、現実的ではないと判断し、導入を見送ることにしました。

IntersectionObserverを使ったパフォーマンス改善

 Windowingライブラリの導入は見送ったものの、「画面に描画されている部分だけをレンダリングさせる」という考え方はパフォーマンス改善に有効だと感じました。そこで、発想を変え、以下のような機能を実装することにしました。

  1. リストの初回レンダリング時は、ダミー要素を描画する
  2. ダミー要素が画面内に描画されている場合は、実際のアイテムコンポーネントを描画する
  3. スクロールや1.で記載している操作時は、画面内に描画されている部分のみ実際のアイテムコンポーネントを描画させる

 この機能を実現するためには、画面内に要素が描画されているかを監視する必要があります。効率よく要素の監視ができるものが無いか調査したところ、IntersectionObserverを見つけました。
 IntersectionObserverは、ある特定の要素が、祖先要素またはビューポート(表示領域)に入ったり出たりするのを監視するためのAPIです。これにより、スクロールイベントを使わずに効率的に要素の表示状態を検出できます。詳細は、MDNのサイトを参照していただければと思います。

アイテム側の実装

 まずは、アイテム側の実装から紹介させていただこうと思います。画面内に入っているかを意味するisIntersectingという状態を定義します。isIntersectingの値により、描画するコンポーネントを切り替えるという形式です。また、リスト側からisIntersectingの値を変更できるようにするために、カスタムイベントをアイテムのDOMに登録しておきます。

import { useEffect, useRef, useState } from 'react';

const Item = () => {
    const itemRef = useRef<HTMLDivElement>(null);
    const [isIntersecting, setIsIntersecting] = useState<boolean>(false);

    const dispatchSetIsIntersecting = (e: CustomEventInit) => {
        setIsIntersecting(e.detail);
    };

    useEffect(() => {
        itemRef.current?.addEventListener('setIsIntersecting', dispatchSetIsIntersecting);

        return () => {
            itemRef.current?.removeEventListener('setIsIntersecting', dispatchSetIsIntersecting);
        }
    }, []);

    return (
        <div
            ref={itemRef}
            data-isintersecting={isIntersecting}
        >
            {isIntersecting ? <LoadItem /> :
                <div>
                    {/*ダミー用の軽いコンポーネントを描画*/}
                </div>}
        </div >
    )
}

export default Item;

リスト側の実装

 続いて、リスト側の実装です。useEffect内で、IntersectionObserverのインスタンスを作成し、要素を監視するようにします。IntersectionObserverのコールバック関数内で、isIntersectingかどうかを計算した上で、アイテムのカスタムイベントを発火させます。そうすることで、アイテムのisIntersectingの状態を切り替え、レンダリングさせています。ただ、スクロールバーを長押しされた際などに要素の監視が上手くいかない場合があります。その対策として、マウスアップ時に、自前の関数(updateIsIntersecting)を発火させています。
 また、この手法には一つ注意点があります。IntersectionObserverはDOMを監視するため、レンダリングによってDOMが再生成された場合は、最新のアイテムDOMに対して、監視をかけ直す必要があります。監視用のuseEffectの依存配列に必要な状態を追加することで、監視をかけ直すことができます。

import { useEffect, useRef } from 'react';

const List = () => {
    const listRef = useRef<HTMLDivElement>(null);

    const getIsIntersecting = (itemElem: Element): boolean => {
        const itemRect = itemElem.getBoundingClientRect();
        const listRect = listRef.current.getBoundingClientRect();
        if ((itemElem != null) && (listRect != null)) {
            if ((itemRect.bottom < 0) || (itemRect.top >= listRect.bottom))
                return false;

            return ((itemRect.top >= listRect.top) || (itemRect.bottom <= listRect.bottom))
        }
        else
            return false;
    }

    const updateIsIntersecting = () => {
        const itemElems = listRef.current.querySelectorAll('[data-isintersecting=true]');
        itemElems.forEach(q => {
            if (!getIsIntersecting(q))
                q.dispatchEvent(new CustomEvent('setIsIntersecting', { detail: false }));
        })
    }

    useEffect(() => {
        const observer = new IntersectionObserver((entries: IntersectionObserverEntry[]) => {
            entries.forEach(entry => {
                let isIntersecting = entry.isIntersecting;
                if (entry.intersectionRect.height <= 0)
                    return;

              //boundingClientRectが上手く取れていない場合は、自前の関数で画面内に描画されているを判定
                if (entry.boundingClientRect.height <= 0)
                    isIntersecting = getIsIntersecting(entry.target);

                entry.target.dispatchEvent(new CustomEvent('setIsIntersecting', { detail: isIntersecting }));
            })
        }, {
            threshold: 0.3, //アイテムが30%表示されるたびにコールバック関数が発火する
        });

        const itemElems = Array.from(listRef.current?.children);
        itemElems.forEach(q => {
            observer.observe(q);
        });

        return () => {
            observer.disconnect();
        }
    }, [/*適宜、依存配列を追加*/]);

    return (
        <div ref={listRef}
            onMouseUp={updateIsIntersecting}
        >
            {[...Array(1000)].map(_ => <Item />)}
        </div>
    )
}

export default List;

パフォーマンス改善後の動作について

 この手法を導入した結果、大幅にパフォーマンス改善をすることができました。実例として、リスト内のアイテムが3000個以上の場合に、アイテム選択操作にかかる時間を計測した結果を掲載します。アイドルの時間を除いた処理自体の実行時間は、499ミリ秒です。改善前はアイテム選択操作に、1~2秒はかかっていたことを踏まえると、かなり負荷が軽減できていることが分かります。

選択処理の実行時間計測結果

まとめ

 いかがだったでしょうか。ユーザービリティ向上のためには、パフォーマンス改善が重要だと思います。皆さんもぜひ、IntersectionObserverを使ったパフォーマンス改善法も検討していただければと思います。

おわりに

 KENTEMでは、様々な拠点でエンジニアを大募集しています! 建設×ITにご興味頂いた方は、是非下記のリンクからご応募ください。 recruit.kentem.jp career.kentem.jp

*1:react-virtuosoでは、アイテムの動的サイズに対応しているようです。

*2:コードサンドボックスへのリンクはこちらになります。