virtual-lists について
React 開発者向けに、表示範囲だけを描画するリストとグリッドの仮想化を解説します。実装方法やスクロール中のデータ読み込みに加え、適さない状況も説明します。
主な機能
- 01表示範囲の行やセルだけを描画
- 02固定サイズと可変サイズのリスト
- 03縦横両方向のグリッド仮想化
- 04スクロールに応じたデータ読み込み
- 05アクセシビリティと動的高さの制約
活用シーン
- 01数百から数千項目のリストを高速化
- 02長いリストの初期描画時間を短縮
- 03大きな表形式データのスクロール改善
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ patternsdev/ skills --skill virtual-listsインストール後、Claude Code で/virtual-listsを入力して読み込まれたか確認します。
詳細
リストの仮想化
目次
リストの仮想化(ウィンドウ化とも呼ばれます)とは、動的なリスト全体ではなく、表示されている行だけをレンダリングする方法です。レンダリングされる行はリスト全体のごく一部であり、ユーザーのスクロールに合わせて表示範囲(ウィンドウ)が移動します。これにより、レンダリングのパフォーマンスが向上することがあります。
React を使用していて、大量のデータを効率よく表示する必要がある場合は、react-virtualizedをご存じかもしれません。これは、Brian Vaughnが開発したウィンドウ化ライブラリで、リスト内で現在表示されているアイテム(スクロール可能な「ビューポート」内)のみをレンダリングします。つまり、数千行のデータを一度にレンダリングするコストを負担する必要がありません。
使用するタイミング
- パフォーマンスの問題を引き起こす大規模なリストやグリッド(数百〜数千項目)をレンダリングする場合に使用します
- 初期レンダリングの時間を短縮し、スクロールのパフォーマンスを向上させるのに役立ちます
使用すべきでないタイミング
- 仮想化しなくてもネイティブのレンダリングで十分に高速な、短いリスト(約100項目未満)の場合
- アクセシビリティ要件により、スクリーンリーダー向けにすべてのリスト項目を DOM 内に含める必要がある場合
- リスト項目の高さが予測できず、コンテンツに応じて変化するため、仮想化の測定が信頼できない場合
手順
react-window(またはreact-virtualized)を使用して、スクロール可能なコンテナ内に表示されている項目のみをレンダリングします- 項目の高さがすべて同じ場合は
FixedSizeList、高さが異なる場合はVariableSizeListを選択します - ユーザーのスクロールに合わせてデータを段階的に読み込むには、
react-window-infinite-loaderを使用します - 完全な仮想化が不要な単純なケースでは、CSS の
content-visibility: autoを検討します
詳細
リストの仮想化はどのように機能しますか?
項目のリストを「仮想化」するには、ウィンドウを維持し、そのウィンドウをリスト内で移動させます。react-virtualized のウィンドウ化は、次のように機能します。
- 相対位置指定をした小さなコンテナー DOM 要素(例:
<ul>)を用意する(ウィンドウ) - スクロール用の大きな DOM 要素を用意する
- コンテナー内の子要素を絶対位置指定し、top、left、width、height のスタイルを設定する
リスト内の数千もの要素を一度にレンダリングするのではなく(初期レンダリングが遅くなったり、スクロールのパフォーマンスに影響したりする可能性があります)、仮想化ではユーザーに表示されている項目だけをレンダリングします。
これにより、ミドルレンジからローエンドのデバイスでも、リストのレンダリングを高速に保つことができます。ユーザーのスクロールに合わせて項目を取得・表示し、以前の項目をアンロードして新しい項目に置き換えられます。
react-virtualized より小さな代替ライブラリ
react-windowは、同じ作者による react-virtualized の書き直し版で、より小さく、高速で、さらにツリーシェイキングしやすいことを目指しています。
ツリーシェイキング可能なライブラリでは、サイズはどの API を使用するかによって変わります。react-virtualized の代わりに使用すると、圧縮後で約20〜30KBの削減が見込めます。
両パッケージの API は似ており、異なる部分では react-window のほうが一般にシンプルです。react-window には次のコンポーネントが含まれています。
リスト
リストは**ウィンドウ化されたリスト(行)**をレンダリングします。つまり、ユーザーに表示されるのは可視範囲の行のみです(例:FixedSizeList、VariableSizeList)。リストでは、行のレンダリングに Grid を(内部で)使用し、プロパティをその内側の Grid に渡します。
React を使用してデータのリストをレンダリングする
React を使用してシンプルなデータ(itemsArray)のリストをレンダリングする例を示します。
import React from "react";
import ReactDOM from "react-dom";
const itemsArray = [
{ name: "Drake" },
{ name: "Halsey" },
{ name: "Camillo Cabello" },
{ name: "Travis Scott" },
{ name: "Bazzi" },
{ name: "Flume" },
{ name: "Nicki Minaj" },
{ name: "Kodak Black" },
{ name: "Tyga" },
{ name: "Buno Mars" },
{ name: "Lil Wayne" }, ...
]; // our data
const Row = ({ index, style }) => (
<div className={index % 2 ? "ListItemOdd" : "ListItemEven"} style={style}>
{itemsArray[index].name}
</div>
);
const Example = () => (
<div
style={{
height: 150,
width: 300
}}
class="List"
>
{itemsArray.map((item, index) => Row({ index }))}
</div>
);
ReactDOM.render(<Example />, document.getElementById("root"));
react-window を使用してリストをレンダリングする
...同じ例を、react-window の FixedSizeList を使用して示します。FixedSizeList はいくつかのプロパティ(width、height、itemCount、itemSize)と、子要素として渡す行のレンダリング関数を受け取ります。
import React from "react";
import ReactDOM from "react-dom";
import { FixedSizeList as List } from "react-window";
const itemsArray = [...]; // our data
const Row = ({ index, style }) => (
<div className={index % 2 ? "ListItemOdd" : "ListItemEven"} style={style}>
{itemsArray[index].name}
</div>
);
const Example = () => (
<List
className="List"
height={150}
itemCount={itemsArray.length}
itemSize={35}
width={300}
>
{Row}
</List>
);
ReactDOM.render(<Example />, document.getElementById("root"));
CodeSandbox で FixedSizeList を試すこともできます。
Grid
Grid は、縦軸と横軸に沿って仮想化した表形式のデータをレンダリングします(例:FixedSizeGrid、VariableSizeGrid)。現在の縦方向と横方向のスクロール位置に基づいて、Grid 自身を埋めるために必要なセルのみをレンダリングします。
import React from 'react';
import ReactDOM from 'react-dom';
import { FixedSizeGrid as Grid } from 'react-window';
const itemsArray = [
[{},{},{},...],
[{},{},{},...],
[{},{},{},...],
[{},{},{},...],
];
const Cell = ({ columnIndex, rowIndex, style }) => (
<div
className={
columnIndex % 2
? rowIndex % 2 === 0
? 'GridItemOdd'
: 'GridItemEven'
: rowIndex % 2
? 'GridItemOdd'
: 'GridItemEven'
}
style={style}
>
{itemsArray[rowIndex][columnIndex].name}
</div>
);
const Example = () => (
<Grid
className="Grid"
columnCount={5}
columnWidth={100}
height={150}
rowCount={5}
rowHeight={35}
width={300}
>
{Cell}
</Grid>
);
ReactDOM.render(<Example />, document.getElementById('root'));
CodeSandbox で FixedSizeGrid を試すこともできます。
react-window のより詳しい例
Scott Taylor は、react-window と FixedSizeGrid を使用して、オープンソースのPitchfork 音楽レビューのスクレイパーを実装しました(ソース)。
Pitchfork scraper は react-window-infinite-loader(デモ)を使用しています。これは、大きなデータセットを、スクロールして表示範囲に入ったときに読み込めるチャンクに分割するのに役立ちます。
このアプリで react-window-infinite-loader をどのように組み込んでいるか、以下のスニペットをご覧ください。
import React, { Component } from 'react';
import { FixedSizeGrid as Grid } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
// ...
render() {
return (
<InfiniteLoader
isItemLoaded={this.isItemLoaded}
loadMoreItems={this.loadMoreItems}
itemCount={this.state.count + 1}
>
{({ onItemsRendered, ref }) => (
<Grid
onItemsRendered={this.onItemsRendered(onItemsRendered)}
columnCount={COLUMN_SIZE}
columnWidth={180}
height={800}
rowCount={Math.max(this.state.count / COLUMN_SIZE)}
rowHeight={220}
width={1024}
ref={ref}
>
{this.renderCell}
</Grid>
)}
</InfiniteLoader>
);
}
アプリを react-virtualized から移行するコミットも参考になるでしょう。
FixedSizeList を使用した実装も利用できます。
return (
<InfiniteLoader
isItemLoaded={this.isItemLoaded}
loadMoreItems={this.loadMoreItems}
itemCount={this.state.count}
>
{({ onItemsRendered, ref }) => (
<section>
<FixedSizeList
itemCount={this.state.count}
itemSize={ROW_HEIGHT}
onItemsRendered={onItemsRendered}
height={this.state.height}
width={this.state.width}
ref={ref}
>
{this.renderCell}
</FixedSizeList>
</section>
)}
</InfiniteLoader>
);
さらに複雑なニーズに対応するため、The Movie Database のデモアプリでは、内部で react-virtualized と Infinite Loader を使用していました。これを react-window と react-window-infinite-loader に移行するのに時間はかかりませんでしたが、まだサポートされていないコンポーネントがいくつかあることがわかりました。それでも、最終的な機能はかなり近いものになっています。
不足していたコンポーネントは WindowScroller と AutoSizer です。次にこれらを見ていきます。
// ...
return (
<section>
<AutoSizer disableHeight>
{({width}) => {
const {movies, hasMore} = this.props;
const rowCount = getRowsAmount(width, movies.length, hasMore);
// ...
return (
<InfiniteLoader
ref={this.infiniteLoaderRef}
// ...
{({onRowsRendered, registerChild}) => (
<WindowScroller>
{({height, scrollTop}) => (
react-window に不足しているものは?
react-window の API はまだ react-virtualized の API をすべて網羅していないため、採用を検討する場合は比較ドキュメントを確認してください。不足しているものは何でしょうか?
- WindowScroller - ウィンドウのスクロール位置に基づいてリストをスクロールできる、
react-virtualizedのコンポーネントです。react-window でこれを実装する予定は現在ありません。そのため、ユーザー側で対応する必要があります。 - AutoSizer - 利用可能な領域に合わせて拡大し、単一の子要素の幅と高さを自動調整する HOC です。Brian はこれをスタンドアロンパッケージとして実装しました。最新情報はこちらの Issueをご確認ください。
- CellMeasurer - ユーザーには見えない方法でセルをレンダリングし、そのコンテンツのサイズを自動測定する HOC です。対応に関する議論はこちらをご確認ください。
とはいえ、標準で含まれている機能で、react-window は私たちのニーズの大半を満たすことがわかりました。
ウェブプラットフォームの改善
一部の最新ブラウザーでは、CSS content-visibility がサポートされています。content-visibility:auto を使うと、画面外のコンテンツのレンダリングと描画を、必要になるまでスキップできます。レンダリングコストの高い長い HTML ドキュメントを扱う場合は、このプロパティを試してみてください。
動的なコンテンツのリストをレンダリングする場合は、react-window のようなライブラリを引き続きおすすめします。display:none を積極的に使用したり、現在多くのリスト仮想化ライブラリが行っているように画面外の DOM ノードを削除したりする方式に勝る、content-visibility:hidden を使ったバージョンのライブラリを実現するのは難しいでしょう。