效能與安全常見問題:慢、卡與不可信資料
結論:幾十 MB 的 JSON 為什麼打不開、深複製有多貴、__proto__ 汙染怎麼防、XSS 從哪裡進來,十條急救結論按症狀排列。

結論:先按症狀判斷瓶頸在解析、渲染還是記憶體,再依次做「減少資料量 → 避免深複製 → Worker + 虛擬滾動 → 流式解析」;處理不可信資料時先限制大小與巢狀深度,再過濾 __proto__、constructor、prototype 三個鍵。
「JSON 很慢」這個說法十次有九次不準確,慢的通常是渲染、深複製或者錯誤的處理順序。
一、為什麼會慢
大 JSON 檔案解析很慢,該怎麼最佳化?
先定位瓶頸,再談最佳化。三個環節的耗時差好幾個數量級:
| 環節 | 10 MB 資料的大致耗時 | 瓶頸原因 |
|---|---|---|
| 網路傳輸 | 取決於頻寬 | 未壓縮的 JSON 通常能壓到 1/5 |
JSON.parse |
20–80 ms | 原生實現,已經很快 |
| 遞迴遍歷/深複製 | 200–2000 ms | 純 JS 逐節點處理 |
| DOM 渲染(樹形檢視) | 秒級,容易卡死 | 節點數量爆炸 |
所以最佳化順序應該是:
- 減少資料量:讓介面分頁、只返回需要的欄位、開啟 gzip/brotli;
- 避免深複製:見下一節,這是最常見的隱形開銷;
- 懶渲染:樹形檢視只渲染展開的層級,而不是一次性生成幾萬個 DOM 節點;
- 流式處理:資料超過 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 也要幾百毫秒到幾秒, 後續每次滾動都會觸發重排。
正確做法:
- 虛擬滾動:只渲染視口內的幾十行,滾動時複用 DOM;
- 預設摺疊:只展開前兩層,深層按需渲染;
- 限制渲染規模:超過 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 這類操作。
防禦三條:
- 過濾鍵名:合併時跳過
__proto__、constructor、prototype; - 用
Object.create(null)作為容器,沒有原型鏈可汙染; - 用
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 需要注意什麼?
按這個順序做,成本低、收益高:
- 限制大小:超過閾值直接拒絕,不要先讀進記憶體再判斷;
- 限制深度:巢狀幾百層的 JSON 能讓遞迴解析棧溢位;
- 限制鍵數量:防止雜湊碰撞攻擊與記憶體放大;
- 過濾危險鍵:
__proto__、constructor、prototype; - 校驗結構:過一遍 Schema,確認型別與必填欄位;
- 不信任
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 有什麼風險?
三個:
- 敏感資訊落盤:手機號、身份證、token、密碼明文進了日誌系統,日誌往往許可權更松、留存更久;
- 日誌注入:JSON 值裡含換行與偽造的日誌字首,會把一條記錄拆成多條,汙染審計;
- 體積爆炸:把整個 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 層 |
| 日誌被打滿 | 整體列印響應 | 截斷 + 脫敏 |