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 つは最初から揃っていました:
- レンダリングは画面外の行に触れない ✅
- ストレージはスパース — 100 万行の宣言で確保されるのは行高さの管理配列(~16MB)だけで、セルではない ✅
- データ……は事前ロード前提だった ❌
遅延ロード・データソースは 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 を読み込む。