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

简介: 深入剖析 JSON 序列化规则,undefined 和 function 的处理差异,toJSON 自定义序列化,循环引用和大数据性能

曾遇到一个线上工单,用户反馈某个页面白屏了。打开控制台,报错信息是 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 个。
31.png

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 字符串,避免不同时区的问题
    32.png

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。
33.png

循环引用的问题

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

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 格式化工具在线分析,查看序列化前后的格式对比和结构差异。

相关文章
|
9天前
|
人工智能 开发工具 iOS开发
Claude Code 新手完全上手指南:安装、国产模型配置与常用命令全解
Claude Code 是一款运行在终端环境中的 AI 编程助手,能够直接在命令行中完成代码生成、项目分析、文件修改、命令执行、Git 管理等开发全流程工作。它最大的特点是**任务驱动、终端原生、轻量高效、多模型兼容**,无需图形界面、不依赖 IDE 插件,能够深度融入开发者日常工作流。
3137 8
|
12天前
|
Shell API 开发工具
Claude Code 快速上手指南(新手友好版)
AI编程工具卷疯啦!Claude Code凭借任务驱动+终端原生的特性,成了开发者的效率搭子。本文从安装、登录、切换国产模型到常用命令,手把手带新手快速上手,全程避坑,30分钟独立用起来。
3199 20
|
5天前
|
人工智能 Linux BI
国内用 Claude Code 终于不用翻墙了:一行命令搞定,自动接 DeepSeek
JeecgBoot AI专题研究 一键脚本:Claude Code + JeecgBoot Skills + DeepSeek 全平台接入 一行命令装好 Claude Code + JeecgBoot Skills + DeepSeek 接入,无需翻墙使用 Claude Code,支持 Wind
2134 3
国内用 Claude Code 终于不用翻墙了:一行命令搞定,自动接 DeepSeek
|
24天前
|
人工智能 JSON 供应链
畅用7个月无影 JVS Claw |手把手教你把JVS改造成「科研与产业地理情报可视化大师」
LucianaiB分享零成本畅用JVS Claw教程(学生认证享7个月使用权),并开源GeoMind项目——将JVS改造为科研与产业地理情报可视化AI助手,支持飞书文档解析、地理编码与腾讯地图可视化,助力产业关系图谱构建。
23591 15
畅用7个月无影 JVS Claw |手把手教你把JVS改造成「科研与产业地理情报可视化大师」
|
1天前
|
人工智能 自然语言处理 文字识别
阿里云百炼Qwen3.7-Max简介:能力、优势、支持订阅计划参考
Qwen3.7-Max是阿里云百炼面向智能体时代推出的新一代旗舰模型,对标GPT-5.5、Claude Opus 4.7等闭源旗舰。该模型支持百万级token上下文窗口,具备顶级推理能力、多模态搜索与视觉理解增强、流式输出低延迟响应等核心优势,覆盖编程、办公、长周期自主执行等复杂场景。同时支持OpenAI接口兼容,便于系统快速迁移。用户可通过Token Plan团队版、Coding Plan或节省计划等订阅方式灵活调用,适合企业级高要求场景使用。
|
11天前
|
人工智能 JSON BI
DeepSeek V4-Pro 接入 Claude Code 完全实战:体验、测试与关键避坑指南
Claude Code 作为当前主流的 AI 编程辅助工具,凭借强大的代码理解、工程执行与自动化能力深受开发者喜爱,但原生模型的使用成本相对较高。为了在保持能力的同时进一步降低开销,不少开发者开始寻找兼容度高、价格更友好的替代模型。DeepSeek V4 系列的发布带来了新的选择,该系列包含 V4-Pro 与 V4-Flash 两款模型,并提供了与 Anthropic 完全兼容的 API 接口,理论上只需简单修改配置,即可让 Claude Code 无缝切换为 DeepSeek 引擎。
2648 3
|
3天前
|
人工智能 自然语言处理 安全
Claude Code 全攻略:命令大全+三种模式+记忆体系+实战工作流完整手册
Claude Code 是当前最流行的终端级 AI 编程助手,能够直接在命令行中完成代码生成、项目理解、文件修改、命令执行、错误修复等全流程开发工作。它不依赖图形界面、不占用额外资源,却能深度理解项目结构,自动生成规范代码,大幅提升研发效率。
772 2
|
10天前
|
人工智能 安全 开发工具
Claude Code 官方工作原理与使用指南
Claude Code 不是传统代码补全工具,而是 Anthropic 推出的终端 AI 代理,具备代理循环、双驱动架构(模型+工具)、全局项目感知、6 种权限模式等核心能力,本文基于官方文档系统解析其工作原理与高效使用技巧。
1444 0

热门文章

最新文章