📜

react-window 調査 まとめ

2021/03/11に公開

こんにちは、ハトです。

いろいろ時間をかけて調査した結果、使わないことになったので供養します(ち〜ん)

長いリストのパフォーマンスを向上させる

Reactで長いリストをレンダリングするときに、初期のレンダリング時にコストをすべて払います。これを画面が表示される部分だけレンダリングさせるようにしたライブラリがreact-windowです。React公式サイトのパフォーマンス向上ライブラリの1つとして紹介されています。

こちらを調査する機会があったので、それをまとめてみました。react-windowはgridの機能もあるのですが、今回はリストのみ調査対象です。

サイト

https://react-window.now.sh/#/examples/list/fixed-size

https://github.com/bvaughn/react-window

概要

実際にリストをすべてレンダリングするのではなく、画面に表示されている領域のみをレンダリングする技術。それによって、リストをすべてレンダリングするコストを回避しています。

ライブラリ内部に仮想のリストを保持してあり、リストの合計高さと、それぞれのアイテムの高さからスクロールとアイテム位置を計算します。

FixedSizeListはシンプルにわかりやすいです。

VariableSizeListは各アイテムの高さが異なるため、ライブラリの利用者はVariableSizeListに各アイテムの高さ情報を渡す必要があります。これが結構ハードルが高く、仮に嘘の情報を渡したら、アイテム要素が重なるし、スクロールもうまくいきません。高さ可変のリストを扱いたいなら素直に無限ローディングとかの技術を使ったほうが良いと思われます。またVariableSizeListは内部にアイテムの各高さ情報をキャッシュしています。アイテムの高さが何かしらの原因で変わった場合はこのキャッシュをリセットする必要があります。リセットされると現在表示されているアイテムはすべて再レンダリングされます。

画面に表示されてあるアイテムのみレンダリングしているので、画面上に表示されていないアイテムはその都度削除されます。つまり上下のスクロールをするとそのたびにレンダリングが発生しています。

各アイテムはライブラリ内でその高さ座標を計算されており、jsによって動的に管理されています。アイテムのstyleは position: absolute になっており、jsによって座標が動的に変化します。absoluteなので、アイテムの高さが何かしらの原因で変わったとき、リセットしなければアイテム同士の要素が重なります。

得意なこと

  • とても長いリストのレンダリングが得意です。
  • アイテムのレンダリングにコストがかかるリストのレンダリングが得意です。
  • アイテムの高さ(横向きだと幅)が決まっているリストのレンダリングが得意です。
  • アイテムの高さ(横向きだと幅)が決まっているリストのスクロールが得意です。

アイテムの高さがきまっているとは、最初からこのアイテムは高さ何pxか決まっているということ。レンダリングして初めてわかるものはこれに当てはまりません。

苦手なこと

  • 表示範囲のみしかレンダリングされないので、印刷は苦手です。リストをすべて印刷したいのであれば、ほかに専用のページを設けるか、csv出力などの代替手段を考える必要があります。
  • アイテムの高さ(横向きだと幅)がきまっていないリストのレンダリングが苦手です。
  • アイテムの高さ(横向きだと幅)が決まっていないリストのスクロールが苦手です。
  • アイテムが縦に伸縮することが多い場合(例えばアイテム内のボタンをクリックすると何らかの要素が展開されてアイテムの高さが長くなったりするやつ)、その都度リストを再レンダリングする必要があります。つまり高さが変わる系のイベントには注意を払う必要があります。

高さが決まっていないアイテムの描画に関して、issueで議論されているようですが、最近は活発ではないみたいです。
https://github.com/bvaughn/react-window/issues/6

issueの後半にあるように、他のライブラリを使用するほうが現実的かもしれません。
https://github.com/TanStack/virtual
https://virtuoso.dev/

結論

銀の弾丸じゃなかった。割と苦手なことも多い。

Tips

以下は色々試した結果のコードを部分的に抽出して再利用しています。動作保証はありません。

render props

コンポーネントのpropsにJSXを返す関数を渡すこと。Reactの公式サイトでも紹介されている。

const ChildComponent = (props) => {
  return (<div>{props.renderItem()}</div>)
}

const Parent = () => {
  const renderItem = () => {
    return <p>Hello!</p>
  }
  return (<ChildComponent renderItem={renderItem} />)
}

以下のTipsではこれを利用している。

refの合成

どこかからか拾ってきました。探したのですがどこか忘れてしまいました。。。

import React from 'react';

type Mutable<T> = {
  -readonly [k in keyof T]: T[k];
};

export const composeRefs = <T>(...refs: React.Ref<T>[]) => (
  component: T,
): void => {
  refs.forEach((ref) => {
    if (typeof ref === 'function') {
      ref(component);
    } else if (ref && 'current' in ref) {
      (ref as Mutable<React.RefObject<T>>).current = component;
    }
  });
};

<div ref={composeRefs(ref, secondRef)} /> のように使えます。

高さと幅を自動リサイズする。

react-virtualized-auto-sizerを使います。

アイテムの高さを描画時に計算する

レンダリングして高さ情報を取得して、高さキャッシュリセットして、もう一度レンダリングする荒業。レンダリングしているアイテムの高さしか正確にはわからないので、まだレンダリングされたことのないアイテムへのスクロールは難しいです。

import { VariableSizeList } from 'react-window';

export interface ListProps {
  itemCount: number;
  renderItem: (index: number) => React.ReactNode;
  getItemSize: (index: number) => number;
}

export const DynamicList = forwardRef<VariableSizeList, ListProps>
  (
    {
      renderItem,
      itemCount,
      getItemSize,
    },
    ref,
  ) => {
    return (
	<AutoSizer>
	{({ height, width }) => (
	  <VariableSizeList
            height={height} 
	    itemCount={itemCount}
	    itemSize={getItemSize}
	    width={width}
	    ref={ref}
	  >
	    {(style, index) => (
	      <div style={style}>renderItem(index)</div>
	    )}
          </VariableSizeList>
	)}
	</AutoSizer>
    );
});

interface RowProps {
  comment: string;
  index: number;
  setRowHeight: (index: number, height: number) => void;
}

export const Row: React.FC<RowProps> = ({ comment, index, setRowHeight }) => {

  const measureAndSet = () => {
    if (rowRef && rowRef.current) {
      setRowHeight(index, rowRef.current.clientHeight);
    }
  };
    
  useEffect(() => {
   // 高さ情報を更新
    measureAndSet();
  }, [rowRef]);
  
  return (
    // いろんな高さのアイテムを想定
    <div ref={rowRef}>{comment}</div>
  )
}

export const PenList = () => {
  const listRef = useRef<VariableSizeList>(null); // scroll用
  const rowHeights = useRef<{ [key in string]: number }>({});
  
  const comments = ['すごい', 'かっこいい', 'あこがれる', 'いいね'];
  
  const getItemSize = (index: number) => {
    const marginTop = 16;

    return rowHeights.current[index.toString()] + marginTop || 150;
  };
  
  // 状況に応じてuseCallbackするといいかも。
  const renderItem = (index: number) => {
  
    const setRowHeight = (index: number, height: number) => {
      if (rowHeights.current[index.toString()] === height) {
        return;
      }
      listRef?.current?.resetAfterIndex(index); // キャッシュリセット
      rowHeights.current = { ...rowHeights.current, [index]: height };
    };
      
    return (
      <Row 
        comment={comments[index]}
	index={index}
	setRowHeight={setRowHeight}
      />
    );
    
  }
  
  return (
    <DynamicList
      ref={listRef}
      renderItem={renderItem}
      itemCount={trunks.length}
      getItemSize={getItemSize}
    />
  )
}

最初に画面に表示されたときに自動でスクロールする

AutoSizer使っているとうまくuseLayoutEffectでスクロールできないので、リスト部分を別コンポーネント(List)にして、そこでuseLayoutEffect内でスクロールさせています。

import AutoSizer from 'react-virtualized-auto-sizer';
import {
  VariableSizeList,
  ListOnItemsRenderedProps,
  areEqual,
  ListChildComponentProps,
} from 'react-window';

export interface ListProps {
  itemCount: number;
  renderItem: (index: number) => React.ReactNode;
  getItemSize: (index: number) => number;
  className?: string;
}

const List = forwardRef<
  VariableSizeList,
  ListProps & { height: number; width: number }
>(
  (
    {
      itemCount,
      getItemSize,
      className,
      height,
      width,
      renderItem
    },
    ref,
  ) => {
    const localRef = useRef<VariableSizeList>(null);

    useLayoutEffect(() => {
      localRef?.current?.scrollToItem(10);
    }, []);

    return (
      <VariableSizeList
        height={height}
        itemCount={itemCount}
        itemSize={getItemSize}
        width={width}
        ref={composeRefs(ref, localRef)}
      >
        {renderItem()}
      </VariableSizeList>
    );
  },
);

export const AutoScrollList = forwardRef<VariableSizeList, ListProps>
  (
    {
      renderItem,
      itemCount,
      getItemSize,
      className,
    },
    ref,
  ) => {
    return (
	<AutoSizer>
	{({ height, width }) => (
	  <div>
	    <List
	      renderItem={renderItem}
	      height={height}
	      width={width}
	      itemCount={itemCount}
	      getItemSize={getItemSize}
	      isPositionAbsolute={isPositionAbsolute}
	      className={className}
	      ref={ref}
	    />
	  </div>
	)}
	</AutoSizer>
    );
});

無限ロードを採用する

react-window-infinite-loaderを使います。

上下にpaddingをつける

githubのREADMEにのってました。

https://codesandbox.io/s/react-window-list-padding-dg0pq

Discussion