JSON 工具箱

大 JSON 的解析、渲染與流式處理

結論:解析往往不是瓶頸,渲染和複製才是。按「解析 → 遍歷 → 渲染 → 傳輸」四層拆開最佳化,才不會白費力氣。

進階約 13 分鐘發佈於 2026-10-30更新於 2026-10-31
大 JSON 的解析、渲染與流式處理封面

結論:先用 performance.now() 或 process.memoryUsage() 把解析、遍歷、渲染、傳輸四段的耗時逐段量出來,再按「減少複製 → 按需展開與虛擬滾動 → 搬進 Worker → 必要時串流解析 → 換 JSON Lines」的順序動手;只有檔案大到放不進記憶體,或只需要少數欄位時,才值得先上串流。

「JSON.parse 慢」這個結論十次裡有八次是錯的。真正慢的是解析完之後你做的事。

一、先量化,再最佳化

同一份 10MB JSON,在不同環節的耗時量級差別很大:

環節 典型耗時 主要成本
JSON.parse(原生) 幾十到一百多毫秒 單次線性掃描 + 建物件
深複製(structuredClone / 遞迴) 可能是解析的 1~3 倍 重新建整棵物件圖
深比較 / diff 可能再翻一倍 遞迴遍歷 + 字串比較
渲染成 DOM 樹 幾萬個節點時就卡住 佈局、重排
字串拼接再序列化 與輸出大小成正比 記憶體翻倍

所以在動手之前先測:在瀏覽器裡用 performance.now() 包住每一段,或用 console.time; 在 Node 裡用 process.memoryUsage() 看堆增長。沒有基線就沒有最佳化。

二、解析層:三種策略

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) {
  // 一次只處理一個元素,記憶體與元素數量無關
  handleItem(value);
}

代價:

  • 程式碼複雜度顯著上升,錯誤處理更麻煩;
  • 只能順序訪問,不能隨機取;
  • 對「需要全域性聚合」的需求不友好。

判斷標準:檔案大到裝不下,或者只需要其中很小一部分欄位,才值得上流式。

2.3 換格式

如果資料是「一批記錄」而不是「一個文件」,那麼一開始就該用 JSON Lines。 行分隔格式讓「流式」變成「按行讀」,實現複雜度低一個數量級。

三、遍歷層:少做一次複製就贏一半

// 反例:為了「安全」先深複製
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)對大物件做遞迴代理,開銷可能比解析還高。

實踐建議:把「原始資料」保持為不可變的普通物件,不做響應式包裝; 需要展示的派生資料單獨計算並快取。

四、渲染層:別把整棵樹塞進 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)),使用者點開某一條才去取具體值。 這一招對「我就想確認欄位對不對」的場景最有效。

五、主執行緒:該搬到 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 預設走結構化克隆,10MB 物件克隆一次也是幾十毫秒。 如果只是「解析完給主執行緒用」,可以只在 Worker 裡做重活(解析 + 轉換 + 校驗),把結果摘要傳回來。

六、傳輸層:體積最佳化

手段 典型收益 代價
gzip / brotli 壓縮後常為原體積的 10%~25% 需要服務端支援
去掉縮排與空格 減少 20%~40% 不可讀
欄位名縮寫 深度巢狀時收益明顯 可讀性下降,需要對映表
數字代替列舉字串 小 需要字典
分頁 / 按需欄位 最大 介面改造

對「給人看」的頁面,正確的組合是:介面傳壓縮後的緊湊 JSON,展示時才美化。 很多工具站預設把美化後的文字再傳給前端,白白多傳 30%。

七、基準測試怎麼做

/**
 * 測量一段同步邏輯的中位耗時,避免單次抖動干擾判斷。
 * @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),否則跨機器對比毫無意義。

八、檢查清單

  • [ ] 有基線資料,最佳化前後可對比;
  • [ ] 超過 1MB 的解析放到 Worker;
  • [ ] 樹形檢視按需展開,列表用虛擬滾動;
  • [ ] 不做無意義的深複製與響應式包裝;
  • [ ] 資料是「一批記錄」時用 JSON Lines;
  • [ ] 介面傳緊湊 JSON,展示時才格式化。

常見問題

如何判斷瓶頸落在解析、渲染還是傳輸?

在瀏覽器裡用 performance.now() 或 console.time 包住每一段,在 Node 裡用 process.memoryUsage() 看堆增長,先拿到基線再動手。再看主執行緒:單段佔用超過 50ms,滾動和輸入就會掉影格;渲染成 DOM 樹在幾萬個節點時就會卡住,那時瓶頸已經不在 JSON.parse。三段分開記,才知道該最佳化哪一層。

為什麼 JSON.parse 通常不是最慢的一環?

原生實現由 C++ 完成掃描與建物件,10MB 文字一般幾十到一百多毫秒,純 JS 庫很難超過它。真正貴的是解析之後的事:深複製可能是解析的 1~3 倍,深比較再翻一倍,把幾萬個節點渲染成 DOM 會直接卡住。所以「JSON.parse 慢」這個判斷十次裡有八次是錯的。

是否應該把所有 JSON 處理都搬進 Worker?

超過 1MB 的解析、格式化、diff 都應該放進 Worker,但 Worker 不是免費的:postMessage 預設走結構化複製,10MB 物件複製一次也是幾十毫秒。更穩的做法是只在 Worker 裡做重活(解析 + 轉換 + 校驗),把結果摘要傳回主執行緒;小於 1MB 且只解析一次時,搬過去反而多付一次通訊成本。