この記事ではReactで巨大なリストを効率よく表示するためのライブラリ react-window
をご紹介します。
似たようなライブラリに react-virtualized
というものがありますが、react-window
は同じ作者による新しいライブラリです。
はじめに
ナイーブな方法では、長大なリストを表示するにはその長さに比例するだけのレンダリングコストがかかります。ナイーブな方法とは下記のようにコンポーネントのリストを作って表示するだけの方法です。
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>
);
}
要素の数を変えて何回か実行してみたところ次のような結果になりました。
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
を渡さないと要素の高さが固定できません。
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
についてもナイーブな実装方法と同様に実行時間を計測してみました。
Renderingにかかる時間
100 items | 500 items | 1000 items |
---|---|---|
9.9 ms | 10.4 ms | 9.9 ms |
要素数が大幅に増えても実行時間がほとんど変わらなくなりました!
FixedSizeGrid
& VariableSizeGrid
リストだけでなくテーブルにも応用できます。次元が増えるだけなのでコードは割愛します。
InfiniteLoader
react-window-infinite-loaderの InfiniteLoader
を使うことで、無限スクロールが実現できます。
いくつか注意点があります。
-
InfiniteLoader
の中でFixedSizeList
を呼んでいます。FixedSizeList
が要素のレンダリングイベントをキャッチしてInfiniteLoader
が要素のロードを行っているというイメージです。 -
InfiniteLoader
に渡すitemCount
はリモートにあるデータ全体を含めた上での長さです。それがわかるならその値を指定すればいいですが、わからない場合は適当に大きい値を設定してください(ここではローカルにあるitemsの長さ+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
のラッパーを呼ぶ部分です。データをフェッチする処理を適当に定義してラッパーに渡します。
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
参考
記事に記載したコードのソース