javascript
const draft = { name: '订单A', remark: undefined, createdAt: new Date('2026-10-10T08:00:00'), cache: new Map([['a', 1]]) };
const back = JSON.parse(JSON.stringify(draft));
// name 在;remark 没了;createdAt 从 Date 变成字符串;cache 变成空对象
JSON.stringify 是前端用得最勤的序列化工具------存缓存、传接口、postMessage、深拷贝兜底,全走它。但它对数据做的动作,比「转成字符串」多不少。我把最容易踩的 7 个坑写成一个实验脚本,27 个用例在 Node 24 里逐个跑了一遍,下面每个输出都是真实结果,代码块可以直接粘进控制台复跑。先看总账:
| # | 坑 | 一句话症状 |
|---|---|---|
| 1 | 键静默消失 | undefined、函数、Symbol 的键,序列化后直接没了 |
| 2 | Date 变字符串 | 类型丢了,时区还被吃成 UTC |
| 3 | NaN 变 null | 报表里莫名出现 null,全程没报错 |
| 4 | Map 变空对象 | 存进去是 Map,取出来是 {} |
| 5 | 键顺序重排 | 正整数键升序插队,依赖顺序的逻辑错乱 |
| 6 | 直接崩 | 循环引用、BigInt,一个 TypeError 糊脸 |
| 7 | parse 侧丢数据 | 重复键静默取后者 |
坑 1:undefined 的键,序列化后没了
javascript
JSON.stringify({ name: '订单A', remark: undefined, cancel: () => {}, [Symbol('id')]: 1 });
// '{"name":"订单A"}' ← 三个键全没了
典型场景:表单里「备注」没填,remark: form.remark || undefined------提交、存储、再读出来,这个键彻底消失。下游想区分「没有这个字段」和「字段是空的」,依据没了。更阴的是全程不报错,'remark' in obj 是 false,不主动查根本发现不了。
顺带一提,我打印这次实验结果时,结果对象里两个 undefined 值的行直接从输出里消失了------写实验的人被实验坑了一次,这坑的覆盖率是真的。
防御:空值占位用 null,别用 undefined。null 会被保留。
坑 2:Date 变字符串,时区还被吃掉
javascript
const d = new Date('2026-10-10T08:00:00'); // 东八区早上 8 点
JSON.stringify({ createdAt: d });
// '{"createdAt":"2026-10-10T00:00:00.000Z"}' ← 变成 UTC 字符串
const back = JSON.parse(JSON.stringify({ createdAt: d }));
back.createdAt instanceof Date; // false,是 string
序列化走 toJSON,toJSON 就是 toISOString,永远吐 UTC。存进去本地 8 点,取回来是 UTC 0 点的字符串,类型也丢了。round-trip 之后所有 instanceof Date 的判断、getTime() 的调用,全部翻车。
我想用 replacer 捞一把 Date,实验泼了冷水:
javascript
JSON.stringify({ d: new Date(0) }, (k, v) => {
console.log(typeof v); // 'string' ------ replacer 收到时,Date 已被 toJSON 处理过
return v;
});
toJSON 先于 replacer 执行,等 replacer 接手时已经是字符串了。防御要么给类自定义 toJSON,要么存时间戳,要么别用 JSON 深拷贝带 Date 的对象(见文末 structuredClone)。
坑 3:NaN 和 Infinity,静默变 null
javascript
JSON.stringify({ score: NaN, bonus: Infinity, level: -Infinity });
// '{"score":null,"bonus":null,"level":null}'
报表场景:均值算出 NaN(分母为 0),存进缓存再读出来,页面上安静地躺着 null。没人报错,只有用户来问「这里为什么是空的」。防御:序列化前把非有限数显式处理掉,别让 null 替你做决定。
坑 4:Map、Set、RegExp,统统变空对象
javascript
JSON.stringify({ cache: new Map([['a', 1]]) }); // '{"cache":{}}'
JSON.stringify({ tags: new Set([1, 2]) }); // '{"tags":{}}'
JSON.stringify({ pattern: /ab+c/gi }); // '{"pattern":{}}'
存的时候明明有数据,取出来长度为 0,遍历不出任何东西。用 JSON 做缓存层,只要对象里混进一个 Map,这部分数据就是单向的------存得进去,取不出来。
防御:传输前 Object.fromEntries(map) 转普通对象,或者别让 JSON 碰这些类型。
坑 5:正整数键会重排,顺序悄悄变
javascript
JSON.stringify({ b: 1, '2': 'x', a: 2, '1': 'y' });
// '{"1":"y","2":"x","b":1,"a":2}' ← 数字键升序排到了前面
V8 里对象键分两种存法:正整数键是「数组索引」,单独存、升序排;字符串键按插入顺序。序列化按内部顺序输出,你定义的顺序就没了。后端按 key 遍历生成下拉选项、按顺序渲染步骤条,顺序悄悄错位。
边界也顺手验证了:负数和浮点键不算数组索引,不会被重排------'-2'、'1.5' 都留在原位,插队的只有正整数键。
防御:顺序是数据的一部分时,用数组,别用对象键。[{id:1},{id:2}] 比 {1:{},2:{}} 稳得多。
坑 6:循环引用和 BigInt,直接抛错
javascript
const a = {}; a.self = a;
JSON.stringify(a);
// TypeError: Converting circular structure to JSON
JSON.stringify({ orderId: 1234567890123456789n });
// TypeError: Do not know how to serialize a BigInt
树形结构、双向引用、链表------往接口里一丢,整个请求挂掉。BigInt 这条更值得记住:接口返回的雪花 ID 越来越长,Number 存不下,前端用 BigInt 接住之后再想原样传回去,stringify 直接罢工。
防御:BigInt 过 replacer 转字符串;循环引用要么传输前断环,要么用 WeakSet 标记。
坑 7:parse 这一侧也不干净
javascript
JSON.parse('{"amount": 19.99, "amount": 9.99}');
// { amount: 9.99 } ← 重复键静默取后者
JSON.parse(JSON.stringify(undefined));
// SyntaxError: "undefined" is not valid JSON
重复键不报错、取最后一个------手拼字符串、模板拼接出重复键时,前面的值被悄悄覆盖。第二个更冷:JSON.stringify(undefined) 返回的不是字符串,是 undefined 本身,parse 它直接语法错误。
parse 侧还有条安全细节顺手验证了:注入 {"__proto__": {...}} 在 V8 里不会污染原型(它成为对象的自有属性),但把这个对象 Object.assign 给别的对象,原型就真的被改了;展开运算符 {...obj} 则安全。
速查表:症状 → 根因 → 修复
| 症状 | 根因 | 一行修复 |
|---|---|---|
| 键消失 | undefined/函数/Symbol 不序列化 | 空值用 null 占位 |
| Date 变字符串 | toJSON 走 toISOString(UTC) | 存时间戳或自定义 toJSON |
| replacer 捞不到 Date | toJSON 先于 replacer 执行 | 在 toJSON 里动手 |
| NaN 变 null | 规范规定非有限数转 null | 序列化前显式处理 |
| Map 取出来是 {} | Map 没有 toJSON | fromEntries 转普通对象 |
| 键顺序变了 | 正整数键是数组索引,升序前置 | 顺序数据用数组 |
| 循环引用崩 | 无法自引用 | WeakSet 断环或 structuredClone |
| BigInt 崩 | 规范不支持 | replacer 里 v.toString() |
| 重复键覆盖 | parse 取最后一个 | 别手拼 JSON 字符串 |
| assign 后原型被改 | proto 走 setter | 用展开运算符代替 assign |
深拷贝的三个流派
这些坑的公共背景是同一个:JSON 是传输格式,不是拷贝工具。真要做深拷贝,现在有三个流派:
javascript
structuredClone({ d: new Date('2026-10-10'), m: new Map([['a', 1]]) });
// Date 还是 Date,Map 还是 Map,循环引用也不崩
structuredClone({ fn: () => {} });
// DOMException: () => {} could not be cloned ------ 函数不支持
structuredClone 救得了坑 2、坑 4、坑 6,救不了函数;JSON round-trip 胜在到处能跑,输在什么都变;lodash 的 cloneDeep 全能,代价是一个依赖。我的立场:进程内拷贝用 structuredClone,跨进程、跨存储才轮到 JSON------别让传输格式兼职拷贝工具。
这 7 个坑的共同点是安静:不报错、不警告、数据默默变形,直到某个下游逻辑用错值才炸。你被第几个坑过、在什么场景发现的,评论区说一声。另外站个队:进程内深拷贝,你选 structuredClone 还是 JSON round-trip------我赌后者的存量比承认的多。