性能与安全常见问题:慢、卡与不可信数据
结论:几十 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 层 |
| 日志被打满 | 整体打印响应 | 截断 + 脱敏 |