大 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)];
}
三條紀律:
- 用固定樣例,不隨機構造,否則資料不可比;
- 至少跑 9 次取中位數,首次執行包含 JIT 預熱,要丟棄;
- 記錄執行環境(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 且只解析一次時,搬過去反而多付一次通訊成本。