データリストがブラウザの「悪夢」になるとき
1万行ほどのテーブルやリストをレンダリングしようとして、Chromeがフリーズしそうになったことはありませんか?その時のユーザー体験は最悪です。スクロールはカクつき、項目をクリックしても反応するまで数秒かかることもあります。
実は、問題はReact自体にあるのではありません。主な原因はブラウザのDOM処理にあり、仮想DOMなしで「爆速」を実現するライブラリが登場するほど、このボトルネックの解消は重要です。例えば1万行をレンダリングし、各行に5つのHTMLタグが含まれているとすると、ブラウザは5万個ものDOMノードを抱えることになります。わずかなステート(state)の変更でも、ブラウザはこれら膨大なノードのレイアウト再計算(reflow)と再描画(repaint)を行う必要があり、結果としてFPSが劇的に低下します。
以前、リアルタイムのサーバーログを表示するダッシュボードを開発した際、数千行程度なら問題ないだろうと考え、最初は.map()を使ってデータを表示していました。しかし、運用開始から30分後、ログの件数が急増するとChromeのメモリ使用量は2GB近くに達し、タブが完全にクラッシュしてしまいました。こうしたUIをフリーズさせずに重いタスクを処理する工夫が不足していると、ユーザーはすぐに離脱してしまいます。ここでの教訓は明確です。「ユーザーが見ていないものをブラウザに描画させてはいけない」ということです。
バーチャライゼーション(仮想化)技術:見えるものだけを描画する
バーチャライゼーション(またはウィンドウイング)の原理は非常にシンプルです。1万行すべてを構築する代わりに、現在表示されている範囲(ビューポート)内の行と、スムーズなスクロールを確保するための数行のバッファだけをレンダリングします。
下にスクロールすると、画面外に消えた古い要素はすぐにDOMから削除されます。同時に、新しい要素が挿入されます。これにより、データセットがいかに大きくても、DOMノードの数は常に最小限(例:20〜30個程度)に保たれます。
Reactコミュニティにおいて、この問題を解決するための最も標準的なライブラリがreact-windowです。これはreact-virtualizedを軽量化したもので、パフォーマンスに特化しており、導入も非常に簡単です。
実践:プロジェクトへのreact-windowの導入
まず、プロジェクトにライブラリを追加します。
npm install react-window
# または
yarn add react-window
1. 行の高さが固定の場合:FixedSizeList
リスト内の各行の高さが同じ(例:50px)である場合、これが最も効率的な選択肢です。UIコンポーネントを分離して開発する際にも、固定サイズは扱いやすく推奨されます。
import { FixedSizeList as List } from 'react-window';
const Row = ({ index, style, data }) => (
// スタイルは行の位置を決定するために非常に重要です
<div style={style} className="border-b flex items-center px-4">
<span>行 {index + 1}: {data[index].name}</span>
</div>
);
const MyList = ({ items }) => (
<List
height={500} // ビューポートの高さ (px)
itemCount={items.length}
itemSize={50} // 各行の高さ (px)
width="100%"
itemData={items}
>
{Row}
</List>
);
注意点:Rowコンポーネントのstyleプロップは必須です。react-windowはスクロール時に各行を正しい位置に配置するためにposition: absoluteを使用します。これを忘れると、すべての行が一番上で重なって表示されてしまいます。
2. 行の高さが可変の場合:VariableSizeList
各行の内容によって高さが異なる場合は、VariableSizeListを使用します。この場合、itemSizeにはインデックスに基づいてサイズを計算する関数を渡します。
import { VariableSizeList as List } from 'react-window';
const getItemSize = index => (index % 2 === 0 ? 50 : 100);
const MyVariableList = ({ items }) => (
<List
height={500}
itemCount={items.length}
itemSize={getItemSize}
width="100%"
>
{({ index, style }) => (
<div style={style}>カスタムの高さを持つ行 {index}</div>
)}
</List>
);
実践的なパフォーマンス最適化のヒント
スクロール時に60 FPSの滑らかさを実現するために、以下の2つの重要なポイントに注意してください。アプリケーション全体のパフォーマンスを最適化する上でも、これらの知識は欠かせません。
RowコンポーネントにReact.memoを使用する
バーチャライゼーションによってノード数は削減されますが、Rowコンポーネントの計算負荷が高いと、スクロールがカクつく原因になります。RowをReact.memoでラップし、ライブラリが提供するヘルパー関数areEqualと組み合わせることで、不要な再レンダリングを防ぎましょう。
import React, { memo } from 'react';
import { areEqual } from 'react-window';
const Row = memo(({ index, style, data }) => {
return <div style={style}>{data[index].label}</div>;
}, areEqual);
AutoSizerによる自動サイズ調整
react-windowは高さと幅を具体的な数値で指定する必要があります。しかし、Webインターフェースではレスポンシブ対応が求められるのが一般的です。その場合は、react-virtualized-auto-sizerを併用して、リストが親要素のスペースを自動的に埋めるようにすると良いでしょう。
react-windowを使うべきではないケース
非常に強力なツールですが、以下のようなケースでは無理に使用する必要はありません。
- 要素数が200未満の短いリスト:DOMは十分に処理可能であり、
.map()を使ったほうがコードがシンプルになります。 - ブラウザの標準検索(Ctrl + F)機能が必要な場合:表示されていない行はDOMに存在しないため、ブラウザはそれらを見つけることができません。
- 行やグリッドの構造に従わない、非常に複雑なUIの場合。
まとめ
Reactの最適化は、useMemoを使うことだけではありません。より高度なWebパフォーマンスの最適化を追求するなら、データ構造やレンダリング戦略そのものを見直す必要があります。大量のデータを扱う鍵は、DOMとの対話方法を変えることにあります。react-windowを使えば、数万行のデータを扱ってもメモリ使用量を抑え、ブラウザの動作を滑らかに保つことができます。ぜひプロジェクトに取り入れて、そのレスポンスの速さを体感してみてください!
