大きな 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)];
}
規律の三つの規則:
- 固定のサンプルを使い、データをランダムに生成しない。さもないと数値が比較できない。
- 少なくとも 9 回実行して中央値を取る。初回は JIT のウォームアップを含むので捨てなければならない。
- 実行環境(Node のバージョン、CPU)を記録する。さもないとマシンをまたぐ比較は無意味。
8. チェックリスト
- [ ] 前後を比較できる基準値がある。
- [ ] 1MB を超えるパースは Worker で動く。
- [ ] ツリービューはオンデマンド展開し、リストは仮想スクロールを使う。
- [ ] 無意味な深いコピーやリアクティブ包みがない。
- [ ] データが「レコードの集まり」なら JSON Lines を使う。
- [ ] API はコンパクトな JSON を送り、整形は表示のためだけに行う。