43
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

react-window で巨大なリストを低コストに表示する

Posted at

この記事ではReactで巨大なリストを効率よく表示するためのライブラリ react-window をご紹介します。

似たようなライブラリに react-virtualized というものがありますが、react-window は同じ作者による新しいライブラリです。

はじめに

ナイーブな方法では、長大なリストを表示するにはその長さに比例するだけのレンダリングコストがかかります。ナイーブな方法とは下記のようにコンポーネントのリストを作って表示するだけの方法です。

Naive
import React from "react";
import styled from "styled-components";

const Container = styled.section`
  display: flex;
  flex-direction: column;
`;

const RowContainer = styled.div`
  position: relative;
  width: 100px;
  height: 100px;
`;

const RowName = styled.div`
  position: absolute;
  bottom: 0;
  width: 100%;
  padding: 2px 8px;
  background-color: black;
  color: white;
  box-sizing: border-box;
`;

const RowImage = styled.img`
  width: 100%;
  height: 100%;
`;

const Row = ({ name, url }) => (
  <RowContainer>
    <RowName>{name}</RowName>
    <RowImage src={url} />
  </RowContainer>
);

const ITEM_NUM = 1000;

export default function App() {
  const rows = new Array(ITEM_NUM)
    .fill(0)
    .map((_, j) => (
      <Row
        key={j}
        name={`photo-${j}`}
        url={`https://picsum.photos/id/${j}/200/300`}
      />
    ));

  return (
    <div className="App">
      <Container>{rows}</Container>
    </div>
  );
}

要素の数を変えて何回か実行してみたところ次のような結果になりました。

image.png

Renderingにかかる時間

100 items 500 items 1000 items
18.9 ms 119.0 ms 425.3 ms

要素数が多くなればなるほど時間がかかっています。シンプルなコンポーネントならともかく、レンダリングコストの大きいコンポーネントのリストについて考えるときは、こういった実装をする前に要素数について慎重に考えなければなりません。

react-window

react-window は表示領域に応じてレンダリングするコンポーネントを制限することで要素数の問題を解決します。react-window は次の4つのコンポーネントといくつかの拡張で構成されています。

  • FixedSizeList
  • VariableSizeList
  • FixedSizeGrid
  • VariableSizeGrid

FixedSizeList & VariableSizeList

FixedSizeList は最も基本的なコンポーネントで、要素の数と要素の高さを指定することで表示する必要がある分だけのコンポーネントを描画してくれます。ナイーブな方法からの変更点は次のようになります。

注意点は、FixedSizeListのchildrenとして要素コンポーネント(Row)を渡すということと、要素コンポーネントのルート要素に style を渡すということです。要素の高さはFixedSizeListが決定するため、styleを渡さないと要素の高さが固定できません。

FixedSizeList
const Row = ({ index, style }) => (
  <RowContainer style={style}>
    <RowName>{items[index].name}</RowName>
    <RowImage src={items[index].url} />
  </RowContainer>
);

const ITEM_NUM = 1000;

const items = new Array(ITEM_NUM).fill(0).map((_, j) => ({
  name: `photo-${j}`,
  url: `https://picsum.photos/id/${j}/200/300`
}));

export default function App() {
  return (
    <div className="App">
      <FixedSizeList
        height={1000}
        width={100}
        itemSize={120}
        itemCount={items.length}
      >
        {Row}
      </FixedSizeList>
    </div>
  );
}

FixedSizeListについてもナイーブな実装方法と同様に実行時間を計測してみました。

image.png

Renderingにかかる時間

100 items 500 items 1000 items
9.9 ms 10.4 ms 9.9 ms

要素数が大幅に増えても実行時間がほとんど変わらなくなりました!

FixedSizeGrid & VariableSizeGrid

リストだけでなくテーブルにも応用できます。次元が増えるだけなのでコードは割愛します。

InfiniteLoader

react-window-infinite-loaderInfiniteLoader を使うことで、無限スクロールが実現できます。

いくつか注意点があります。

  1. InfiniteLoaderの中でFixedSizeListを呼んでいます。FixedSizeListが要素のレンダリングイベントをキャッチしてInfiniteLoaderが要素のロードを行っているというイメージです。
  2. InfiniteLoaderに渡すitemCountはリモートにあるデータ全体を含めた上での長さです。それがわかるならその値を指定すればいいですが、わからない場合は適当に大きい値を設定してください(ここではローカルにあるitemsの長さ+1の長さを指定しています)。
InfiniteLoader-part-1
const Row = ({ data, index, style }) => {
  const itemLoading = index >= data.length;

  if (itemLoading) {
    // return loading component
  } else {
    return (
      <RowContainer style={style}>
        <RowName>{data[index].name}</RowName>
        <RowImage src={data[index].url} />
      </RowContainer>
    );
  }
};

const List = ({ items, moreItemsLoading, hasNextPage, loadMoreItems }) => {
  // リモートの分も含めたデータの本質的な数を指定する必要がある。わからなければ適当に大きい値でも良い。
  const itemCount = hasNextPage ? items.length + 1 : items.length;

  console.log(items.length);

  return (
    <InfiniteLoader
      isItemLoaded={index => index < items.length - 1}
      itemCount={itemCount}
      loadMoreItems={loadMoreItems}
    >
      {({ onItemsRendered, ref }) => (
        <FixedSizeList
          height={1000}
          width={100}
          itemCount={itemCount}
          itemSize={120}
          onItemsRendered={onItemsRendered}
          ref={ref}
        >
          {Row}
        </FixedSizeList>
      )}
    </InfiniteLoader>
  );
};

以下はInfiniteLoaderのラッパーを呼ぶ部分です。データをフェッチする処理を適当に定義してラッパーに渡します。

InfiniteLoader-part-2

const ENDPOINT = "https://api.thecatapi.com/v1/images/search";
const LIMIT = 10;

export default class App extends React.Component {
  state = {
    items: [],
    moreItemsLoading: false,
    page: 0
  };

  async loadMoreItems(startIndex, stopIndex) {
    console.log("Loading...");

    this.setState({ moreItemsLoading: true });

    const response = await fetch(
      `${ENDPOINT}?limit=${LIMIT}&page=${startIndex / LIMIT}`
    );
    const data = await response.json();
    const items = data.map(e => ({ name: `cat-${e.id}`, url: e.url }));

    this.setState({
      moreItemsLoading: false,
      page: this.state.page + 1,
      items: this.state.items.concat(items)
    });
  }

  render() {
    return (
      <List
        items={this.state.items}
        moreItemsLoading={this.state.moreItemsLoading}
        hasNextPage={true}
        loadMoreItems={this.loadMoreItems.bind(this)}
      />
    );
  }
}

はじめにこれを見たときはトリッキーな書き方だと感じましたが、拡張は容易そうです。

react-virtualized との違いは?

同じ作者からほぼ同じ機能を持つライブラリ react-virtualized が公開されています。作者曰く、こちらは長らく開発されてきたプロジェクトで、本質的でない機能がたくさんあり動作が遅くパッケージが巨大になってしまっているそうです。react-window が2KBほどのサイズに対して react-virtualized は33.5KBほどあります。今から新しいプロジェクトを開発する場合は、ほとんどの場合でreact-windowを使ったほうが良いようです。

References

参考

記事に記載したコードのソース

43
20
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
43
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?