JSON.stringify 你不知道的隐藏细节

2025-11-12开发工具
分享到

曾遇到一个线上工单,用户反馈某个页面白屏了。打开控制台,报错信息是 TypeError: Converting circular structure to JSON。数据是后端返回的,结构不算复杂,但某个关联字段在特殊情况下形成了一个环。

这种问题好修,加个 safeStringify 包装一下就行。但真正引起我好奇的是另一件事:同样这段数据,JSON.stringify 之后,有些字段莫名其妙消失了。再仔细查——哦,值是 undefined。数组里面的 undefined 变成了 null。Date 对象变成了一串字符串。

打开 JSON 格式化工具,把数据贴进去对比序列化前后的结构变化。这一对比发现了很多之前没注意到的细节。于是花了些时间把 JSON.stringify 的规范从头读了一遍,发现这层皮底下藏着的东西比想象的多。

JSON 标准简史

JSON 的全称是 JavaScript Object Notation,2001 年由 Douglas Crockford 提出。他当时正在做浏览器和服务器的异步通信,觉得 XML 太重了,解析慢、语法冗余。于是从 JavaScript 的对象字面量语法里提取了一个子集,形成了 JSON 的雏形。

2006 年,Crockford 向 IETF 提交了 JSON 规范,2007 年发布为 RFC 4627。2013 年 RFC 7158 发布,2017 年 JSON 成为互联网标准 STD 90(RFC 8259)。这版规范至今仍是 JSON 的最终定义。

JSON 的成功不是因为技术多先进。同期有 YAML、MessagePack、甚至 Google 的 Protocol Buffers。但 JSON 胜在极致的简洁:规范文档只有十几页,数据类型只有 6 种。任何一种编程语言都能在很短时间内实现完整的 JSON 支持,跨语言通信的门槛降到了最低。

但硬币的另一面是:JSON 是 JavaScript 的子集——子集意味着它只取了 JavaScript 的一部分,不是全部。JavaScript 里很多运行时概念,JSON 完全不知道它们的存在。

JSON 官方支持的 6 种数据类型

RFC 8259 定义的 JSON 数据类型有且仅有 6 个:

  • string:字符串,必须用双引号
  • number:数字,包含整数和浮点数
  • boolean:布尔值 true 或 false
  • null:空值
  • object:对象,一组无序的键值对,键必须是字符串
  • array:数组,有序的值列表

只有这 6 个。不是 7 个,不是 8 个。

JavaScript 类型与 JSON 类型对应关系

JavaScript 里常见的 undefined、Function、Symbol、BigInt、Map、Set、Date、RegExp——全都不在 JSON 的规格里。当 JSON.stringify 碰到这些类型时,行为不是统一报错,也不是统一忽略,而是分情况处理。每种类型的处理方式不同,这正是踩坑的源头。

undefined、function、Symbol 去哪了

这三种类型在 JSON 中没有对应表示。JSON.stringify 遇到它们时的处理规则可以用一句话概括:看位置。

如果值是 undefined、function 或 Symbol:

  • 出现在对象属性值的位置 → 直接忽略该属性,键值对整个消失
  • 出现在数组元素位置 → 序列化为 null
  • 单独作为参数传给 JSON.stringify → 返回 undefined(不是字符串 undefined,而是 undefined 原始值)

用代码验证:

const obj = {
  a: 1,
  b: undefined,
  c: function () {},
  d: Symbol("test"),
};
JSON.stringify(obj); // '{"a":1}' — b, c, d 三个属性全消失了

const arr = [1, undefined, function () {}, Symbol("test")];
JSON.stringify(arr); // '[1,null,null,null]' — 全部变成 null

JSON.stringify(undefined); // undefined — 连字符串都不是

这个差异在实际开发中很容易踩坑。对象属性丢了你不一定有感知——少了一个键不影响 JSON 结构的合法性,下游可能直接用了默认值。但数组的元素变成 null 就可能出问题。之前处理一个工具配置,里面有个数组存的是回调函数的引用列表,JSON.stringify 之后所有回调变成了 null,数组长度还在但内容全废了。下游拿到数据后遍历数组,每一项都是 null,用 typeof 判断也判断不出来,跑出了诡异的错误。

处理方案就两种。第一种是序列化前做好数据清洗,用 null 代替 undefined,用普通对象代替函数列表。第二种是用 replacer 参数在序列化时统一转换。

replacer 参数的妙用

JSON.stringify 的第二个参数 replacer,很多人知道但很少用。它可以是一个函数或者一个数组。

当 replacer 是函数时,它接收 (key, value) 两个参数,在序列化过程中对每个键值对做拦截。返回值就是最终序列化的值。返回 undefined 表示删除该属性。

const data = {
  name: "test",
  secret: "password123",
  callback: () => {},
  threshold: 0,
  count: null,
};

JSON.stringify(data, (key, value) => {
  if (key === "secret") return undefined;
  if (typeof value === "function") return "[Function]";
  if (value === 0) return "zero";
  return value;
});
// '{"name":"test","callback":"[Function]","threshold":"zero","count":null}'

replacer 的实用场景远不止过滤敏感字段:

  • 序列化 Map、Set 等非标准集合类型为普通数组或对象
  • 给数字加上格式化标记,比如金额保留两位小数
  • 控制 JSON 输出的字段顺序(按自定义顺序返回键值对)
  • 替换循环引用为占位字符串
  • 统一各种时间格式为 ISO 字符串
  • 把 Buffer 或 TypedArray 转成 Base64 字符串

注意一个细节:replacer 函数的 this 指向当前被序列化的对象,但在严格模式下 this 是 undefined。ES 规范推荐用闭包或者箭头函数来保持上下文,不要依赖 this。

当 replacer 是数组时,它指定了需要序列化的属性白名单:

const obj = { a: 1, b: 2, c: 3, d: 4 };
JSON.stringify(obj, ["a", "c"]); // '{"a":1,"c":3}'

这比手动 delete 属性优雅很多。在做接口字段过滤时非常实用。

toJSON 协议的执行规则

如果一个对象上有 toJSON 方法,JSON.stringify 在序列化该对象时不会直接处理原始对象,而是先调用 toJSON 获取一个新值,再对新值做序列化。

const obj = {
  name: "test",
  timestamp: new Date(),
  toJSON() {
    return { ...this, timestamp: this.timestamp.toISOString() };
  },
};

Date 对象是 toJSON 最广泛的应用。Date.prototype 上自带 toJSON 方法,返回 date.toISOString() 的结果。所以 JSON.stringify(new Date()) 得到的总是字符串,而不是 Date 对象。

toJSON 的返回值可以是任何 JSON 可序列化的类型——对象、字符串、数字,甚至一个完全不同的结构。常见的使用场景:

  • 把嵌套模型扁平化,只暴露需要的字段,隐藏内部实现
  • 过滤掉私有属性(以下划线开头的 _secret、_internal 等)
  • 把大型数据结构压缩成精简表示,减少传输体积
  • 统一时间格式为 ISO 字符串,避免不同时区的问题

toJSON 协议执行流程:toJSON 先于 replacer 执行

toJSON 和 replacer 的执行顺序很多人搞混。正确顺序是:先检查 toJSON,再执行 replacer。也就是说,如果对象有 toJSON,replacer 处理的是 toJSON 的返回值,不是原始值。

const obj = {
  value: 42,
  toJSON() {
    return { transformed: true, result: this.value * 2 };
  },
};

JSON.stringify(obj, (key, value) => {
  console.log(key, value);
  return value;
});
// replacer 只会看到 { transformed: true, result: 84 }
// 它永远不会看到原始的 { value: 42 }

这个顺序意味着两件事。第一,你想在 replacer 里拦截原始值?不行,toJSON 先跑了,你能处理的已经是转换后的结果。第二,如果第三方库的对象上有 toJSON 方法,你没法通过 replacer 阻止它的执行。除非你先手动把 toJSON 删掉——delete obj.toJSON。

JSON.stringify 序列化管线流程

循环引用的问题

文章开头提到的线上事故就是它。

JavaScript 的对象允许循环引用。一个对象引用自身,两个对象互相引用,这在图结构、树形结构、缓存系统中很容易出现。JSON.stringify 在处理已经序列化过的对象时不会做去重也不会做标记,直接报 TypeError。

const parent = { name: "parent" };
const child = { name: "child", parent };
parent.child = child;

JSON.stringify(parent);
// TypeError: Converting circular structure to JSON

常见的循环引用来源:

  • 树结构中子节点持有父节点的引用
  • 双向链表或图的环
  • 框架的响应式系统(Vue 的依赖收集、React 的 fiber 树)
  • ORM 模型中的双向关联
  • 缓存系统中对象缓存自身引用

标准 JSON.stringify 没有内置的循环引用处理手段。三个解决方案:

方案一,用 replacer 维护已访问对象的 Set:

function safeStringify(obj, space) {
  const seen = new WeakSet();
  return JSON.stringify(
    obj,
    (key, value) => {
      if (typeof value === "object" && value !== null) {
        if (seen.has(value)) return "[Circular]";
        seen.add(value);
      }
      return value;
    },
    space,
  );
}

用 WeakSet 是关键——它会随着对象被垃圾回收自动释放引用,不会造成内存泄漏。如果用 Set,已经废弃的对象引用会一直留在 Set 里,内存只增不减。

方案二,用第三方库。flatted 库专门处理循环引用,它的序列化格式包含引用标记,可以完整还原数据结构。json-stringify-safe 则更简单,碰到循环引用替换为占位字符串。

方案三,从根源切断。在后端数据层就打破循环引用,不让它传到序列化阶段。API 返回前做一次数据清洗,把双向引用改成单向,或者用 ID 引用代替对象引用。

BigInt 序列化

BigInt 是 ES2020 引入的类型,用于表示任意精度的整数。JSON.stringify 碰到它会直接报 TypeError。

JSON.stringify({ value: 123n });
// TypeError: Do not know how to serialize a BigInt

JSON 规范里没有定义任意精度整数。BigInt 可能超出 Number.MAX_SAFE_INTEGER(9007199254740991),直接转 Number 会丢精度。

最简单的处理是给 BigInt 加上 toJSON:

BigInt.prototype.toJSON = function () {
  return this.toString();
};

但问题来了:解码时得到的是字符串而不是数字,类型信息丢失了。如果你的应用需要精确还原 BigInt,可以在序列化时加上类型标记:

JSON.stringify({ value: 123n }, (key, value) => {
  if (typeof value === "bigint") {
    return { __type: "BigInt", value: value.toString() };
  }
  return value;
});

相应的解析逻辑用 JSON.parse 的 reviver 参数反向还原:

JSON.parse(jsonStr, (key, value) => {
  if (value && value.__type === "BigInt") {
    return BigInt(value.value);
  }
  return value;
});

这种模式可以推广到其他无法原生序列化的类型,比如 Map、Set、RegExp。给每种类型一个唯一标记,序列化时打包,反序列化时还原。但要注意这个约定必须在通信两端都对得上,不是 JSON 标准的一部分。

对象键的排序规则

JSON.stringify 输出的对象键顺序有明确规则:数字键按升序排列,非数字键按插入顺序排列。

这里的数字键指的是能被解析为无符号 32 位整数的字符串键。

JSON.stringify({ b: 1, a: 2, 2: 3, 1: 4 });
// '{"1":4,"2":3,"b":1,"a":2}'
// 1 和 2 被排到前面,b 和 a 保持原始顺序

这个特性在大部分日常场景中不会造成问题。但在做 API 签名校验、缓存 key 生成、或者需要精确对比 JSON 字符串的场景下非常关键。两端插入属性的顺序不同,生成的 JSON 字符串就不同,哈希值就对不上。

我曾经排查过一个阿里云 API 签名的 Bug,根源就是对象属性顺序。Chrome 和 Node.js 在不同 V8 版本上对对象属性的枚举顺序有不一致的行为。后来把键值对改成数组传输才彻底解决。

如果需要控制 JSON 输出的键顺序,有两个办法。第一,用 replacer 函数手动排序:

function orderedReplacer(key, value) {
  if (value && typeof value === "object" && !Array.isArray(value)) {
    return Object.keys(value)
      .sort()
      .reduce((acc, k) => {
        acc[k] = value[k];
        return acc;
      }, {});
  }
  return value;
}

第二,用 Map 代替对象。Map 的迭代顺序就是插入顺序,JSON.stringify 不会自动排序 Map 的键——前提是 Map 有自己的序列化逻辑。

大数据量下的性能

JSON.stringify 处理大型数组或深层嵌套对象时,性能和内存值得关注。

V8 引擎对 JSON.stringify 有专门的优化路径。对于纯数据对象(没有 getter、没有 toJSON、没有 Proxy 包装),V8 会用快速路径直接序列化,速度比通用路径快一个数量级。但对象上有自定义 toJSON、getter、或者 Proxy,就退回到慢速路径。

一些粗测数据(V8 引擎,10 万条简单对象数组):

  • 纯数据对象,快速路径:约 80ms
  • 带 toJSON 方法的包装对象:约 350ms
  • 包含函数和 Symbol 需要过滤的:约 200ms
  • 深度嵌套的复杂结构:500ms 以上

优化方向:

  1. 序列化前用简单的转换函数把复杂对象拍平。新建一个普通对象,只复制需要的字段,去掉 toJSON 和其他方法带来的额外负担
  2. 大数据量分批处理,避免一次阻塞主线程超过 100ms。可以使用 requestIdleCallback 或者 Web Worker 异步处理
  3. 用 structuredClone 替代 JSON.parse(JSON.stringify(obj)) 做深拷贝。structuredClone 是浏览器原生支持的深拷贝 API,更快,而且支持循环引用、Date、Map、Set 等多种类型
  4. Node.js 端可以考虑用流式序列化,逐步输出 JSON 而不是一次性生成完整字符串

内存方面有个容易忽视的问题:JSON.stringify 返回的字符串在 JavaScript 中按 UTF-16 编码,每个字符占 2 字节。如果原始数据有 200MB,序列化后的字符串可能占用超过 400MB 内存。

加上中间递归调用的栈和临时对象,整体开销可能翻倍甚至更多。我见过一个线上案例,服务端对一个 50MB 的复杂对象做 JSON.stringify,直接撑爆了 Node.js 的默认内存限制。

还有一个容易忽略的点:JSON.stringify 抛出异常时不会释放已经生成的中间结果。如果序列化到一半报错(比如遇到 BigInt),之前处理的数据全部作废。对于大 JSON,建议先做一次类型检查再序列化。

JSON.stringify 与手动序列化的差异

有人觉得 JSON.stringify 功能简单,手写一个替代也不难。但真写起来会发现边界情况多到惊人。

一个看似正确的简陋实现:

function manualStringify(obj) {
  if (typeof obj === "string") return '"' + obj + '"';
  if (typeof obj === "number") return String(obj);
  if (typeof obj === "boolean") return obj ? "true" : "false";
  if (obj === null) return "null";
  if (Array.isArray(obj)) {
    return "[" + obj.map(manualStringify).join(",") + "]";
  }
  if (typeof obj === "object") {
    const parts = Object.entries(obj).map(
      ([k, v]) => '"' + k + '":' + manualStringify(v),
    );
    return "{" + parts.join(",") + "}";
  }
}

这段代码看起来很完整,但问题清单一拉很长:

  1. 没有处理 toJSON 协议。Date、自定义类等对象不会被正确序列化
  2. 没有处理 replacer 参数
  3. 字符串没有做特殊字符转义。换行符 \n、制表符 \t、回车 \r、双引号、反斜杠——这些在 JSON 字符串中必须转义。原生 JSON.stringify 对控制字符 \u0000-\u001F 全部转义为 \uXXXX 形式
  4. 没有处理 undefined、function、Symbol 在不同位置的差异
  5. 没有处理 BigInt。碰到 BigInt 应该抛 TypeError
  6. 没有处理循环引用,会无限递归然后栈溢出
  7. NaN 和 Infinity 在 JSON 中不被支持。原生 JSON.stringify 会把这些值序列化为 null,你的实现会输出 NaN 或 Infinity
  8. 没有处理嵌套深度限制。原生实现内置了最大递归深度,超出会报错
  9. 稀疏数组([1, , 3])中的空位没有被正确处理
  10. 数值 -0 和 0 的区分。原生 JSON.stringify 把 -0 序列化为 "0"

去除边界情况之后,核心 JSON 序列化其实逻辑不复杂。但把这些边界情况全部处理好,代码量会翻好几倍。这也是为什么我建议不要重复造轮子,直接用原生 JSON.stringify,把精力花在数据处理上。

实战建议

踩过坑就会知道:

第一,序列化前做好数据清洗。用明确的数据模型代替隐式类型。比如接口返回值中用 null 代替 undefined,用普通对象代替 Map。类型确定性越高,序列化的行为就越可预测。

第二,在 API 边界层统一处理序列化逻辑。不要把 JSON.stringify 散落在业务代码的各个角落。封装一个 safeStringify 函数,把循环引用、BigInt、特殊类型都处理掉,业务层只管传数据。

第三,深拷贝用 structuredClone 替代 JSON.parse(JSON.stringify(obj))。两者的区别不只是性能——structuredClone 原生支持 Date、Map、Set、RegExp、ArrayBuffer 等类型,而且能处理循环引用。

第四,大数据量注意监控。超过 1MB 的 JSON 序列化就值得关注耗时了。10MB 以上建议分批处理或者做成流式。

第五,生产环境永远用 safeStringify 包装函数兜底。不能因为 JSON.stringify 抛异常就让页面白屏或者接口 500。

几个顺手好用的原生替代方案:

  • structuredClone 做深拷贝
  • Response.json() + Response.prototype.json() 做流式序列化和反序列化
  • BroadcastChannel 的结构化克隆传输

理解了这些细节之后,JSON.stringify 在你眼里就不再是一个黑盒了。下次再遇到它的报错,你能更快定位到问题本质。

如果在线上排查 JSON 序列化问题时需要验证结构,也可用JSON 格式化工具在线分析,查看序列化前后的格式对比和结构差异。