仮想スクロールは万能じゃない:Next.jsで導入して見えたメリット・デメリット
はじめに
Next.jsで数百行規模の明細テーブル(行内に入力フォームが並び、開閉もある)を扱っていました。自分の環境では、行数が増えるにつれて操作がラグく感じるようになり、特に100行を超えたあたりからつらさが出てきました。
ページネーションは要件的に入れない方針だったので、描画量を減らす手段として 仮想スクロール(@tanstack/react-virtual) を導入しました。その中で分かった「良かった点(メリット)」と「注意点(デメリット)」をまとめました。
仮想スクロールとは
仮想スクロール(virtual scroll / virtualization)は、一覧の表示でよく使われる最適化の考え方で以下のような特徴があります。
- 画面に見えている行(+少し先読み分)だけを描画する
- 画面外の行は DOMとして持たない(=実際には存在しない)
- その代わり、スクロール領域全体の高さは「全件分あるように」見せて、ユーザーには普通のスクロールに見えるようにする
map で全件を描画すると、行数が増えたときに DOM が巨大になり、レイアウト計算や再レンダーの負荷が上がりやすくなります。
仮想スクロールは、DOMの量を抑えることで操作の重さ(ラグ)を減らす方向のアプローチです。
一方で、見えていない行はDOMに存在しないので、
- ブラウザの全体検索(Cmd + F)が効かない
- テストが「要素が存在する前提」だと崩れる
- 開閉などで行の高さが動的に変わると難易度が上がる
といったトレードオフも出やすいです(このあたりは後半で触れます)。
メリット:導入して良かった点
ラグが減りユーザビリティが改善
全行DOMを持っていた状態と比べると、操作が軽くなりました。
明細テーブルみたいに1行が重いケースだと、特に差が出やすい印象でした。
また、改善前後でWebの計測ツールを使い、200件描画時の指標を比較しました
あくまで自分の環境・条件での結果ですが、数値としては次の変化が出ました。
| 改善前の数値 | 改善後の数値 | |
|---|---|---|
| LCP (Largest Contentful Paint) | 6.34s | 3.11s |
| CLS (Cumulative Layout Shift) | 0.05 | 0.01 |
| INP (Interaction to Next Paint) | 2,184ms | 8ms |
ここで出てくる指標(Web Vitals)の意味はざっくり次のとおりです。
-
LCP (Largest Contentful Paint)
画面の主要コンテンツが表示されるまでの時間を表す指標で、「表示が完了するまでの体感」に近いです。 -
CLS (Cumulative Layout Shift)
表示中にレイアウトがどれだけズレたか(ガタついたか)を表す指標で、値が小さいほど安定しているとみなされます。 -
INP (Interaction to Next Paint)
クリックや入力などの操作に対して、次に画面が描画されるまでの遅延を表す指標で、「操作の反応の良さ」に近いです。
体感としても、初期レンダリング速度の向上や入力や開閉に対する“次の反応”が軽くなった感触がありました。
デメリット:導入して分かった注意点
この明細テーブルでは、仮想スクロールで体感は良くなった一方で、デメリット(注意点)もいくつか出ました。
1) 実装コストと複雑性が増えた
DOMが減って軽くなる方向には進んだ一方で、実装は複雑になりました。
- スクロール位置
- 開閉状態
- 行の高さ
このあたりが絡むので、体感改善と引き換えに「扱うべき前提」が増えた印象でした。
(導入後も、仕様追加や改修のたびにこの複雑性が効いて工数が嵩んでいるのを感じました)
2) テスト(Vitest)が落ちるようになった
今回のケースでは仮想化を導入したことで テストが落ちるようになりました。
仮想スクロールは「スクロール量・要素サイズ」に依存して描画範囲を計算するため、それらが存在しないテスト環境では正しく動作しなくなるためスクロール関連の値やイベントをモックする必要があったからです。
@tanstack/react-virtual を例にすると、内部ではだいたいこんな情報を扱っているのでそのモック化がテスト内部で必要になりました。詳しい実装などはまた別の記事で扱おうと考えているので今回は割愛します。
- scrollTop
- clientHeight
- scrollHeight
- getBoundingClientRect()
- スクロールイベント(scroll)
3) ブラウザの全体検索(Cmd + F)が効かなくなった
仮想スクロールは「見えている行しかDOMに存在しない」状態になるので、ブラウザのページ内検索(Cmd + F / Ctrl + F)で テーブル全体を検索できない という問題が出ました。
自分のケースでも、あとから「明細全体を検索したい」という要件が出てきたときに、仮想化だけでは満たせないことが分かり、別途対応が必要になりそうでした。
- 例:入力データ(配列)を対象にした独自検索UIを用意する
- 例:検索結果の行へスクロールしてハイライトする(仮想化のAPIと組み合わせる)
仮想スクロールの導入判断について
仮想スクロールは「効くときは効いた」一方で、コストも増えました。
なので導入はコストとユーザビリティのバランスを考えて判断する必要があると感じました。
また導入時にテストなどの避けられないコストもありますが、行が動的に高さが変わるかことやスクロールに関連する機能の多さなど要件によってコストも増える可能性があるので判断材料にしても良いかもしれません。
行が軽い・データが少ない・ページネーションで解ける場合は、仮想化はやりすぎになるケースが多くなりそうだと思いました。
まとめ
仮想スクロールは、明細テーブルの操作性の向上が顕著だった一方で、実装の複雑性やテスト対応、Cmd+Fで全体検索できないといったデメリットやコスト増加も見受けられました。
そのため「とりあえず入れる」より、ページネーションなどの代替案と、可変高さ・スクロール要件・検索要件・テスト工数を含めて、導入可否を判断するのが良さそうでした。
Discussion