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 层
日志被打满 整体打印响应 截断 + 脱敏