- MongoDB・PostgreSQL向けのデータベースGUIを作る中で、BSON・JSONB型、ネストした列、検索、編集、固定、ドラッグをサポートする必要があり、そのため約1年間にわたり両軸仮想化と状態管理構造を最適化した
- 元のドキュメントとは別に、表示文字列・型・フラット化されたパス・列順・検索結果を事前計算するシャドウテーブルを作成し、画面に見える行と列だけを一定サイズのDOMでレンダリングした
- スクロール経路にはパッシブイベントリスナー、
requestAnimationFrame、バッファ・ヒステリシス、速度追跡を適用し、レイアウトプロパティの代わりに**transformとopacity**を活用してメインスレッドの作業を減らした - セルごとのアイコンを共有SVG背景画像に置き換え、エディタは必要なときだけマウントし、行と列を位置基準で追跡するDOMプーリングによってスクロール中のノード生成をなくした
- CanvasはDOMより高い60fps性能上限を提供するが、テキスト・選択・アクセシビリティ・機能拡張で不利なため、実際のテキスト選択と高速な開発を維持するDOMベース設計を選択した
目標と初期制約
- 単純な2次元配列とネストしたループから始めたが、約1年にわたる断続的な最適化作業へとつながった
- データベースGUIの表は表示専用ではなく、さまざまな状態と相互作用をサポートする必要があった
- MongoDBのすべてのBSON型とPostgreSQLなどのJSONBを理解し、型ごとの色付きアイコンを表示する
- 文字列
"123"と整数123のように、クエリ結果が変わる型を区別する - ネストしたドキュメントを実際の子列として展開し、ネストパス全体を検索してセル内の一致部分を強調表示する
- 列の並べ替え・サイズ変更・固定、セル内編集、値・行・列を視覚的クエリビルダーへドラッグする機能が必要だった
- こうした機能の状態は元のドキュメントには存在せず、スクロール後も維持される必要があるため、別個のレンダリング構造が必要だった
1段階: すべての項目を直接レンダリングする
- 行とフィールドをネストして走査し、すべてのセルを作る方式は100行では動作するが、大規模データでは破綻する
- 10,000行 × 30列では約300,000個のDOMノードが作られ、フレームワークの変更検知がそれらを繰り返し走査する
- DOMノード1つがブラウザ内部構造まで含めて約1KBを消費すると考えると、実データより前に数百MBが必要になる
- 60fpsのフレーム予算は16.7msであり、スタイル・レイアウト・ペイントもこの時間を共有する
- 実際の実装では、約20列を持つ1,000行をレンダリングしようとして失敗した
- 一部だけをレンダリングするには、現在見えている行と列、順序、ネストフィールドの展開、検索結果を別途追跡する必要がある
2段階: シャドウテーブルで表示状態を分離する
- 元のドキュメントはネストしていて型もばらばらなため、レンダリング入力として使いにくい
- MongoDBにはObjectId・Decimal128・タイムスタンプ・バイナリのようなBSON値がある
- SQLデータにはJSONBやタイムゾーン付きタイムスタンプがあり、セルごとに形式決定が必要になる
- レンダリングループで形式を判断すると毎フレーム同じコストが繰り返され、元データには列順・展開状態・検索結果のような表の状態もない
- シャドウテーブルは元のドキュメントを変更せずに、実際の表が表示する状態の基準点として機能する
- 読み込み時に一度構築し、状態が変わるたびに更新し、スクロール中には変更しない
- セルごとに切り詰めた表示文字列、確定した型、フラット化されたパスを事前計算する
- 表示文字列を制限し、16MBのドキュメントがレンダリング状態に16MBの文字列をそのまま生成しないようにする
- 型はアイコン・エディタ・検索方法を決定する
"address.geo.lat"のようなフラット化されたパスをキーとして使い、毎回ツリーを走査しない
- ネストしたオブジェクトを展開すると、子パスが実際の列へ昇格し、ソート・検索結果・列順・展開状態も同じ構造に保存される
- この段階ではDOM数は減らないが、その後の列順と幅を高速に計算する基盤が整う
3段階: 垂直仮想化とゴーストスクロール領域
- 垂直仮想化では、ビューポート内の行と少量のバッファだけをレンダリングし、残りの高さは偽の領域にする
- ゴースト領域(phantom) は
rowCount × rowHeight高さを持つ内部コンテナである- 100万行 × 40pxなら、中身がほとんどない4,000万px高さの
divが作られる - ブラウザはこの高さを基準にネイティブスクロールバーとスクロール動作を提供する
- 100万行 × 40pxなら、中身がほとんどない4,000万px高さの
- 表示範囲は次の計算で求める
firstRow = floor(scrollTop / rowHeight)lastRow = floor((scrollTop + viewportHeight) / rowHeight)- 小さな移動のたびに再レンダリングしないよう、両側にバッファ行を追加する
- 表示行は
firstRow × rowHeight位置のスラブ(slab) コンテナに配置し、topよりtransformを使うほうが有利である - ユーザーには100万行が見えているが、DOMには約40行しか存在しない
- しかし300列なら、40行だけでも12,000個のセルが生まれるため、列も仮想化しなければならない
4段階: 可変幅を扱う水平仮想化
- ドキュメントデータベースのコレクションには数百のフィールドが生まれうるため、列仮想化も必要である
- 列幅が一定ではないので、固定値の割り算ではなく累積和と二分探索を使う
position[n] = width[0] + ... + width[n-1]という形の累積和で、各列のx座標を1回の配列参照で得る- スクロールオフセット
xに対応する列は、累積和配列を二分探索して見つける - 列が1,000本あっても探索はマイクロ秒単位で終わる
- 累積和は列サイズ変更・非表示・並べ替えのように幅が実際に変わるときだけ再構築し、スクロール中には作らない
- 左右に約200pxバッファを設け、次の列が画面に現れる前にあらかじめレンダリングする
- 両軸仮想化後、レンダリング面積はデータサイズに関係なく約40行 × 12列に保たれる
- 500列のコレクションでも一度に約12列しか存在しないため、ロード時間は増えなかった
- レンダリング対象は減ったが、1秒あたり数百回発生するスクロールイベントごとに範囲を再計算する問題が残った
5段階: スクロール経路のフレーム予算管理
- スクロール処理はスタイル・レイアウト・ペイントと16.7msのフレーム予算を共有するため、最小限の作業しか行ってはならない
- パッシブイベントリスナーをフレームワークの変更検知の外で登録する
preventDefaultを呼ばないことをブラウザに伝え、コンポジタがJavaScriptを待たずにピクセルを移動できるようにする- スクロールイベント自体がフレームワークのレンダリング検査を引き起こさない
- 複数イベントをフレームごとに1回へまとめる
- 最新のスクロール位置だけを記録し、
requestAnimationFrameコールバックを1つ予約する - 1フレームに発生した12個のイベントも、1回の範囲計算で処理される
- 最新のスクロール位置だけを記録し、
- Handsontableのソースから得た高速描画終了を適用する
- 新しい表示範囲が既存のレンダリングバッファ内にあれば、整数比較を2回するだけですぐに返る
- バッファ境界にはヒステリシスを適用する
- 表示範囲がバッファ端から約40px以内に近づいたら再構築する
- 約200pxのバッファを新しい位置中心に再配置し、空いた端が見えたり、境界で再構築が繰り返されたりするのを防ぐ
- イベント間のpx/msを追跡する速度検出器を追加する
- 実装では10px/msを超えると高速フリックと判断してレンダリングを停止する
- ネイティブスクロールがゴースト領域上を移動するようにし、速度が安定したらスラブを再び埋める
- JavaScript作業を減らした後もフレーム低下は残り、スタイル・ストライプ・アイコンを含むレイアウトとペイントが次のボトルネックになった
6段階: レイアウトプロパティのコストを除去する
- ブラウザのメインスレッドはスタイル・レイアウト・ペイントを担当し、コンポジタはすでに描画されたレイヤーをGPU上で移動する
- アニメーションをコンポジタで処理できるプロパティは**
transformとopacity**であり、top・left・width・height・background-colorなどはメインスレッドを起こす - 行番号と固定列パネルのスクロール同期で
topを更新すると、1秒あたり60回の強制レイアウトが発生した- これを
translate3dに置き換え、同じ見た目を保ったままメインスレッドのコストをなくした
- これを
- 行ごとの背景で実装していたストライプは、本体全体の
repeating-linear-gradientひとつに変更した- 行高はCSS変数で渡す
- ブラウザは2行分サイズのタイルを1つだけラスタライズし、GPUテクスチャから繰り返しコピーする
- 行ごとのクラスバインディングがなくなり、本体と固定パネルが同じタイルを共有するため色の不一致も防げる
- 列の区切り線は4,000万px高さのゴースト領域全体ではなく、現在レンダリングされたスラブの高さにだけ描画する
- 通常スクロールは滑らかになったが、新しいセルを作るウィンドウ切り替え時には、セルごとに不要なDOMが蓄積してコストが残った
7段階: アイコンとエディタの軽量化
- データベースグリッドの型アイコンは装飾ではなく、ObjectId・文字列・整数・JSONBのような値の意味を区別する機能である
- 最初の実装ではセルごとにフォントアイコン要素を追加したが、追加DOMノードとグリフのテキストレンダリング経路が大きなコストを生んだ
- アイコンをセル自体の
background-imageへ移し、SVGデータURIとしてエンコードした- 同じ型のすべてのセルが同一のURI文字列を参照する
- ブラウザは型ごとのアイコンを1回だけラスタライズし、キャッシュされたGPUテクスチャから繰り返しコピーする
- 別個のDOMノードなしで反復的な装飾を表示できる
- セル編集には混合レンダリング方式を適用した
- 通常時のセルは普通のテキストと
span1つだけを使う - ダブルクリック時にだけ、重い型認識エディタコンポーネントをそのセル上にポータルのようにマウントする
- 通常時のセルは普通のテキストと
- すべてのセルにフレームワークコンポーネントを使うと、インスタンス生成コストが積み上がり、グリッドライブラリをセルレンダラーコンポーネントと組み合わせると性能が落ちることがある
- セル自体は軽くなったが、新しい列が1本現れたときにフレームワークが内容が同じ残りのセルまで作り直す問題が残った
8段階: 位置ベースのDOM再利用
- Angularの
trackBy、ReactやVueのkeyのような追跡基準は、ウィンドウ移動時に既存要素を再利用するか、破棄して作り直すかを決定する - 行が新しいデータに再バインドされず毎回置き換えられると、コンポーネント・DOMノード・イベントリスナーを何度も作り直さなければならない
- 行と列をデータ値ではなく画面上の位置で追跡する
- 同じ約40個の行要素と列要素が維持され、中身だけが新しい値に置き換わる
- グリッドはオブジェクトプールのように動作し、スクロール中に新しいDOMを割り当てない
- この段階で両軸スクロールとウィンドウ再構築コストは解決したが、列ドロップ時の1フレームの揺れや、行番号の半ピクセルずれのような仕上げの問題が残った
9段階: 細かな相互作用の仕上げ
- 列ドラッグ中は
transformで既存列を新しい順序のように移動し、ドロップ時に並べ替えとtransformの初期化を同じレンダリングパスで処理する- 最後のドラッグフレームと最初の並べ替えフレームをピクセル単位で同一にし、遷移が見えないようにする
- ツールチップはセルごとにバインドせず、コンテナの委譲hoverリスナー1つで処理する
- カーソル下のセルについてだけツールチップを計算する
- SQLのJSONB値を見やすく整形するような高コストの文字列生成も、実際のhover時点まで遅延させる
- 行番号列の1px境界線がデータ行とテキストのベースラインを半ピクセルずらし、スクロール中の揺れを生んでいた
- 両側の列が同じボックスモデルを使うよう修正した
- ストライプは再利用されるDOM位置ではなく絶対行インデックスを基準に決めるため、仮想化ウィンドウが変わっても色がちらつかない
- 独自グリッド構築を正当化した機能も維持した
- ネストしたドキュメントをJSON文字列のままにせず、実際の子列として展開する
- ネストパスの一致文字列をセル内で強調表示する
- 型付きの値をグリッドから視覚的クエリビルダーへ直接ドラッグする
- 一般的なグリッドライブラリにこうした機能を追加するコストは、レンダラーを自前で持つコストより大きかった
10段階: 既存の高性能グリッドのソースを読む
- フロントエンド性能学習では、他プロジェクトのソースコードを読むことが最も価値の高い習慣であり、文書化されていない最適化が公開リポジトリに残っていた
- 調査したDOMグリッドであるAG-Gridには、次の手法が適用されていた
- 明示的なフレームごとの時間予算と優先度付き作業キューでDOM作業を時間分割する
- 列ビューポートをハッシュ化し、変化のないスクロールを文字列比較1回で終える
- スクロール方向に沿って行を生成し、ユーザーが向かっている側の内容から先に表示する
- 新しいセルを先に生成し、既存セルの破棄を後ろへ回して、新しい内容が消える内容より先に描画されるようにする
- セルごとのイベントリスナーが測定されたボトルネックだったため、コンテナレベルのイベント委譲を使う
- CanvasベースのグリッドはDOMを介さず、レイアウトとスタイル再計算なしに、フレームごとに数百のテキストを再描画する
- 過度な操作下でも60fpsを維持できるため、DOMグリッドより性能上限が高い
- ピクセルグリッド外でラスタライズされたテキストはぼやけることがある
- 選択はセル単位でしか動作せず、省略記号も自分で描かなければ表示されない
- 新しいセル機能ごとに描画コードとヒット判定コードを追加しなければならない
- 最終的にDOMを選び、鮮明なテキスト、実際のテキスト選択、アクセシビリティ、高速な機能開発を維持した
- Canvas並みの滑らかさには届かなくても、必要なユーザー体験と開発コストを考慮した明示的なトレードオフである
まだコメントはありません。