JSON 工具箱

效能與安全常見問題:慢、卡與不可信資料

結論:幾十 MB 的 JSON 為什麼打不開、深複製有多貴、__proto__ 汙染怎麼防、XSS 從哪裡進來,十條急救結論按症狀排列。

進階約 13 分鐘發佈於 2026-10-30更新於 2026-10-30
效能與安全常見問題封面

結論:先按症狀判斷瓶頸在解析、渲染還是記憶體,再依次做「減少資料量 → 避免深複製 → Worker + 虛擬滾動 → 流式解析」;處理不可信資料時先限制大小與巢狀深度,再過濾 __proto__、constructor、prototype 三個鍵。

「JSON 很慢」這個說法十次有九次不準確,慢的通常是渲染、深複製或者錯誤的處理順序。

一、為什麼會慢

大 JSON 檔案解析很慢,該怎麼最佳化?

先定位瓶頸,再談最佳化。三個環節的耗時差好幾個數量級:

環節 10 MB 資料的大致耗時 瓶頸原因
網路傳輸 取決於頻寬 未壓縮的 JSON 通常能壓到 1/5
JSON.parse 20–80 ms 原生實現,已經很快
遞迴遍歷/深複製 200–2000 ms 純 JS 逐節點處理
DOM 渲染(樹形檢視) 秒級,容易卡死 節點數量爆炸

所以最佳化順序應該是:

  1. 減少資料量:讓介面分頁、只返回需要的欄位、開啟 gzip/brotli;
  2. 避免深複製:見下一節,這是最常見的隱形開銷;
  3. 懶渲染:樹形檢視只渲染展開的層級,而不是一次性生成幾萬個 DOM 節點;
  4. 流式處理:資料超過 10 MB 時不要整體解析。

如果瓶頸真的在 JSON.parse,那說明資料量已經到幾十 MB 以上,正確做法是換流式解析器:

import { parser } from 'stream-json';
import { streamArray } from 'stream-json/streamers/StreamArray';

// 逐條處理陣列元素,記憶體佔用與總量無關
const pipeline = fs.createReadStream('big.json')
  .pipe(parser())
  .pipe(streamArray());

pipeline.on('data', ({ value }) => {
  processItem(value);
});

幾十 MB 的 JSON 在瀏覽器裡開啟就崩,怎麼辦?

先確認是「崩」還是「卡」。兩者的處理完全不同:

  • 卡:標籤頁提示「頁面無響應」,等幾十秒可能恢復。原因是主執行緒被長任務佔滿;
  • 崩:標籤頁直接變成「Aw, Snap」,通常是記憶體耗盡(OOM)。

不管哪種,都不要在主執行緒裡處理。用 Web Worker 把解析搬出去:

// worker.js
self.onmessage = (event) => {
  const parsed = JSON.parse(event.data);
  self.postMessage(parsed);
};

Worker 解決的是「介面不凍結」,解決不了記憶體上限。瀏覽器單標籤頁可用記憶體通常在 1–4 GB, 一個 200 MB 的 JSON 解析成物件後可能佔 1 GB 以上(每個鍵值對都有物件開銷), 再加上渲染,很容易觸頂。

真正的方案是根本不要整體載入:

資料量 方案
< 5 MB 直接 JSON.parse,注意渲染
5–50 MB Worker 解析 + 虛擬滾動
> 50 MB 流式解析,只保留需要的欄位
> 500 MB 換格式(Parquet / SQLite / 分片 JSON Lines)

JSON.parse(JSON.stringify(obj)) 是深複製嗎?

是深複製,但有幾處會「悄悄改資料」,不適合當通用複製工具。

const source = {
  date: new Date(),
  fn: () => {},
  undef: undefined,
  nan: NaN,
  map: new Map(),
  nested: { a: 1 },
};
JSON.parse(JSON.stringify(source));
// {
//   date: "2026-10-30T03:00:00.000Z",  // Date 變成字串
//   nan: null,                          // NaN 變成 null
//   nested: { a: 1 }                    // 只有這層是對的
// }
// fn 與 undef 消失,map 變成 {}

問題清單:

型別 結果
Date 變成 ISO 字串
NaN / Infinity 變成 null
undefined / 函式 / Symbol 物件裡消失,陣列裡變 null
Map / Set 變成 {}
BigInt 直接拋 TypeError
迴圈引用 直接拋 TypeError

現代瀏覽器用 structuredClone,它支援 Date、Map、Set、迴圈引用,但不支援函式:

const copy = structuredClone(source);

如果只是淺層的配置合併,用展開運算子最省:{ ...config, timeout: 3000 }。

為什麼樹形檢視展開特別卡?

因為每個節點都是一個 DOM 元素,節點數上萬時瀏覽器排版開銷爆炸。

一個 10 MB 的 JSON 可能包含 5–10 萬個葉子節點。即使只渲染可見區域,一次性構造 DOM 也要幾百毫秒到幾秒, 後續每次滾動都會觸發重排。

正確做法:

  1. 虛擬滾動:只渲染視口內的幾十行,滾動時複用 DOM;
  2. 預設摺疊:只展開前兩層,深層按需渲染;
  3. 限制渲染規模:超過 1 萬個節點時提示「資料過大,請用搜尋定位」,不要硬渲染。

這也是本站編輯器對大檔案做降級處理的原因——不是技術上渲染不出來,是渲染出來也沒法用。

二、為什麼會被攻擊

__proto__ 注入是怎麼回事?

原型汙染。攻擊者構造一個帶 __proto__ 鍵的 JSON,如果程式碼把它合併進物件而不做過濾, 就會改掉 Object.prototype,影響整個執行時的所有物件。

const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');

// 危險寫法:遞迴合併時不檢查鍵名
function merge(target, source) {
  for (const key in source) {
    if (typeof source[key] === 'object') {
      target[key] = target[key] || {};
      merge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

merge({}, payload);
({}).isAdmin;  // true —— 所有物件都被汙染了

關鍵事實:JSON.parse 本身不會汙染原型,{"__proto__": ...} 解析後是一個普通自有屬性。 危險的是解析之後的合併、賦值、Object.assign、lodash.merge 這類操作。

防禦三條:

  1. 過濾鍵名:合併時跳過 __proto__、constructor、prototype;
  2. 用 Object.create(null) 作為容器,沒有原型鏈可汙染;
  3. 用 Map 儲存不可信鍵值對。
function safeMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
      continue;
    }
    const value = source[key];
    if (value && typeof value === 'object' && !Array.isArray(value)) {
      target[key] = safeMerge(target[key] ?? {}, value);
    } else {
      target[key] = value;
    }
  }
  return target;
}

另外注意 for...in 會遍歷繼承屬性,用 Object.keys() 更安全。

解析不可信 JSON 需要注意什麼?

按這個順序做,成本低、收益高:

  1. 限制大小:超過閾值直接拒絕,不要先讀進記憶體再判斷;
  2. 限制深度:巢狀幾百層的 JSON 能讓遞迴解析棧溢位;
  3. 限制鍵數量:防止雜湊碰撞攻擊與記憶體放大;
  4. 過濾危險鍵:__proto__、constructor、prototype;
  5. 校驗結構:過一遍 Schema,確認型別與必填欄位;
  6. 不信任 Content-Type:宣告的型別和實際內容可能不一致。

深度檢查很便宜,可以放在解析前用正則粗篩,也可以解析後遍歷:

function measureDepth(value, depth = 0) {
  if (value === null || typeof value !== 'object') {
    return depth;
  }
  let maxDepth = depth;
  for (const key of Object.keys(value)) {
    maxDepth = Math.max(maxDepth, measureDepth(value[key], depth + 1));
  }
  return maxDepth;
}

但這段程式碼本身就是遞迴的,對惡意構造的超深資料仍可能棧溢位。穩妥做法是在解析階段就限制, 用支援深度選項的解析器(部分庫提供 maxDepth 引數)。

JSON 裡的 XSS 是怎麼發生的?

JSON 本身不是 HTML,不會直接執行。XSS 發生在「JSON 值被拼進 HTML」的環節。

三種常見路徑:

路徑 例子 防禦
服務端模板拼接 res.send('<div>' + data.name + '</div>') 輸出時 HTML 轉義
前端 innerHTML el.innerHTML = data.html 改用 textContent 或 sanitize
內聯指令碼嵌入 <script>var d = ${JSON.stringify(data)}</script> 轉義 <、>、&

第三種最隱蔽。JSON.stringify 不轉義 <,所以資料裡含 </script><script>alert(1)</script> 就能提前閉合指令碼標籤:

const data = { name: '</script><script>alert(1)</script>' };
// 輸出:<script>var d = {"name":"</script><script>alert(1)</script>"}</script>

修法是把危險字元轉成 Unicode 轉義:

function safeJsonForHtml(data) {
  return JSON.stringify(data)
    .replace(/</g, '\\u003c')
    .replace(/>/g, '\\u003e')
    .replace(/&/g, '\\u0026')
    .replace(/\u2028/g, '\\u2028')
    .replace(/\u2029/g, '\\u2029');
}

要不要限制巢狀深度?

要。這是投入產出比最高的防禦之一。

不限制的後果:遞迴函式棧溢位、JSON.parse 在某些實現上因深度過大而崩潰、 序列化時同樣遞迴爆棧。一個 1 KB 的 [[[[...]]]] 就能打掛沒有防護的解析器。

合理閾值按業務定:

資料型別 建議上限
普通業務物件 10–20 層
樹形結構(組織架構、目錄) 50–100 層
通用 API 閘道器 20 層

什麼是 JSON 劫持?

一個歷史漏洞:早期瀏覽器允許跨域 <script src> 載入 JSON 並執行,攻擊者透過重定義 Array 建構函式或 Object.prototype.__defineSetter__ 竊取資料。

現代的防護已經比較完善:

  • 瀏覽器不再允許用 __defineSetter__ 劫持陣列字面量;
  • 正確的 Content-Type: application/json 會阻止指令碼執行;
  • X-Content-Type-Options: nosniff 可以防止 MIME 嗅探。

但老系統仍有殘留風險。實踐建議:頂層只用物件,不要用裸陣列。 {"data": [...]} 這種包裝讓響應不是合法的 JavaScript 語句({ 會被當作程式碼塊), 從語法層面阻斷劫持。

日誌裡列印 JSON 有什麼風險?

三個:

  1. 敏感資訊落盤:手機號、身份證、token、密碼明文進了日誌系統,日誌往往許可權更松、留存更久;
  2. 日誌注入:JSON 值裡含換行與偽造的日誌字首,會把一條記錄拆成多條,汙染審計;
  3. 體積爆炸:把整個 10 MB 響應打進日誌,日誌系統很快就會被打滿。

做法:

// 打日誌前先脫敏與截斷
function safeLog(data) {
  const cloned = structuredClone(data);
  for (const key of ['password', 'token', 'idCard', 'phone']) {
    if (key in cloned) cloned[key] = '***';
  }
  const text = JSON.stringify(cloned);
  return text.length > 2000 ? text.slice(0, 2000) + '...(truncated)' : text;
}

結構化日誌系統(如 pino)本身會對換行做轉義,但脫敏與截斷仍要自己做。

三、速查

症狀 先查什麼 處理
解析慢 資料量、是否深複製 分段/流式、去掉 JSON.parse(JSON.stringify())
頁面卡死 主執行緒長任務、渲染節點數 Worker + 虛擬滾動
標籤頁崩潰 記憶體佔用 不要整體載入,改流式
深複製丟資料 Date、NaN、Map 用 structuredClone
物件憑空多出屬性 原型汙染 過濾 __proto__、用 Map
彈出指令碼 字串拼進 HTML 轉義 <、用 textContent
棧溢位 巢狀深度 限制 10–20 層
日誌被打滿 整體列印響應 截斷 + 脫敏