先记住这个答案
JSON 格式本身只有字符串、数字、布尔、null、对象和数组,没有 undefined、函数和 Symbol。JSON.stringify 碰到这三种值,按所在位置分三种处理:作为对象属性的值时,整个属性被跳过;作为数组元素时,被换成 null;直接把它们传给 JSON.stringify 时,返回值是 undefined。另外 NaN 和 Infinity 会变成 null,循环引用和 BigInt 会抛 TypeError。
- 对象属性值是这三种时,属性直接消失
- 数组里的这三种值会变成
null,保住下标 - 直接序列化它们,返回
undefined而不是字符串 - 循环引用与
BigInt会报错
先看三种位置的输出
把同一组值分别放进对象、数组,再单独序列化,差别一眼就能看出来。
const obj = {
a: undefined,
b() {},
c: Symbol('x'),
[Symbol('key')]: 1, // Symbol 作为键,同样被忽略
d: null,
e: NaN
}
console.log(JSON.stringify(obj))
console.log(JSON.stringify([undefined, () => {}, Symbol('x'), 1]))
console.log(JSON.stringify(undefined))
console.log(typeof JSON.stringify(() => {}))查看输出与解释
{"d":null,"e":null}
[null,null,null,1]
undefined
undefined对象里 a、b、c 和那个 Symbol 键全没了,只剩 d,而 NaN 被改写成了 null。数组不能随便删元素,删了后面的 1 就从下标 3 跑到下标 0,所以只能用 null 占位。最后两行说明顶层值序列化的结果是真正的 undefined,不是字符串,拿它去拼接或者当请求体发出去,都会出问题。
项目里最常踩的两个坑
第一个坑是用 JSON.parse(JSON.stringify(obj)) 做深拷贝。拷贝完函数和值为 undefined 的字段都丢了,Date 变成了 ISO 字符串,Map、Set 变成了空对象 {}。表单数据这种纯数据还能凑合用,带方法或特殊类型的对象就不行了,现在更推荐 structuredClone,它能处理 Date、Map 和循环引用,但遇到函数会直接抛错。
第二个坑在接口提交上。比如编辑用户资料,前端把清空的昵称设成 undefined,axios 序列化请求体时这个字段直接消失,后端收到的请求里压根没有 nickname,于是认为「不修改」。想表达「清空」必须传 null,null 会原样保留在 JSON 里。反过来,有些团队就是利用这一点,把不想提交的字段设成 undefined 来省掉手动删除。
- 用
replacer把undefined保留下来 JSON.stringify的第二个参数replacer是个函数,每个键值对都会先经过它。写成(key, value) => value === undefined ? null : value,值为undefined的属性就会以null的形式保留。函数和 Symbol 也能用同样的办法转成字符串描述。第三个参数是缩进,调试打印时传2可读性好很多。
对象自己决定怎么被序列化
序列化一个值之前,JSON.stringify 会先看它有没有 toJSON 方法,有的话用这个方法的返回值代替原值。Date 能变成字符串,就是因为 Date.prototype.toJSON 返回了 ISO 格式的时间。自己的类也可以加一个 toJSON,比如隐藏密码字段,或者把金额对象输出成数字。
这个顺序要记住:先调用 toJSON,再交给 replacer,最后才按上面的规则丢弃或替换。所以如果 toJSON 返回了 undefined,这个属性照样会被跳过。
容易答错的地方
- 以为
undefined会变成null - 只有在数组里才变成
null。对象属性是直接消失,这也是很多人深拷贝后发现字段少了的原因。 - 以为
NaN会被保留或报错 - JSON 没有
NaN和Infinity,它们会被悄悄改成null,不报错。统计接口里算出0 / 0,前端拿到的就是null,排查时很容易误以为是后端没返回。 - 以为所有异常值都会被静默处理
BigInt不会被丢掉,而是直接抛TypeError,要么先转成字符串,要么给BigInt.prototype加toJSON。循环引用同样抛TypeError,错误信息里会提到circular structure。
面试官还会怎么问?
怎么序列化带循环引用的对象?
用 replacer 配合 WeakSet 记录已经访问过的对象,再次遇到同一个对象时返回一个占位字符串或者 undefined 跳过它。日志库打印复杂对象时一般就是这么做的。
JSON.parse 能把丢掉的值还原回来吗?
不能,信息在序列化那一步就没了。JSON.parse 也有第二个参数 reviver,可以把 ISO 字符串转回 Date,但前提是字符串还在,被丢掉的函数和 undefined 找不回来。
Map 和 Set 序列化出来是什么?
都是 {},因为它们的数据不在可枚举的自有属性上。要保存内容,先用 Array.from(map) 或 [...set] 转成数组再序列化。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。