バーチャルスクロールの限界を突破する
はじめに
私は今、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
これよりも大きい height
や width
を持つ要素は、上限値に切り捨てられます。
(なお、top
, left
や margin
, 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使ってます。)
おわりに
最後まで読んでいただきありがとうございました🙇♂️
よかったらSmoothCSVを使ってみてください。
Discussion
こんにちは。
この御方法にも限界はあるのでしょうか?
そうですね、限界はあると思います。
パッと思いつくのはこれくらいです。
最近似たことをやってるので参考になります
自分の調査不足かもしれませんが
既存のライブラリは2行目と5行目と3列目だけを固定(position:sticky)するような動作に対応してないのですよね…
自分は viewportSize の5倍くらいの高さの描画をしつつ端あたりまでスクロールしたら再描画するようにしてます
home や end は別途対応が要りますが pagedown や pageup に自然に対応できるのが良いです
(試してないので分かりませんがスマホのスクロールにも多分対応できますね)
印刷機能の対応とかも無理そうですしできればページャーで対応したいですね…
そうですね、スクロール位置に合わせて都度DOMを更新する方法の他に、viewportの数倍分のコンテンツを用意して内部でページ切り替えのようなことをする方法もあると考えていました。(私は前者の方法で目的を達成できたので後者の方法は試していません。)
バーチャルスクロールはブラウザの印刷やFind機能の恩恵を享受できなくなるという弱点があるので、そういう機能が必要でないシーンで使うか、代替手段(e.g. Find的な機能の実装)を提供するようにするとベストなのですが、そこまでやるのはなかなか骨が折れますね...
なるほどです
自分は windows (と Mac)の不特定多数のユーザーを対象にしていたので
onwheel でなく onscroll を使う方を選んでました
最近は windows 入りのノートパソコンでタッチスクリーンが付いているものがあるからですね
これは一般的に言えることなのですが、ブラウザが現状提供している「普通のUI」に不満があるからと言ってそれを作り直すことはすべきではありません。
今回だと、普通のスクロールバーは自作しないほうが懸命です。
そもそもの話、長大なコンテンツを表示させるときは普通のスクロールバーは無い方が良いのです。
なぜならどうせ頑張って操作しても最初と最後以外の狙った付近に持ってこれないので、あることで逆にユーザーのストレスになってしまうからです。
だからネイティブのスクロールバーに頼らないのなら、別の操作方法(1%,10%送りボタンを置く、行を入力できるようにする)などに切り替えた方がだいぶ良いと思います。
もしくはどうしてもスクロールバーにしたいのならスクロールが250万行あったらその数%に当たる10万行ずつ動くようにするとか、刻みで動くようにしないと存在価値がありません。
もっと言うと、この手のアプリでたまに見かけると思いますが、現在地を中心にバーに魚眼レンズを置いたような表示にして、
近い範囲は非常に細かく滑らかに移動できるが現在地から遠くなるほど大きな刻みで跳ぶようになるというUIがUX的に良いと思います。
逆に言うとそのくらいしないのであれば、スクロールバーは自作しない方が懸命でしょうね。
ありがとうございます!指摘内容は御尤もだと思います。参考にさせていただきます。