JSON Toolkit

大きな JSON のパース、描画、ストリーミング

要点:パースがボトルネックでないことが多い。遅いのは描画とコピーです。「パース → 走査 → 描画 → 転送」の四層に沿って最適化し、労力を無駄にしないようにしましょう。

中級約 13 分で読めます2026-10-30 公開2026-10-31 更新
大きな JSON のパース、描画、ストリーミングのカバー画像

要点:まずパース、走査、描画、転送を別々に測り、次の順で手を打ちましょう。コピーを削り、仮想スクロールとオンデマンド展開で描画し、Worker に仕事を移し、ストリーミングに切り替え、それからようやく形式を変えます。ストリーミングは、ファイルがメモリに収まらないか、必要なフィールドがごく一部のときだけ価値があります。

「JSON.parse は遅い」という結論は十回中八回間違っています。本当に遅いのは、パース後にあなたがしていることです。

1. まず測る、それから最適化する

同じ 10MB の JSON ファイルでも、段階ごとに費やす時間は桁違いです。

段階 典型的な所要時間 主なコスト
JSON.parse(ネイティブ) 数十〜100 ミリ秒強 一度の線形走査とオブジェクト構築
深いコピー(structuredClone / 再帰) パースの 1〜3 倍になりうる オブジェクトグラフ全体の再構築
深い比較 / diff さらに倍になりうる 再帰走査と文字列比較
DOM ツリーへの描画 数万ノードで固まる レイアウトとリフロー
文字列連結後のシリアライズ 出力サイズに比例 メモリが二倍

ですから行動の前に測ります。ブラウザでは各段階を performance.now() や console.time で包み、 Node では process.memoryUsage() でヒープの増加を見ます。基準値がなければ最適化もありません。

2. パース層:三つの戦略

2.1 全体パース(既定の選択)

const data = JSON.parse(text);

ネイティブ実装が C++ で走査とオブジェクト構築を行うので、純 JS のライブラリが勝つのは難しいです。次の三条件が成り立つ限りこれを使います。

  • テキストが完全にメモリに収まる(通常 100MB 未満)。
  • パース後にほとんどのフィールドが実際に必要。
  • データの不信頼度が扱える範囲。

2.2 ストリーミングの増分パース

stream-json のような SAX 形式のパーサーなら、完全なオブジェクトツリーを構築せずに対象フィールドを走査できます。

import { createReadStream } from 'node:fs';
import { parser } from 'stream-json';
import { pick } from 'stream-json/filters/Pick';
import { streamArray } from 'stream-json/streamers/StreamArray';

const pipeline = createReadStream('huge.json')
  .pipe(parser())
  .pipe(pick({ filter: 'items' }))
  .pipe(streamArray());

for await (const { value } of pipeline) {
  // 1 要素ずつ処理する。メモリは要素数に依存しない
  handleItem(value);
}

コスト:

  • コードの複雑さが大幅に上がり、エラー処理が乱雑になる。
  • 逐次アクセスしかできず、ランダムアクセスがない。
  • 全体集計が必要な要件には不向き。

判断規則:ファイルが大きすぎて収まらない、または必要なフィールドがごく一部のときだけストリーミングが割に合います。

2.3 形式を変える

データが「単一の文書」ではなく「レコードの集まり」なら、最初から JSON Lines を使うべきでした。 行区切り形式は「ストリーミング」を「行ごとに読む」に変え、実装の複雑さを一桁下げます。

3. 走査層:コピーを一つ減らすだけで半勝負

// アンチパターン:「安全のため」先に深いコピーをする
const safeCopy = structuredClone(bigData);   // メモリが即座に二倍

// 良いパターン:読み取り専用の場面では参照をそのまま渡す
function renderSummary(data: OrderDocument): Summary {
  return { total: data.items.length, amount: data.total };
}

よくある隠れたコピー:

  • JSON.parse(JSON.stringify(x)) を「深いコピー」として使う。最も高コストな種類です。
  • { ...data } は一段しかコピーしませんが、大きなオブジェクトではトップレベル全キーの参照配列をコピーします。
  • Array.prototype.map は新しい配列を作り、要素が多いとこれも高コストです。
  • フレームワークのリアクティブプロキシ(Vue の reactive)は大きなオブジェクトを再帰的にプロキシし、オーバーヘッドがパース自体を超えることがあります。

実践的な助言:「生データ」は素の不変オブジェクトとして保ち、リアクティブで包まないでください。 表示に必要な派生データは別に計算してキャッシュします。

4. 描画層:ツリー全体を DOM に詰め込まない

5MB の JSON はおよそ 3 万〜10 万ノードを持ち、すべてを DOM 要素として描画するとブラウザが応答を止めます。

有効な三つの方法:

4.1 オンデマンド展開

ルートノードの直接の子だけを描画し、ノードが展開されたときにだけ次の階層を計算します。ツリービューアの標準的な方法です。

type TreeNode = { key: string; value: unknown; expanded: boolean };

/**
 * 現在見えているノードだけを返し、ツリー全体の一度の構築を避ける。
 * @param rootNode ルートノード
 * @param expandedPaths 展開済みパスの集合
 * @returns 可視ノードの一覧
 */
function collectVisibleNodes(rootNode: TreeNode, expandedPaths: Set<string>): TreeNode[] {
  const visibleNodes: TreeNode[] = [];

  /**
   * 展開された枝の子ノードを再帰的に集める。
   * @param currentNode 現在のノード
   * @param currentPath 現在のパス
   */
  const walk = (currentNode: TreeNode, currentPath: string): void => {
    visibleNodes.push(currentNode);
    if (!expandedPaths.has(currentPath) || typeof currentNode.value !== 'object' || currentNode.value === null) {
      return;
    }
    for (const [childKey, childValue] of Object.entries(currentNode.value)) {
      walk({ key: childKey, value: childValue, expanded: false }, `${currentPath}/${childKey}`);
    }
  };

  walk(rootNode, '');
  return visibleNodes;
}

4.2 仮想スクロール

展開ノードが数百を超えたら、ビューポート内の数十個だけを描画し、残りの空間はプレースホルダーの高さで確保します。 リスト形式のビュー(表、ログ)で最も効果的です。

4.3 要約だけを表示する

まず「構造+型+件数」(たとえば items: Array(12000))を示し、ユーザーが項目を開いたときだけ具体的な値を取得します。 この手法は「フィールドが正しいか確認したいだけ」の場面に最適です。

5. メインスレッド:必要なときは Worker に仕事を移す

1MB を超えるパース、整形、diff は Web Worker に入れるべきです。

// json.worker.ts
self.onmessage = (event: MessageEvent<{ text: string }>) => {
  try {
    const parsed = JSON.parse(event.data.text);
    self.postMessage({ ok: true, data: parsed });
  } catch (parseError) {
    self.postMessage({ ok: false, message: (parseError as Error).message });
  }
};
/**
 * Worker 内で JSON をパースし、メインスレッドをブロックしない。
 * @param text JSON テキスト
 * @returns パース結果
 */
function parseInWorker(text: string): Promise<unknown> {
  return new Promise((resolvePromise, rejectPromise) => {
    const worker = new Worker(new URL('./json.worker.ts', import.meta.url), { type: 'module' });
    worker.onmessage = (event) => {
      worker.terminate();
      if (event.data.ok) {
        resolvePromise(event.data.data);
      } else {
        rejectPromise(new Error(event.data.message));
      }
    };
    worker.postMessage({ text });
  });
}

Worker の通信コストに注意してください。postMessage は既定で structured clone を使い、10MB のオブジェクトを一度複製するのも数十ミリ秒かかります。 必要なのが「パースしてメインスレッドに渡す」だけなら、重い処理(パース+変換+検証)を Worker 内で行い、結果の要約を返してください。

6. 転送層:サイズ最適化

手法 典型的な利得 コスト
gzip / brotli 圧縮後の出力は元の 10%〜25% になることが多い サーバー側の対応が必要
インデントと空白の除去 20%〜40% の削減 人間には読めない
フィールド名の短縮 深いネストで目立つ利得 可読性の低下。マッピング表が必要
enum 文字列の代わりに数値 小さい 辞書が必要
ページネーション / フィールドのオンデマンド化 最大 API の再設計

人間向けのページでは、正しい組み合わせはこうです。API はコンパクトで圧縮された JSON を送り、UI は表示のためだけに整形する。 多くのツールサイトは既定で整形済みテキストをフロントエンドに再度送り、無駄に 30% 多いバイトを運んでいます。

7. ベンチマークの方法

/**
 * 同期処理の所要時間の中央値を測り、一度の揺らぎが結果を歪めないようにする。
 * @param runOnce テスト対象の関数
 * @param repeatCount 繰り返し回数
 * @returns ミリ秒単位の中央値
 */
function measureMedian(runOnce: () => void, repeatCount = 9): number {
  const durations: number[] = [];
  for (let roundIndex = 0; roundIndex < repeatCount; roundIndex += 1) {
    const startedAt = performance.now();
    runOnce();
    durations.push(performance.now() - startedAt);
  }
  durations.sort((firstValue, secondValue) => firstValue - secondValue);
  return durations[Math.floor(durations.length / 2)];
}

規律の三つの規則:

  1. 固定のサンプルを使い、データをランダムに生成しない。さもないと数値が比較できない。
  2. 少なくとも 9 回実行して中央値を取る。初回は JIT のウォームアップを含むので捨てなければならない。
  3. 実行環境(Node のバージョン、CPU)を記録する。さもないとマシンをまたぐ比較は無意味。

8. チェックリスト

  • [ ] 前後を比較できる基準値がある。
  • [ ] 1MB を超えるパースは Worker で動く。
  • [ ] ツリービューはオンデマンド展開し、リストは仮想スクロールを使う。
  • [ ] 無意味な深いコピーやリアクティブ包みがない。
  • [ ] データが「レコードの集まり」なら JSON Lines を使う。
  • [ ] API はコンパクトな JSON を送り、整形は表示のためだけに行う。