大きな JSON ツリーがカクつく理由:仮想スクロールと折りたたみ戦略
要点:カクつきはスクロール時ではなく DOM ノードの生成時に始まります。折りたたみ優先の描画、仮想リスト、検索ハイライト、Worker の境界を指標とともに扱います。

要点:「遅い」をパース、ノード生成、レイアウトと描画、操作応答に分け、各段階を測りましょう。実装はこの順です。既定で折りたたみ、仮想リストを追加、計算を Worker へ移し、ハイライトを可視行に限定します。折りたたみなしでいきなり仮想リストに飛ぶと可視集合が縮むだけで、ツリー全体の再計算コストは残ります。
カクつきは通常、その 5MB そのものではなく、5MB のために作った数十万の DOM ノードから来ます。
1. 「遅い」を四つの段階に分ける
ユーザーが「開くと固まる」と言うとき、問題はまったく異なる四つの段階にあり、それぞれ処方が違います。
| 段階 | 典型的な引き金 | 知覚できるしきい値 | 支配的なコスト |
|---|---|---|---|
| パースとオブジェクト構築 | ファイルを開く、テキストを貼る | 50ms を超えるあらゆるタスク | 文字列の走査、オブジェクトグラフの構築 |
| DOM ノードの生成 | ツリー全体を一度に描画 | 数万ノード | 要素、テキストノード、スタイルオブジェクトの割り当て |
| レイアウトと描画 | 初回表示、スクロール | 16.7ms を超えるあらゆるフレーム | ジオメトリ、リフロー、再描画 |
| 操作応答 | 展開、折りたたみ、検索 | 100ms 超で遅れて感じる | 再平坦化と再描画 |
ブラウザのメインスレッドは一度に一つしか動きません。どの段階でも 50ms を超えて占有すると、スクロールと入力がフレームを落とします。そのようなタスクが続けば、ページは死んで見えます。最初の最適化は requestAnimationFrame ではなく、どの段階が実際に時間を使っているかを測ることです。
2. ノード数が本当の敵
ツリービューアは設計上ノード数を増やします。JSON の 1 行は通常「インデント+キー+コロン+値+カンマ+シェブロン」になるので、保守的な見積もりで 1 行あたり 3〜6 個の DOM ノードです。
| ファイルサイズ | おおよその行数 | ツリービューの DOM ノード(推定) | 一度に描画すると |
|---|---|---|---|
| 100 KB | 2,500 | 10,000 | 通常は許容 |
| 1 MB | 25,000 | 100,000 | 明らかにカクつき、数秒の空白画面もあり |
| 5 MB | 125,000 | 500,000 | 応答不能になる可能性が高い |
| 20 MB | 500,000 | 200 万超 | メモリもレイアウトも崩壊 |
各要素は数十バイトのオブジェクト以上のものです。スタイル規則、レイアウトボックス、描画命令を持ちます。一度生成されたノードは、後で折りたたまれた親に隠されても、実際に削除されていなければレイアウトとスタイルの仕事を負い続けます。「すべて描画してから隠す」は最適化ではなく、コストを次のリフローに先送りするだけです。
3. まず折りたたみ、次に仮想化
仮想スクロールは「可視行が多すぎる」を解き、折りたたみは「そもそもこれほど多くの行を見る必要がない」を解きます。順序が重要です。既定で展開された百万行の文書では、完璧な仮想リストでもユーザーが「すべて折りたたむ」を押したときにツリー全体を再計算しなければなりません。
実用的な折りたたみ戦略:
- 既定では最初の二階層だけを展開する。 ルートとその直接のフィールドが見え、深い階層は
items: Array(12000)のような要約を表示します。 - 型と件数を要約に入れる。 「フィールド名が正しいか」を確認するユーザーは、一万レコードを必要としません。
- サブツリーごとに行をキャッシュする。 展開された各ノードは自身の平坦化された行配列を持ち、折りたたみで破棄し、展開ではそのサブツリーだけを再計算します。
- 切り替えのたびにツリー全体を再走査しない。 展開が 3,000 行目で起きるなら、その範囲だけを置き換え、周囲の行はそのまま保ちます。
/**
* ツリー全体を再平坦化するのではなく、親の後ろにサブツリーの行を挿入する。
* @param rows 現在の可視行配列
* @param parentIndex 親行のインデックス
* @param childRows サブツリーの平坦化された行
* @returns 新しい可視行配列
*/
function replaceSubtreeRows(
rows: TreeRow[],
parentIndex: number,
childRows: TreeRow[],
): TreeRow[] {
const nextRows = rows.slice();
nextRows.splice(parentIndex + 1, 0, ...childRows);
return nextRows;
}
避けるべきパターンは「一度の展開で一度の全体再レイアウト」です。クリックのたびに完全なオブジェクトグラフを再走査すると、コストは文書に線形に増えます。サブツリー単位のキャッシュなら、一回の展開はそのサブツリー分のコストで済みます。
4. 仮想リスト:ビューポートだけを描画する
折りたたみで行数が減っても、残りは数万行になりえます。仮想リストはコンテナの高さを「総行数 × 固定行高」にし、ビューポートとその上下の小さなバッファ分だけ要素を生成します。
const ROW_HEIGHT = 24;
const OVERSCAN = 8;
/**
* ビューポートが描画すべき行範囲を計算する。
* @param rows 可視行すべて
* @param scrollTop コンテナの現在のスクロール位置
* @param viewportHeight コンテナの可視高さ
* @returns 描画する行、開始インデックス、垂直オフセット
*/
function getViewportRows(
rows: TreeRow[],
scrollTop: number,
viewportHeight: number,
): { visibleRows: TreeRow[]; startIndex: number; offsetTop: number } {
const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
const end = Math.min(
rows.length,
Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + OVERSCAN,
);
const visibleNodes = rows.slice(start, end);
return {
visibleRows: visibleNodes,
startIndex: start,
offsetTop: start * ROW_HEIGHT,
};
}
譲れない制約:
- 行の高さは固定でなければならない。 そうして初めて
scrollTop / ROW_HEIGHTが直接インデックスを与えます。折り返し行を許すとすべての高さを測ってキャッシュする必要があり、複雑さと揺らぎが一桁増します。 transformか絶対配置でオフセットする。 スペーサー要素で内容を押し下げないでください。スクロールのたびにレイアウトが変わります。- 安定したキーを使う。 行は配列インデックスではなくパスでキー付けします。さもないと一行の展開がそれ以降のすべての行を再マウントします。
- スクロールハンドラでは算術だけをする。 描画は
requestAnimationFrameに遅延し、重複スケジュールを防ぎます。 - バッファを小さく保つ。 上下 5〜10 行で十分です。バッファを倍にすると DOM ノードとスタイル作業も倍になります。
800 ピクセルのビューポートと 24 ピクセルの行なら、可視はおよそ 34 行、バッファを含めても 50 行程度を描画します。下にある文書は 10 万行を保持できますが、DOM は一貫して数百要素に保たれ、レイアウトコストは平坦なままです。
5. 折りたたみ、展開、スクロールのアンカリング
行インデックスが唯一の座標になると、いくつかの操作の詳細が壊れ始めます。
- ビューポートより上で折りたたむとき、視覚的なアンカーを保つ。 下の行が上へずれるので、同じコミットで
scrollTopを調整しなければ内容が飛んで見えます。アンカー行のパスを記録し、新しいインデックスでスクロール位置を復元します。 - ジャンプ前に祖先を展開する。 検索が深いフィールドに当たったら、まずすべての親を展開し、次に対象行のインデックスを計算し、それからスクロールしてハイライトします。最初の手順を飛ばすと対象行は可視配列にすら入っていません。
- キーボードナビゲーションは可視行から駆動する。 上下は行インデックスを動かし、左右は展開と折りたたみです。キーハンドラ内でツリー全体を歩かないでください。
- 更新を一フレームにまとめる。 複数階層を続けて展開するときは配列の変更をマージして一度だけ描画します。さもないと各階層が完全なリフローを起こします。
6. 検索とハイライト:一度計算し、少しだけ表示する
検索はツリービューアで最も見落とされる性能の罠です。打鍵のたびにオブジェクトグラフ全体を歩いて結果リストを再構築すると、10 万ノードの文書は即座に固まります。
階層的な方法:
- 入力を 150〜250ms デバウンスし、各検索にシーケンス番号を付け、古い結果を捨てる。
- Worker で走査する。 オブジェクトグラフの走査は DOM に依存しない純粋な計算なので、入力をブロックすべきではありません。
- パスと行インデックスだけを保存する。 一致したノードをすべてコピーしたり、ハイライト済み HTML を事前構築したりしない。
- 可視行だけをハイライトする。 ビューポートは数十行なので、描画時に一致パスでスタイルを適用します。
- ジャンプには「祖先を展開してからインデックスを特定」を再利用する。シェブロンのクリックと同じ経路です。
セキュリティ上の詳細が一つ。ツリービューアのキーと値はユーザーデータです。そこからハイライト済み HTML を構築するにはエスケープが必要で、さもないとマークアップを含む文字列が XSS になります。JSON ビューアにリッチテキストは不要なので、テキストノードとクラス名の方が安全かつ高速です。
7. シンタックスハイライト:CodeMirror と Monaco は別の道具
ツリービューとテキストエディタは描画モデルが異なるので、「どちらが速いか」は直接比較できません。
| 観点 | CodeMirror 6 | Monaco Editor |
|---|---|---|
| 位置づけ | モジュール式エディタコア | 完全な IDE エディタ |
| 描画 | ビューポートベース | ビューポートベースだがモデルと装飾が重い |
| パース | 変更範囲に対する Lezer の増分パース | Monarch トークナイザ、行単位 |
| 大きなファイル | 拡張に依存。既定では軽量 | 機能完備。メモリと起動コストが高い |
| 折りたたみと検索 | 別の拡張。オンデマンド読み込み | 組み込み。常時のオーバーヘッド |
| 典型的な落とし穴 | 拡張を入れすぎるとコストが戻る | ミニマップ、スティッキースクロール、積み重ねた装飾 |
実践的な結論は二つ:
- ツリービューに完全なトークナイザは不要。 キー、文字列、数値、真偽値にそれぞれ一色を、素のテキストノードと CSS クラスで与えれば、シンタックスハイライトを動かすより一桁安上がりです。
- ツリービューとエディタを同時にマウントしない。 一つの文書に二つのモデルを保つとメモリが倍になります。古いビューは隠すのではなく破棄してください。
エディタが本当にビューア内に属するなら、CodeMirror 6 は「読み取り専用、大きなファイル、ローカルハイライト」に、Monaco は補完、診断、マルチカーソル編集に向きます。機能一覧を読む前に、初回描画時間と常駐メモリを測ってください。エディタと CLI ツールのより深い比較はエディタ、コマンドラインツール、自動化を参照してください。
8. Worker の境界:パースと行構築をメインスレッドから移す
描画ジャンクのもう半分はメインスレッドでのパースです。1MB を超える JSON では、パース、整形、平坦行の構築は Web Worker に属し、メインスレッドは行を受け取ってビューポートを描画するだけにします。
Worker は無料ではありません。postMessage は structured clone を使うので、50MB のオブジェクトグラフをメインスレッドに送り返すと二つ目のコピーが作られ、ピークが倍になります。正しい境界は、Worker 内でパースと平坦化を行い、描画が必要とする行だけを返すことです。
worker.onmessage = (event: MessageEvent<{ rows: TreeRow[] }>) => {
rows = event.data.rows;
requestAnimationFrame(() => renderViewport());
};
worker.postMessage({ text });
行データがなお大きいなら、行を並列配列(path、type、value offset)に圧縮し、ArrayBuffer に入れて所有権を移し、メインスレッドがコピーのコストを払わないようにします。代償は自前のシリアライザを書くことで、数百万行のときだけ価値があります。
境界の規則は単純です。メインスレッドは DOM に触れなければならない仕事だけを行うべきです。 パース、検索、並び替え、整形、diff はすべて移せます。描画、スクロール、入力、フォーカスはメインスレッドに残り、すべてのフレームが仕事の予算に収まらなければなりません。
9. 指標と実装順
最適化は「速く感じる」で止まれません。次の集合はそのまま受け入れチェックリストにできます。
| 指標 | 目標 | 測り方 |
|---|---|---|
| 最初の可視フレーム | 300ms 未満 | 開いてから初回描画までの performance.now() |
| ロングタスク | 50ms 超なし | PerformanceObserver で longtask を監視 |
| スクロールのフレーム時間 | 16.7ms 未満で安定 | DevTools の Performance 記録 |
| ビューポート内の DOM 要素 | 2,000 未満 | document.querySelectorAll('*').length |
| 展開 / 折りたたみの応答 | 50ms 未満 | 操作前後のタイムスタンプ |
| 10 万行の検索 | 300ms 未満、Worker 内 | Worker 内で時間を測り、所要時間を返す |
| 常駐ヒープ | テキストサイズの 3 倍未満 | DevTools Memory のヒープスナップショット |
均一に生成した小さなオブジェクトではなく、実際のファイルで測ってください。データの形が重要で、長い文字列、深いネスト、非常に幅の広い配列はそれぞれ異なる曲線を生みます。オブジェクトグラフの台帳自体はJSON のメモリ台帳を参照してください。
実装の順序:
- 基準値を測る:初回描画時間、ノード数、スクロールのフレーム時間。
- 既定で折りたたむ:二階層を展開し、要約に型と件数を表示。
- 仮想リスト:固定行高、安定したキー、ビューポートと小さなバッファ。
- 計算を移す:パース、検索、平坦化を Worker へ。
- ハイライトを制約する:可視行だけにスタイルを当て、ツリー全体の再描画を避ける。
- ガードレールを足す:しきい値を超えたら「最初の N 行を表示」を提供するか、検索フィルタを要求する。
すべての手順は一つの目標を共有します。同じ瞬間に存在しなければならないノードとオブジェクトを減らすこと。ブラウザにそれらをより強く働かせることではありません。階層化戦略の完全な背景は大きな JSON のパース、描画、ストリーミングにあります。
仮想スクロールはパース時のカクつきを直せますか?
いいえ。仮想スクロールは DOM ノードを減らすだけです。パースがまだメインスレッドで起きているなら、ページは依然カクつきます。1MB を超える文書ではパースを Worker に移し、二つの問題を別々に扱ってください。
すべて展開していて、それでもカクつきます。何が悪いのですか?
仮想リストが実際には有効でないか、展開のたびにツリー全体を再走査しています。画面外の行がなお DOM を生成していないか、展開が影響するサブツリーだけを再計算しているか確認してください。
JSON ビューアには CodeMirror か Monaco か?
読み取り専用、大きなファイル、ローカルハイライトには CodeMirror 6 を優先してください。モジュール式でオンデマンド読み込みが軽いからです。補完、診断、マルチカーソル編集が必要なら Monaco を選びます。どちらでも、同じ文書に対してツリービューとエディタを同時にマウントしないでください。
検索ジャンプ後にスクロール位置がずれるのはなぜですか?
通常、展開する前にスクロールしてしまうからです。まずすべての祖先を展開し、次に新しい行インデックスでスクロール位置を設定し、最後にハイライトします。行はインデックスではなくパスでキー付けし、展開がすべてのインデックスをずらさないようにしてください。
FAQ
カクつきがどの段階から来るかを知るには?
「遅い」をパースとオブジェクト構築、DOM ノード生成、レイアウトと描画、操作応答に分け、開いてから初回描画までを performance.now() で各段階を計時します。次に PerformanceObserver で longtask を監視します。50ms を超えるタスクはフレームを落とし、DevTools の Performance 記録でスクロールのフレーム時間が 16.7ms 未満に保たれているか分かります。
ツリー全体を一度に描画すると画面が空白になるのはなぜですか?
5MB の文書はおよそ 125,000 行で、ツリービューは通常 1 行を 3〜6 個の DOM ノードにするので、一度に約 500,000 要素です。各要素はスタイル規則、レイアウトボックス、描画命令も持つので、50ms 超のメインスレッドタスクが続くとページは死んで見えます。折りたたまれた親に隠されたノードもレイアウト作業を負い続けるので、「すべて描画してから隠す」は最適化ではありません。
50MB のオブジェクトグラフを Worker から返すべきですか?
いいえ。postMessage は structured clone を使うので、受信側が二つ目のコピーを作り、ピークが倍になります。正しい境界は Worker 内でパースと平坦化を行い、描画が必要とする行だけを返すことです。数百万行なら並列配列(path、type、value offset)を ArrayBuffer に詰めて所有権を移せます。