Reactアプリに潜むパフォーマンスの天敵
標準的なReactのリスト表示は、通常シンプルな.map()の呼び出しから始まります。この方法は、項目が50個や100個程度であれば完璧に動作します。しかし、アプリで1万行のログや膨大な製品カタログを表示する必要が出てきた瞬間、ブラウザの動作が不安定になり始めます。検索バーへの入力に200msの遅延を感じたり、スクロールが重くなったり、タブのメモリ使用量が簡単に1GBを超えたりすることに気づくでしょう。
ここでボトルネックとなっているのはReactではなく、DOMです。ノードを注入するたびにメモリを消費し、ブラウザにレイアウトの再計算を強いることになります。各リスト項目に5つの子要素が含まれている場合、1万行のリストはドキュメントに5万個のノードを投入することになります。それらが画面外にあったとしても、ブラウザはメモリに保持し続けなければなりません。仮想スクロール(ウィンドウイングとも呼ばれます)は、DOMに対する考え方を変えることで、このボトルネックを解消します。
仮想スクロールの仕組み
プロダクションレベルのアプリケーションにおいて、データ集約的なビューに対するバーチャライゼーション(仮想化)は不可欠な最適化手法です。コンセプトは明快です。リスト全体をレンダリングするのではなく、ユーザーのビューポート(表示領域)に現在見えている項目だけをレンダリングします。また、スクロール体験をシームレスに保つために、表示領域の上下にわずかなバッファとしていくつかの項目を含めます。
これを家の窓に例えてみましょう。長さ50メートルの壁の前に立っていたとしても、見えるのは幅1メートルの窓ガラス越しに見える庭の一部だけです。廊下を歩けば景色は変わりますが、窓の大きさは変わりません。技術的な用語で言えば、スクロールバーを正確に保つために巨大なスクロール可能な高さを備えたコンテナを用意しますが、実際にマウントされる<div>要素は常に10個から20個程度に留めるということです。
技術の背後にある核となる計算
仮想化をゼロから構築するには、以下の3つの変数を追跡する必要があります。
- Scroll Top: ピクセル単位での現在の垂直スクロール位置。
- Viewport Height: 表示領域の高さ(例:500px)。
- Item Height: 1行あたりの高さ。
これらの数値があれば、表示すべきインデックスを正確に特定できます。ユーザーが1,000pxスクロールし、各行の高さが50pxであれば、最初に表示される項目はインデックス20となります。単純な計算ですが、これによってブラウザが不要なノードで溢れかえるのを防ぐことができます。
実践:react-windowによる仮想スクロールの実装
カスタムのonScrollリスナーを自作することもできますが、コミュニティにはすでに高度に最適化されたツールが存在します。特におすすめなのがreact-windowです。gzip済みで約6kbと非常に軽量であり、キーボードナビゲーションや特定のインデックスへのスクロールといった複雑なエッジケースを標準でサポートしています。では、1万件の項目を表示する基本的な実装を見てみましょう。
import React from 'react';
import { FixedSizeList as List } from 'react-window';
const Row = ({ index, style }) => (
<div style={{ ...style, borderBottom: '1px solid #eee', display: 'flex', alignItems: 'center' }}>
行 {index} - データポイント: {Math.random().toFixed(4)}
</div>
);
const VirtualList = () => {
const itemCount = 10000;
return (
<div style={{ height: '400px', width: '100%', border: '1px solid #ccc' }}>
<List
height={400}
itemCount={itemCount}
itemSize={50}
width="100%"
>
{Row}
</List>
</div>
);
};
export default VirtualList;
ブラウザのデベロッパーツールを開き、スクロールしながらリストを検証してみてください。項目がビュー外に移動すると、そのDOMノードが即座に再利用または削除されるのがわかります。リストが100行であっても10万行であっても、ノードの総数は一定に保たれます。
動的な高さへの対応:真の課題
固定の高さは計算が簡単ですが、現実世界のデータが均一であることは稀です。ある行は1行の文章で、次の行は3つの段落がある場合はどうでしょうか?500番目の項目の高さがわからないと、全体のスクロール領域やスクロールバーの位置を正確に計算できません。
現代のアプリでは、通常推測される高さ(Estimated Heights)を使用してこの問題に対処します。@tanstack/react-virtualのようなライブラリでは、概算の高さを指定できます。行が実際にレンダリングされると、ライブラリが実際のDOMノードを計測し、リスト全体のレイアウトを動的に更新します。これにより、長さの異なるコンテンツをスクロールする際の「ガタつき」を防ぎます。
以下は、@tanstack/react-virtualがこれらの動的なシナリオを処理する方法です。
import { useVirtualizer } from '@tanstack/react-virtual';
function DynamicList({ items }) {
const parentRef = React.useRef();
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100, // 推測される高さ
});
return (
<div ref={parentRef} style={{ height: '500px', overflow: 'auto' }}>
<div style={{ height: `${rowVirtualizer.getTotalSize()}px`, width: '100%', position: 'relative' }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${virtualRow.start}px)` }}
>
{items[virtualRow.index].content}
</div>
))}
</div>
</div>
);
}
滑らかな体験のためのベストプラクティス
仮想化されたリストであっても、設定が不適切だと「カクつき」を感じることがあります。60fpsのパフォーマンスを確保するために、過去のプロジェクト監査から得られた以下の3つのルールに従ってください。
1. バッファとしてOverscanを使用する
Overscanは、表示領域のすぐ外側にあるいくつかの項目を追加でレンダリングします。ユーザーが素早くスクロールした際、このバッファがあることで、真っ白な画面が表示されるのを防ぎ、即座にコンテンツを見せることができます。通常、5〜10項目程度のOverscanを設定すれば、ほとんどのモバイルデバイスでレンダリングの遅延を隠すのに十分です。
2. 行コンポーネントをメモ化する
バーチャライザーはスクロールイベント中に常に再レンダリングをトリガーします。行コンポーネントが複雑な場合は、React.memoを使用して、表示されたままの各行の内部ロジックをReactが再計算するのを防ぎましょう。この小さな変更により、高速スクロール中のCPU使用率を30%削減できる場合があります。
3. 行のロジックを軽量に保つ
行コンポーネント内での重いデータ処理や日付のフォーマットは避けてください。1万件の日付をフォーマットする必要がある場合は、データ取得時に一度だけ行います。フォーマット済みの文字列をリストに渡す方が、フレームごとに計算するよりも大幅に高速です。
結論
仮想スクロールは、現代のWebアプリにおける基本的な最適化手法です。「すべてをレンダリングする」という考え方から脱却することで、膨大なデータセットを扱ってもレスポンスの良いインターフェースを構築できます。
軽量なreact-windowを選ぶか、複雑な動的レイアウトのためにTanStack Virtualを選ぶかにかかわらず、ユーザー体験への効果は絶大です。現在のプロジェクトで200行を超えるリストがないか確認してみてください。それらを仮想化バージョンに置き換えることは、多くの場合、達成可能な最大のパフォーマンス改善となります。

