ReoGrid ReoGrid Web

ブラウザのスプレッドシートで 100 万行 — 100 万行をロードせずに

· unvell team
ブラウザのスプレッドシートで 100 万行 — 100 万行をロードせずに

10,000 行を 60fps で描画する記事には、正直な制限事項のセクションがありました。そこにはこう書いてあります:

100 万行はスコープ外です。

考えを変えました。@reogrid/pro v1.2.2 から、これが動きます:

worksheet.setDataSource({
  totalRows: 1_000_000,
  totalColumns: 10,
  async load(rows) {
    const res = await fetch(`/api/rows?ids=${rows.join(',')}`);
    return res.json(); // string[][] — リクエストされた行ごとに 1 レコード
  },
});

グリッドは 100 万行にリサイズされ、スクロールバーはその全体を表します。つまみを掴んで 500,000 行目に投げれば、サーバーが応答する速さでビューポートが埋まります — 100ms の疑似ネットワークレイテンシを入れたデモでは、約 200 行のフェッチ 1 回と再描画 1 回です。

記事に値するのは「何が起きないか」のほうです。グリッドは残りの 999,800 行をロードも走査もメモリ確保もしません。テストセッションで 100 万行シートをあちこちジャンプした後、メモリ上のセルストアが保持していたのは 1,000,001 行中 611 行でした。


なぜ Canvas グリッドはこれをほぼタダで手に入れられるのか

DOM 仮想化グリッド(AG Grid / Handsontable 系)は行のウィンドウイングを何年も前に解決しています: 可視行分だけ DOM ノードをリサイクルし、スクロールに合わせて動かす方式です。しかし多くのアーキテクチャでは、仮想化はレンダリングの話であって、データセット自体はクライアントメモリに常駐するか、バックエンド側に専用実装を要求するサーバーサイド・ローモデル経由で届きます。

ReoGrid Web のレンダラーはもともとビューポート限定です。単一の <canvas> であり、毎フレーム、ワークシートの可視矩形だけを走査して描画します。セルデータはスパースな Map に格納され、空セルのコストはゼロ。描画コストは宣言行数と無関係に O(可視セル数) です。

つまり 100 万行シートに必要な 3 つの材料のうち 2 つは最初から揃っていました:

  1. レンダリングは画面外の行に触れない ✅
  2. ストレージはスパース — 100 万行の宣言で確保されるのは行高さの管理配列(~16MB)だけで、セルではない ✅
  3. データ……は事前ロード前提だった ❌

遅延ロード・データソースは 3 つ目を解決します。実装は意図的に小さく作りました: インターセプトは 1 箇所、管理クラスは 1 つ、独立したセルストアはなし。


API

import { createReogrid } from '@reogrid/pro';

const { worksheet } = createReogrid({ workspace: '#grid', licenseKey: '…' });

const handle = worksheet.setDataSource({
  /** データセットの総行数 — グリッドはこのサイズにリサイズされる */
  totalRows: 1_000_000,
  totalColumns: 10,

  /** グリッドが必要とする行番号で呼ばれる。バッチ化・重複排除済み */
  async load(rows) {
    const res = await fetch(`/api/rows?from=${rows[0]}&to=${rows[rows.length - 1]}`);
    return res.json();
  },

  /** 可視範囲を超えて先読みする行数(デフォルト 50) */
  bufferRows: 100,
});

// ハンドル:
handle.cachedRowCount;        // 現在メモリに保持している行数
handle.invalidateRows([42]);  // 特定行を破棄して再ロード
handle.invalidateAll();       // 全部破棄してビューポートを再ロード
await handle.ensureVisible(); // 可視範囲のロード完了で resolve
handle.detach();              // 切断 — ロード済みデータはシートに残る

レコードは密な配列(string[]、インデックス = 列)とスパースな Map(Map<number, string>)の両対応です。データがすでにクライアント側にあるなら load コールバックは同期でも構いません — Worker 内の 100 万行配列、IndexedDB、DuckDB-WASM の結果セットなど。

detach() の挙動は意図的です: ロード済みデータはワークシートに残ります。これにより便利なレシピが成立します — 必要な分をロードし、detach すれば、シートはそのデータを持つ完全に普通のスプレッドシートになる(ソート・フィルタ・エクスポート可能)。


内部の仕組み

設計は ReoGrid の .NET 版から借りています。.NET 版にはローカルデータベースを背後に持つデスクトップアプリ向けの DataSourceLoadMode.LazyLoading が以前からありました。Web 実装は 3 つの部品で構成されます。

1. インターセプトは 1 箇所

描画されるすべてのセルはワークシートのテキスト取得を通ります。遅延ロードのチェックはその先頭にあります:

protected getCellText(row: number, column: number): string | null {
  if (this.delayLoadManager !== null && !this.delayLoadManager.isRowLoaded(row)) {
    this.delayLoadManager.requestRow(row); // フェッチをスケジュール
    return null;                           // 今は空白で描画
  }
  // …通常パス
}

データソース未接続時のコストはセル読み取りごとの null チェック 1 回。別のコードパスもラッパー層もプロキシもありません。

2. microtask によるリクエストのバッチ化

1 回の描画パスは可視セル全部に対して getCellText を呼びます — たとえば 30 行 × 10 列を、1 つのタスク内で同期的に。セルごとにフェッチを飛ばすのは論外、行ごとでもまだダメです。代わりに requestRow は行番号を Set に入れ、queueMicrotask で単一の flush をスケジュールします:

  • 同じタスク内で触られた行はすべて同じバッチに入る
  • microtask は重複排除・ソート済みの行リストで load() を 1 回だけ呼ぶ
  • ロード済み・フェッチ中の行はスキップされる

レコードが到着すると、ワークシートの既存のスパースなセル Map に直接書き込まれ(シャドウストアなし)、再描画が 1 回スケジュールされます — 描画は requestAnimationFrame でバッチされるため、1 フレームに合体します。

デモでは、1,000,001 行シートの初回描画のコストはちょうどフェッチ 1 回: rows 0–127(可視 ~28 行 + バッファ 100 行)でした。

3. スクロール時の debounce 先読み

描画パスのインターセプトだけでも動きますが、それは「すでに画面に映っているもの」しかフェッチしません — スクロールのたびに空白がチラつくことになります。16ms の debounce 付きスクロールリスナーが可視範囲 ± bufferRows を先読みするので、ゆっくりしたスクロールで空白セルが見えることはなく、スクロールバーを投げ飛ばしても、リクエストの嵐ではなく 1 回の範囲フェッチで落ち着きます。

病的なケースに対するガードが 2 つあります:

  • 洪水ガード。 全選択コピーはシートの全行を走査します。上限がなければ 100 万行の load() 呼び出しが 1 回飛ぶことに。バッチは 2,048 行で打ち切られ、超過分は実際に画面に入ったときにロードされます。
  • エポック無効化。 invalidateAll() はエポックカウンタを進めます。それ以前に飛んでいたレスポンスは、古いデータを復活させる代わりに破棄されます。

シートを走査する機能はどうなる?

ここはグリッドベンダーが口をつぐみがちな部分なので、明示しておきます。遅延グリッドは「持っていないデータを持っているフリ」をします。ワークシートを走査する機能はすべて、ブロックしてフェッチするか、間違った答えを返すかの二択を迫られます — 私たちはどちらも選びませんでした:

機能データソース接続中の挙動
数式未ロード行に対しては非サポート — 遅延ロードシートは表示用途
ソート / オートフィルタデータが必要: 必要分をロード → detach() → 通常どおりソート/フィルタ
xlsx / JSON エクスポートロード済み行のみ出力
コピー未ロード範囲は空白としてコピー(100 万行フェッチも誘発しない)
編集ロード済み行では動作。未ロード行への編集はデータ到着時に上書きされうる

「100 万行、全部数式」がユースケースなら — ブラウザでそれを救えるツールは存在しません。デモでごまかすより、そう言うほうを選びます。

想定ユースケースは実際によくあるもののほうです: サーバーにある大きなデータセットを、使い慣れたスプレッドシート UI でスクロールして、眺めて、ところどころ編集したい — ログテーブル、取引履歴、商品カタログ、センサーデータ。この形の問題に対して、グリッドは数百行分のメモリで数百万行を提示します。


これが必要ないケース

ありがちな間違いは、50,000 行でデータソースに手を伸ばすことです。やめましょう — その規模ならバルクローダーのほうがシンプルで、事実上一瞬です:

データセット適切なツールコスト
~10 万行以下bulkSetCells()10 万行 × 10 列を ~350ms でロード、イベント 1 回、描画 1 回
~10 万行以上、サーバーバックsetDataSource()ビューポートごとにフェッチ 1 回、メモリ ~一定
無制限 / ストリーミングsetDataSource() + invalidateRows()変更行をその場でリフレッシュ

bulkSetCells はデータが本当にそこにあるので、数式・ソート・フィルタ・エクスポートの全機能がそのまま使えます。データソースはそれらをスケールと引き換えにします。気分ではなくデータセットで選んでください。


試す

遅延ロード・データソースは @reogrid/pro v1.2.2 に搭載されています。100 万行のライブデモも公開中 — スクロールバーを掴んで 500,000 行目に投げて、フェッチログがリクエストを合体させる様子を見てください:

npm install @reogrid/pro      # フル機能 — setDataSource、数式、エクスポート
npm install @reogrid/lite     # 無料 — 100 行 × 26 列、xlsx インポート

すでに ReoGrid Web を使っている場合、setDataSource() を呼ばない限り何も変わりません — インターセプトは呼ぶまで null チェック 1 回のままです。

このシリーズの過去記事: Canvas レンダラーが密なシートで 60fps を維持する仕組みサーバーに送らずブラウザで .xlsx を読み込む

ReoGrid Web を試してみる

React/Vue 向けの Canvas ベース Excel 互換スプレッドシートコンポーネント。 Lite は無料 — npm install 一発で始められます。

関連記事

ニュースレター

開発の最新情報をお届けします

新しいリリース・機能追加・お知らせをいち早く受け取るには、
メーリングリストにご登録ください。