本文へスキップ

仮想スクロール

1万件のリストでも、見えている範囲の行だけをDOMに描画する仮想スクロール。スクロールに応じて表示スライスを差し替え、総量ぶんの高さはスペーサーで確保する。

プレビュー

スクロール

操作枠内をスクロールすると動きます。

実装メモ

1万件を全部DOM化すると重いので、可視範囲+前後の余白(overscan)ぶんの行だけを描画します。スクロール総量は spacer の高さ(件数×行高)で確保し、描画スライスを translateY で正しい位置へずらします。scroll イベントは requestAnimationFrame で間引き、描画範囲が変わったときだけ差し替えます。行高(ROW_H)を可変にする場合は各行の実測位置を積算する必要があります。

使いどころ

数千〜数万件規模の一覧(ログ、テーブル、チャット履歴)で、全件をDOM化すると描画・スタイル計算・メモリが重くなる場面に向く。このサンプルの1万行がまさにその例で、実際にDOMへ存在するのは可視範囲+オーバースキャン分の数十行だけに絞られている。逆に、数百件程度までのリストではブラウザの通常描画やcontent-visibility:autoで十分間に合い、自前で仮想化を組む複雑さに見合わない。また、行の高さが画像や折り返しテキストで毎回変わるコンテンツにもこの実装のままでは向かない(後述のとおり固定行高が前提)。加えて、ブラウザのページ内検索(Ctrl+F)やクローラーが一覧全文を拾う必要がある用途にも不向きで、画面外の行はDOM上に存在しないため見つけられない。検索性が要るなら、専用の検索欄を実装するか、そもそも仮想化しない一覧を別に用意する必要がある。

仕組み

総件数ぶんの高さは.vs-spacerにTOTAL×ROW_H(40px)で確保し、ブラウザのネイティブスクロールバーには1万行分の長さがあると正しく認識させつつ、実際に描画するのは.vs-slice内の可視ウィンドウだけにする。表示範囲はfirst=floor(scrollTop/ROW_H)-OVERSCAN、count=ceil(viewportHeight/ROW_H)+OVERSCAN×2で計算し、上下に4行ずつの余白(オーバースキャン)を持たせることで、高速スクロール時に一瞬空白が見える隙を減らす。sliceの位置合わせはtransform:translateY(first×ROW_H)で行い、topではなくtransformを使うことでコンポジタ処理に留めている。scrollイベントはrequestAnimationFrameで間引き、さらに計算したfirst/lastが前回描画と同じなら再描画自体をスキップする二段構えで、無駄なDOM再構築を避けている。バーの幅は行番号ベースの決定論的な擬似乱数式で、Math.random()を使うと同じ行が再度画面内に入るたびに値がちらつくため、あえて固定式にしてある。

実装の注意

最大の前提は行高(ROW_H=40)が固定であることで、JSの定数とCSSの.vs-itemの高さを必ず一致させる必要がある。折り返しテキストや画像で行の高さが変わる一覧にこのまま適用すると、スペーサーの高さもtranslateYの計算も総崩れになり、実測した各行の高さを積算するオフセットテーブルを持つ全く別の実装に作り替える必要がある(コード内のnoteにも明記されている)。次に、ブラウザのCtrl+Fは画面外の行を見つけられない。検索機能が必要なら、データ配列側で検索してヒット行のインデックスからscrollTopを直接計算して移動させる独自の検索を実装しなければならない。また、フィルタや絞り込みでTOTAL自体が変わる場面では、spacerの高さの再計算とscrollTopの範囲外参照(負の値やオーバーの値)への対処が要るが、このサンプルは初期化時の1回だけ計算しており、動的な件数変化には未対応。firstが負値にならないようMath.maxで下限を切ってはいるが、上限側の再クランプも含めて実装が必要になる。

アクセシビリティ

.vs-viewportにtabindex=0とaria-label、focus-visibleのアウトラインがあり、フォーカスすれば矢印キーやPage Up/Down、Home/Endでのネイティブなキーボードスクロールが機能する土台はできている。一方で、リストとしての意味構造(role=listや各行のrole=listitem)や、全体の中の何行目かを伝えるaria-posinset/aria-setsizeは付いていない。画面外の行はDOM自体が存在しないため、スクリーンリーダーで一覧をたどっても1万行中何行目にいるかという感覚が全く得られない。role=listをスライスのコンテナに、各行にrole=listitem aria-posinset={i+1} aria-setsize={TOTAL}を付けると多少改善するが、根本的にはスクリーンリーダー利用者が1万行を線形にたどって使う設計自体が非現実的である。視覚的なスクロール性能の最適化と支援技術での使い勝手は別問題と割り切り、行番号を直接指定してジャンプできる入力や、検索・フィルタで絞り込んでから読む代替導線を別に用意する方が実用的な解決になる。

調整して使う

行高が可変なコンテンツには、各行を実測してオフセットの累積テーブルを持つ方式に切り替える(計算量は増えるが、二分探索でスクロール位置から該当行を求められる)。横方向にも同じ発想を適用でき、translateXで列を絞り込む水平仮想化はスプレッドシートや幅広テーブルに使える。グリッド状のカード一覧(マソンリー等)では縦横2次元の仮想化が必要になり、行の発想を格子のセル単位に拡張する。件数が固定でなく随時増え続ける一覧(ログの追記など)では無限スクロールと組み合わせ、TOTALの増加に合わせてspacerの高さを都度更新する設計にする。自前実装がここまで複雑になる場合は、react-windowやvirtuaのような実績のあるライブラリに乗せる選択肢も現実的で、行高可変・水平対応・アクセシビリティ配慮が既に組み込まれている分、保守コストを下げられる。

コード(コピーして使えます)

<div class="vs-root">
  <div class="vs-header">
    <span class="vs-title">ログ</span>
    <span class="vs-count" id="vs-count">全 10,000 行</span>
  </div>
  <div class="vs-viewport" id="vs-viewport" tabindex="0" aria-label="仮想スクロールリスト">
    <div class="vs-spacer" id="vs-spacer">
      <div class="vs-slice" id="vs-slice"></div>
    </div>
  </div>
</div>

タグ

同じカテゴリ「ナビ・リスト操作」のほかのパーツ

関連パーツ