🦫

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

2024/10/08に公開

はじめに

私は今、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