🦫

バーチャルスクロールの限界を突破する

2024/10/08に公開
7

はじめに

私は今、CSVエディタ SmoothCSV 3 を開発しています。フレームワークとして Tauri を採用しており、レンダラーにはWebの技術(React + TypeScript)を使っています。

CSVエディタは大量の行・セルを表示する必要がありますが、Webの技術ではこのようなシーンではバーチャルスクロールを使うのが定石です。
SmoothCSVでもバーチャルスクロールを使っていましたが、どうやらこのバーチャルスクロールにも限界があるらしく、数百万行のような極端に大量のデータを表示する場合に最後まで表示しきれない問題に遭いました。

ここではバーチャルスクロールの基本と、その限界をどう乗り越えたかを紹介します。

About Me

  • 株式会社ヘンリーでソフトウェアエンジニア & アーキテクト的なことをしつつ、個人開発してます。
  • Social accounts:

なぜバーチャルスクロールが必要なんだっけ?

まず普通のスクロール

ブラウザでアイテム数が非常に多いリストやテーブルを表示する場合、ナイーブに実装するとアイテム数が増えるにつれてパフォーマンスが悪化し、やがてブラウザが固まってしまいます。ブラウザの表示領域外の要素も含めすべてのアイテムについてDOMツリーを構築しレンダリングするためです。

バーチャルスクロールという解決策

この問題を解決するためによく使われるのがバーチャルスクロールです。
バーチャルスクロールとは、スクロール位置に応じて見えているアイテムだけをJavaScriptで動的に生成するテクニックであり、これによりブラウザにかかる負荷を軽減します。

表示領域のことをビューポートと呼び、ビューポートの外側のコンテンツをバッファと呼びます。
ブラウザに正しくスクロールさせるために、バッファ部分にはダミー要素(Spacer)を配置し、コンテンツの高さを維持します。

(もしくはコンテンツのコンテナの高さを指定し、表示アイテムをposition: absoluteで配置する方法もあります。)

バーチャルスクロールの実装方法

バーチャルスクロールを簡単に実装するためのライブラリは多くあり、通常はこれらを使います。
Reactだとここらへんが有名だと思います。

私は TanStack Virtual を好き好んで使っています。React以外にVue, Svelte, Solid, Lit, Angularにも対応しています。

バーチャルスクロールの限界

あまり知られていないことですが、ブラウザで表示できる要素の幅や高さには上限があります
私が手元で実測した値は↓の通りです。

  • Safari: 33,554,428px
  • Chromium: 16,777,216px

これよりも大きい heightwidth を持つ要素は、上限値に切り捨てられます。
(なお、top, leftmargin, padding などのプロパティにも同様の上限があります。)

<!-- Safari での例 -->
<!-- 33,554,428px に切り捨てられる -->
<div style="height: 9999999999999px;" />

<!-- 直接サイズを指定しなくても同様 -->
<div> <!-- 計40,000,000pxになるはずが、33,554,428px に切り捨てられる -->
  <div style="height: 20000000px" />
  <div style="height: 20000000px" />
</div>

バーチャルスクロールもこの制限の影響を受けます。
スクロール対象のコンテンツの高さ(=全アイテムの合計の高さ)がこの上限値を超える場合、バーチャルスクロールを使っても最後までスクロールできません。

拙作のCSVエディタでは1行あたりの高さを22pxとしているため、macOS(Safari)では 1,525,200行くらいで限界に達し、それ以降の行を表示できないという問題がありました。


(行番号のボーダーもなぜか崩れている)

通常はあまり想定しなくてもいい行数かもしれませんが、CSVファイルは行数が非常に多くなることがあるため、この制約を受け入れてしまうとCSVエディタとしての価値が落ちてしまいます。

限界を超える戦略

バーチャルスクロールは、スクロールの仕組み自体はブラウザに任せているため、バッファも含めたスクロール対象コンテンツの高さを実際の全行の合計と同じにする必要があります。
逆に言うとブラウザのスクロール機能を頼る限り、この制約に縛られます
...ということはスクロールの仕組みを自作すればいいわけです!

(ここまで来ればもはやcanvasで描画しても良いかもしれません)

限界を超える実装

Reactで実装をしてみます。

1. スクロールバーを作る

まずは div を組み合わせてスクロールバーを作ります。
一般的にスクロールバーのレールを Track、つまみを Thumb と呼びます。

ScrollBar コンポーネントは、プロパティとしてビューポートの高さ(viewportSize)、スクロール対象のコンテンツの高さ(contentSize)、現在のスクロール位置(scrollPosition)を受取り疑似スクロールバーを描画し、ユーザー操作の結果をコールバック(onScroll)で通知します。

実装はだいたいこんな感じです。状態を持たない、Controlledなコンポーネントです。

export type ScrollBarProps = {
  viewportSize: number; // 表示領域のサイズ
  contentSize: number; // 対象のコンテンツの合計サイズ
  scrollPosition: number; // スクロール位置
  onScroll?: (scrollPosition: number) => void; // スクロール位置が変更されたときのコールバック
};

export function ScrollBar({
  contentSize,
  viewportSize,
  scrollPosition,
  onScroll,
}: ScrollBarProps) {
  // contentSize,viewportSize,scrollPositionから描画するスクロールバーのサイズとか位置とか計算
  const scrollRatio = viewportSize / contentSize;
  const thumbSize = Math.max(
    16, // Thumbの最小の長さ (Thumbが小さくなりすぎるないように)
    scrollRatio * viewportSize,
  );
  const maxScrollPosition = contentSize - viewportSize;
  const thumbPosition = (scrollPosition / maxScrollPosition) * (viewportSize - thumbSize);

  // コンテンツがビューポートに収まる場合はスクロールバーを表示しない
  const scrollBarVisible = contentSize > viewportSize;

  // Thumbの位置から実際のスクロール位置に換算
  const translateToScrollPosition = (thumbPosition: number) => {
    const newPosition = (thumbPosition / (viewportSize - thumbSize)) * maxScrollPosition;
    return Math.min(maxScrollPosition, Math.max(0, newPosition));
  };

  // Thumbをつまんでドラッグしたときの処理
  const handleMouseDownOnThumb = (event: React.MouseEvent) => {
    if (!scrollBarVisible) return;
    event.preventDefault();
    event.stopPropagation();

    const startMousePosition = event.clientY;
    const startThumbPosition = thumbPosition;

    // マウス押下したままスクロールしたときのハンドラー
    // (マウスがどこに動いても動作するように、ScrollBarのdivではなくdocumentにイベントを登録)
    const handleMouseMove = (event: MouseEvent) => {
      const delta = event.clientY - startMousePosition;
      onScroll?.(translateToScrollPosition(startThumbPosition + delta));
    };

    const handleMouseUp = () => {
      document.removeEventListener("mousemove", handleMouseMove);
      document.removeEventListener("mouseup", handleMouseUp);
    };
    document.addEventListener("mousemove", handleMouseMove);
    document.addEventListener("mouseup", handleMouseUp);
  };

  const handleMouseDownOnTrack = (event: React.MouseEvent) => {
    // Thumbの外側のTrackをクリックしたときの処理
    // パターン1: クリックした位置にThumbを移動
    // パターン2: クリックした方向に1ページ分移動
  };

  return (
    <div
      style={{
        position: "relative",
        height: viewportSize,
        width: 14,
      }}
      onMouseDown={handleMouseDownOnTrack}
    >
      {scrollBarVisible && (
        <div
          onMouseDown={handleMouseDownOnThumb}
          style={{
            position: "absolute",
            top: thumbPosition,
            height: thumbSize,
            width: 8,
            borderRadius: 8,
            margin: "0 3px",
            background: "rgba(0, 0, 0, 0.5)",
          }}
        />
      )}
    </div>
  );
}

なお、アクセシビリティやモバイル端末での挙動などを考えるともう少しいろいろ対応が必要です。

2. スクロールペインを作る

次に、スクロールバーの状態管理と、それに連動するコンテンツを表示するビューポートを提供するコンポーネントを作ります。
(このコンポーネントはスクロールの仕組みだけを提供し、スクロール位置に応じて描画する内容物は children で受け取ります。)

import type React from "react";
import { useState } from "react";
import { ScrollBar } from "./ScrollBar";

export type ScrollPaneProps = {
  contentSize: number;
  viewportSize: number;
  // 子要素として「スクロール位置に応じた表示コンテンツを返す関数」を受け取る
  // (Function as a Child (FaCC) パターン)
  children: (scrollPosition: number) => React.ReactNode;
};

export function ScrollPane({
  children,
  contentSize,
  viewportSize,
}: ScrollPaneProps) {
  const [scrollPosition, setScrollPosition] = useState(0);

  const handleWheel = (event: React.WheelEvent) => { // マウスホイールでスクロールしたときの処理
    setScrollPosition((prev) => {
      const newScrollPosition = prev + event.deltaY;
      return Math.max(
        0,
        Math.min(contentSize - viewportSize, newScrollPosition),
      );
    });
  };

  return (
    <div
      style={{ display: "flex", height: viewportSize }}
      onWheel={handleWheel}
    >
      {/* Viweport */}
      <div style={{ flex: 1, position: "relative", overflow: "hidden" }}>
        {children(scrollPosition)}
      </div>
      <ScrollBar
        contentSize={contentSize}
        viewportSize={viewportSize}
        scrollPosition={scrollPosition}
        onScroll={setScrollPosition}
      />
    </div>
  );
}

これを使う側はこんな感じで、ScrollPane の中に表示したいコンテンツを関数として渡します。

// ここでは単にスクロール位置を表示しているだけ
export default function App() {
  return (
    <ScrollPane
      contentSize={1000}
      viewportSize={300}
      style={{ border: "1px solid #ddd" }}
    >
      {(scrollPosition) => (<div>scrollPosition: {scrollPosition}</div>)}
    </ScrollPane>
  );
}

3. コンテンツを描画する

最後に、スクロール位置に合わせて表示したいコンテンツを描画します。
ここでは高さが均一のアイテムリストを表示する例を示します。

const ITEM_HEIGHT = 30;

// 300万アイテムのリストを生成
const items = Array.from({ length: 3000000 }, (_, i) => `Item ${i}`);

// 300万*30px = 90,000,000px (>ブラウザの上限)
const totalHeight = ITEM_HEIGHT * items.length;
const viewportSize = 300;

export default function App() {
  return (
    <ScrollPane
      contentSize={totalHeight}
      viewportSize={viewportSize}
    >
      {(scrollPosition) => {
        // ビューポート内に表示するアイテムを計算
        const startIndex = Math.floor(scrollPosition / ITEM_HEIGHT);
        const endIndex = Math.min(
          Math.ceil((scrollPosition + viewportSize) / ITEM_HEIGHT) + 1,
          items.length
        );
        const visibleItems = items.slice(startIndex, endIndex);

        // 先頭の表示アイテムの論理的な位置
        const startPosition = startIndex * ITEM_HEIGHT;

        return (
          <div
            style={{
              position: "absolute",
              top: startPosition - scrollPosition, // = ビューポートの上端からの距離
            }}
          >
            {visibleItems.map((item) => (
              <div key={item} style={{ height: ITEM_HEIGHT }}>
                {item}
              </div>
            ))}
          </div>
        );
      }}
    </ScrollPane>
  );
}

これで完成です 🎉

アイテムの表示位置は、ビューポートの上端からの距離を top で指定することで調整します。
最後の方のアイテムは startPosition が非常に大きくなりますが、scrollPosition も同様に大きくなるため、top に渡す値は現実的な範囲に収まります。

ちょっときれいにした完成形コードはこちらです。(TailwindCSS使ってます。)

https://github.com/kohii/react-custom-scroll-example

おわりに

最後まで読んでいただきありがとうございました🙇‍♂️
よかったらSmoothCSVを使ってみてください。

https://github.com/kohii/smoothcsv3

Discussion

kkddkkdd

こんにちは。
この御方法にも限界はあるのでしょうか?

kohiikohii

そうですね、限界はあると思います。
パッと思いつくのはこれくらいです。

  • JSのArrayのサイズの限界:
    • 23^2-1 = 4,294,967,295
    • 表示するアイテムを保持するための配列を作れない
  • JSのNumberの上限:
    • 論理的な高さやスクロール位置がJSのNumberで扱える範囲を超える場合に高さを正しく計算できないはず
  • メモリの限界:
    • ↑の限界に達する前に、表示するアイテムを保持するための配列が極端に大きくなった場合にメモリが不足しブラウザがクラッシュする
    • 表示範囲の描画に必要なデータだけを動的にフェッチ/ロードして、不要になったデータを破棄するようにすればこの限界は突破できる
rithmetyrithmety

最近似たことをやってるので参考になります

自分の調査不足かもしれませんが
既存のライブラリは2行目と5行目と3列目だけを固定(position:sticky)するような動作に対応してないのですよね…

自分は viewportSize の5倍くらいの高さの描画をしつつ端あたりまでスクロールしたら再描画するようにしてます
home や end は別途対応が要りますが pagedown や pageup に自然に対応できるのが良いです
(試してないので分かりませんがスマホのスクロールにも多分対応できますね)

印刷機能の対応とかも無理そうですしできればページャーで対応したいですね…

kohiikohii

そうですね、スクロール位置に合わせて都度DOMを更新する方法の他に、viewportの数倍分のコンテンツを用意して内部でページ切り替えのようなことをする方法もあると考えていました。(私は前者の方法で目的を達成できたので後者の方法は試していません。)

バーチャルスクロールはブラウザの印刷やFind機能の恩恵を享受できなくなるという弱点があるので、そういう機能が必要でないシーンで使うか、代替手段(e.g. Find的な機能の実装)を提供するようにするとベストなのですが、そこまでやるのはなかなか骨が折れますね...

rithmetyrithmety

なるほどです

自分は windows (と Mac)の不特定多数のユーザーを対象にしていたので
onwheel でなく onscroll を使う方を選んでました
最近は windows 入りのノートパソコンでタッチスクリーンが付いているものがあるからですね

mikamika

これは一般的に言えることなのですが、ブラウザが現状提供している「普通のUI」に不満があるからと言ってそれを作り直すことはすべきではありません。
今回だと、普通のスクロールバーは自作しないほうが懸命です。

そもそもの話、長大なコンテンツを表示させるときは普通のスクロールバーは無い方が良いのです。
なぜならどうせ頑張って操作しても最初と最後以外の狙った付近に持ってこれないので、あることで逆にユーザーのストレスになってしまうからです。

だからネイティブのスクロールバーに頼らないのなら、別の操作方法(1%,10%送りボタンを置く、行を入力できるようにする)などに切り替えた方がだいぶ良いと思います。
もしくはどうしてもスクロールバーにしたいのならスクロールが250万行あったらその数%に当たる10万行ずつ動くようにするとか、刻みで動くようにしないと存在価値がありません。

もっと言うと、この手のアプリでたまに見かけると思いますが、現在地を中心にバーに魚眼レンズを置いたような表示にして、
近い範囲は非常に細かく滑らかに移動できるが現在地から遠くなるほど大きな刻みで跳ぶようになるというUIがUX的に良いと思います。
逆に言うとそのくらいしないのであれば、スクロールバーは自作しない方が懸命でしょうね。

kohiikohii

ありがとうございます!指摘内容は御尤もだと思います。参考にさせていただきます。