我用 JSON.parse(JSON.stringify()) 深拷贝了一个带 Date 的对象,拷贝完调 date.getTime() 直接报错,因为那个 Date 早被悄悄变成了字符串:一次 JS 深拷贝丢失类型的深度复盘
那个 bug 是拷贝出来的对象"用着用着就报错"才暴露的:我需要深拷贝一个配置对象(里面有创建时间 createdAt 是个 Date、还有些可选字段)。我用了那个"人人都在用、看起来万能"的深拷贝技巧:const copy = JSON.parse(JSON.stringify(obj))。一开始好好的,直到某段代码对拷贝出来的对象调用 copy.createdAt.getTime()——砰,TypeError: copy.createdAt.getTime is not a function。我盯着这行愣住了:原对象的 createdAt.getTime() 明明好好的,怎么一拷贝就没了这个方法?我把拷贝前后的对象都打印出来对比,才看明白,后背发凉:问题出在 JSON.parse(JSON.stringify()) 这个深拷贝方法的"能力边界"上。它的原理是"先把对象序列化成 JSON 字符串,再解析回对象"——可JSON 这个格式,本身只能表示有限的几种数据类型(对象、数组、字符串、数字、布尔、null);那些JSON 表示不了的类型,在这个"序列化再解析"的来回中,就被悄悄地改变或丢弃了:Date 被序列化成了 ISO 字符串(解析回来就是个 string、不再是 Date,自然没有 getTime 方法);undefined、函数、Symbol 直接被丢弃(键都没了);Map、Set 变成了空对象 {};循环引用直接抛错;NaN、Infinity 变成 null。问题的根,是用 JSON.parse(JSON.stringify()) 做深拷贝:它受限于 JSON 格式能表达的类型,Date/undefined/函数/Map/Set 等会在序列化往返中被改变或丢失。这篇就把这次"深拷贝丢失类型"的坑,从头到尾复盘一遍。
故障现场:JSON 深拷贝,Date 变字符串、字段丢失
问题在于 JSON.parse(JSON.stringify()) 受限于 JSON 格式,无法保留 Date 等类型:
// ✗ 出问题的代码: 用 JSON.parse(JSON.stringify()) 深拷贝
const obj = {
name: "配置",
createdAt: new Date(), // Date 对象
count: undefined, // undefined
tags: new Set(["a", "b"]), // Set
compute: () => 42, // 函数
score: NaN, // NaN
};
const copy = JSON.parse(JSON.stringify(obj));
console.log(copy.createdAt); // "2026-06-02T..." ← ✗ 变成了【字符串】, 不再是Date!
copy.createdAt.getTime(); // ✗ TypeError: getTime is not a function (string没有这方法)
console.log("count" in copy); // ✗ false ← undefined的键被【丢弃】了
console.log(copy.tags); // ✗ {} ← Set变成了【空对象】
console.log(copy.compute); // ✗ undefined ← 函数被【丢弃】
console.log(copy.score); // ✗ null ← NaN变成了【null】
// 为什么? JSON.parse(JSON.stringify()) 的原理是"序列化成JSON字符串, 再解析回来":
// - JSON这个格式只支持: object / array / string / number / boolean / null;
// - 凡是JSON表示不了的类型, 在这一去一回中就被改变或丢弃:
// * Date → 被 toJSON() 序列化成ISO字符串 → 解析回来是string(丢了Date身份和方法);
// * undefined / 函数 / Symbol → 序列化时被【忽略】(对象的该键直接消失);
// * Map / Set → 序列化成 {} (它们没有可枚举属性) → 变成空对象;
// * NaN / Infinity → 序列化成 null;
// * BigInt → 直接抛错(无法序列化);
// * 循环引用 → 直接抛 TypeError: Converting circular structure to JSON。
// 还有: 它对大对象性能也差(要序列化整个字符串再解析); 且原型链(类实例)也丢失(变普通对象)。
// 关键: JSON.parse(JSON.stringify())深拷贝受限于JSON能表达的类型; Date变字符串、undefined/函数/Symbol丢失、
// Map/Set变空对象、NaN变null、循环引用报错 —— 它只适合"纯JSON数据", 含特殊类型就会丢/错。
第一次看清"原来 Date 在 JSON 来回里变成了字符串、好几个字段直接没了"时,我又懊恼又后怕:"我一直把 JSON.parse(JSON.stringify()) 当成万能深拷贝、随手就用,完全没意识到它会悄悄改变和丢弃这么多类型的数据。"这个坑最隐蔽的地方在于:它对"纯 JSON 数据"(只有对象/数组/基本类型)工作得完美,所以平时用着没问题、给了你"它很可靠"的错觉;只有当对象里恰好含有 Date/undefined/Set 等特殊类型时,它才悄悄出错;而且丢失/转换是静默的(不报错,Date 变字符串、字段消失都无声无息),直到很久以后用到那个变了味的字段才爆发。下面就来拆解,深拷贝该怎么正确做。
第一件事:搞懂 JSON 深拷贝的能力边界
我顺着这次事故,把 JS 深拷贝的几种方式和各自的能力边界彻底理清了。
JSON.parse(JSON.stringify()) 深拷贝, 到底会丢/改哪些东西?
【核心: 它受限于JSON能表达的类型, Date变字符串/undefined·函数·Symbol丢失/Map·Set变空对象/NaN变null/循环引用报错; 只适合纯JSON数据】
1. 它的原理: 序列化 → 反序列化
- JSON.stringify(obj): 把对象变成JSON字符串;
- JSON.parse(str): 把JSON字符串解析回新对象;
- 一去一回, 得到一个全新的、深层独立的对象 → 这就是它"深拷贝"的来源。
2. 但JSON格式只支持有限类型: object/array/string/number/boolean/null
- 凡是JSON表达不了的, 在序列化时就要被"处理"(转换或丢弃):
3. 它会"丢失或改变"的东西:
- Date → ISO字符串(丢失Date类型和getTime等方法);
- undefined / 函数 / Symbol → 直接丢弃(对象里该键消失, 数组里变null);
- Map / Set → 变成 {} (空对象);
- NaN / Infinity / -Infinity → 变成 null;
- BigInt → 抛错(不能序列化);
- 循环引用 → 抛 TypeError;
- 类的实例 → 丢失原型链, 变成普通对象(方法没了);
- 性能: 大对象慢(整个序列化+解析)。
4. 那它什么时候能用? —— 只有"纯JSON数据"时
- 如果对象确定只含 object/array/string/number/boolean/null(如从接口拿的纯JSON), 它能用、也简单;
- 一旦含上述特殊类型, 就别用它。
5. 深拷贝的其他选择:
- structuredClone(obj): 【现代浏览器/Node 17+ 内置】, 正确处理Date/Map/Set/循环引用等(但函数/Symbol仍不支持);
- 库: lodash 的 cloneDeep, 支持广泛;
- 手写递归拷贝: 针对性处理你的具体结构;
- 浅拷贝({...obj} / Object.assign): 只拷一层, 嵌套对象仍共享引用(不是深拷贝, 别混淆)。
一句话: JSON.parse(JSON.stringify())深拷贝受限于JSON能表达的类型, Date变字符串、undefined·函数·Symbol丢失、
Map·Set变空对象、NaN变null、循环引用报错; 只适合纯JSON数据, 含特殊类型用structuredClone或cloneDeep。
这套认知,是整个坑的根。它的原理:序列化→反序列化——stringify 成 JSON 字符串、parse 回新对象,一去一回得到深层独立的对象。但 JSON 只支持有限类型(object/array/string/number/boolean/null),表达不了的在序列化时被转换或丢弃。它会丢失/改变的:Date→ISO 字符串、undefined/函数/Symbol→丢弃、Map/Set→空对象、NaN/Infinity→null、BigInt→抛错、循环引用→抛错、类实例→丢原型链变普通对象,大对象还慢。什么时候能用:只有"纯 JSON 数据"(只含 object/array/基本类型)时;含特殊类型就别用。其他选择:structuredClone(obj)(现代内置,正确处理 Date/Map/Set/循环引用,但不支持函数/Symbol)、lodash cloneDeep、手写递归;浅拷贝({...obj})只拷一层、嵌套仍共享引用(不是深拷贝别混淆)。一句话:JSON.parse(JSON.stringify()) 深拷贝受限于 JSON 能表达的类型,Date 变字符串、undefined/函数/Symbol 丢失、Map/Set 变空对象、NaN 变 null、循环引用报错;只适合纯 JSON 数据,含特殊类型用 structuredClone 或 cloneDeep。
第二件事:正解——用 structuredClone / cloneDeep,按数据特性选深拷贝方式
搞懂了原理,正解就清晰了:含 Date/Map/Set/循环引用的对象用 structuredClone(现代内置)或 lodash cloneDeep;纯 JSON 数据才用 JSON 方式;分清浅拷贝和深拷贝;按数据实际含的类型选方法。
// ====== 正解一: structuredClone(现代浏览器/Node 17+ 内置, 推荐) ======
const obj = { name: "配置", createdAt: new Date(), tags: new Set(["a"]), nested: { x: 1 } };
const copy = structuredClone(obj);
copy.createdAt.getTime(); // ✓ 正常! createdAt 仍是 Date
copy.tags; // ✓ 仍是 Set
copy.nested.x = 2; // ✓ 深拷贝, 改copy不影响obj
// structuredClone 正确处理: Date / Map / Set / ArrayBuffer / 循环引用 / 嵌套;
// 仍不支持: 函数 / Symbol / DOM节点(会抛错或丢失) —— 但这些本就不该被"拷贝"。
// ====== 正解二: lodash cloneDeep(老环境/需要更广支持) ======
import cloneDeep from "lodash/cloneDeep";
const copy2 = cloneDeep(obj); // 支持广泛, 含类实例等(库帮你处理好)
// ====== 正解三: 纯JSON数据, 才用 JSON 方式(简单够用) ======
const data = { id: 1, name: "x", items: [1, 2, 3] }; // 确定只有纯JSON类型
const copy3 = JSON.parse(JSON.stringify(data)); // ✓ 此时它完全够用
// ====== 关键区分: 浅拷贝 vs 深拷贝(别混淆!) ======
const original = { a: 1, nested: { b: 2 } };
// 浅拷贝: 只拷贝第一层; 嵌套的对象仍然是【共享的同一个引用】!
const shallow = { ...original }; // 或 Object.assign({}, original)
shallow.a = 99; // ✓ 改第一层不影响original
shallow.nested.b = 99; // ✗ 改嵌套对象, original.nested.b 也变成99! (共享引用)
// 深拷贝: 递归拷贝所有层, 完全独立
const deep = structuredClone(original);
deep.nested.b = 99; // ✓ 不影响 original.nested.b
// → 别把浅拷贝({...obj})当深拷贝用! 它只拷一层, 嵌套对象还是共享的, 改了会互相影响。
// ====== 选型建议 ======
// 1. 含Date/Map/Set/循环引用/嵌套: structuredClone(首选, 内置) 或 lodash cloneDeep;
// 2. 确定是纯JSON数据: JSON.parse(JSON.stringify()) 简单够用;
// 3. 只需拷贝第一层、且无嵌套对象/或可接受共享嵌套: {...obj} 浅拷贝(注意它的局限);
// 4. 含函数/类实例且要保留行为: 一般"拷贝数据"不该含函数; 真要保留行为, 手动重建。
// 核心: 按对象实际含的类型选深拷贝——含Date/Map/Set等用structuredClone或cloneDeep, 纯JSON才用JSON方式;
// 分清浅拷贝(只一层、嵌套共享)和深拷贝(完全独立); 别拿一个有局限的方法当万能深拷贝。
修复的核心,是"按数据实际含的类型选深拷贝方式,别拿 JSON 方式当万能"。正解一:structuredClone(现代内置,推荐)——正确处理 Date/Map/Set/ArrayBuffer/循环引用/嵌套(仍不支持函数/Symbol,但这些本不该被拷贝)。正解二:lodash cloneDeep(老环境/需更广支持,含类实例)。正解三:纯 JSON 数据才用 JSON 方式(确定只含纯 JSON 类型时简单够用)。关键区分:浅拷贝 vs 深拷贝——{...obj} 只拷第一层,嵌套对象仍共享同一引用、改了会互相影响,别当深拷贝用。选型:含特殊类型用 structuredClone/cloneDeep、纯 JSON 用 JSON 方式、只拷一层用浅拷贝(注意局限)。归根结底:按对象实际含的类型选深拷贝——含 Date/Map/Set 等用 structuredClone 或 cloneDeep,纯 JSON 才用 JSON 方式;分清浅拷贝(只一层、嵌套共享)和深拷贝(完全独立);别拿一个有局限的方法当万能深拷贝。
第三件事:JavaScript 拷贝与引用中其他常见的坑
排查后我把 JS 拷贝、引用相关的其他坑也系统梳理了一遍。
JS 拷贝与引用的其他常见坑
# 1. JSON深拷贝丢类型(本文): Date变串/字段丢失。→ structuredClone/cloneDeep。
# 2. 浅拷贝当深拷贝: {...obj}只拷一层, 嵌套共享引用。→ 深拷贝用structuredClone。
# 3. 对象/数组是引用传递: 函数里改参数对象会影响外部。→ 需要不变就先拷贝。
# 4. const对象仍可改内容: const只防重新赋值, 不防改属性。→ 需冻结用Object.freeze(且是浅冻结)。
# 5. 数组方法的"原地修改" vs "返回新数组": sort/reverse/splice原地改; map/filter/slice返回新的。→ 分清。
# 6. 比较对象用 ===: 比的是引用是否同一个, 不是内容。→ 内容比较要深比较。
# 7. 闭包/回调里共享了同一个对象引用: 多处改同一对象互相影响。→ 注意所有权。
# 8. Object.freeze是浅冻结: 嵌套对象仍可改。→ 需要深冻结要递归。
# 共同根源: JS里对象/数组是【引用类型】(变量存的是"指向对象的引用", 不是对象本身);
# "拷贝引用"和"拷贝对象"是两回事、"改引用指向"和"改对象内容"是两回事——
# 不分清这些, 就会在"以为拷贝了其实共享、以为独立其实联动"上踩坑。
# 核心: 牢记JS对象是引用类型; 分清浅拷贝/深拷贝、引用相等/内容相等、改引用/改内容;
# 需要独立副本就按类型选对的深拷贝(structuredClone/cloneDeep), 别让"共享的引用"造成意外联动。
排查让我把拷贝与引用的其他坑也梳理清了。一、JSON 深拷贝丢类型(本文)。二、浅拷贝当深拷贝。三、对象引用传递。四、const 对象仍可改内容。五、数组原地改 vs 返回新数组。六、比较对象用 ===(比引用非内容)。七、闭包共享对象引用。八、Object.freeze 是浅冻结。它们的共同根源是:JS 里对象/数组是引用类型(变量存的是指向对象的引用,不是对象本身);"拷贝引用"和"拷贝对象"是两回事、"改引用指向"和"改对象内容"是两回事——不分清这些,就会在"以为拷贝了其实共享、以为独立其实联动"上踩坑。核心是:牢记 JS 对象是引用类型;分清浅拷贝/深拷贝、引用相等/内容相等、改引用/改内容;需要独立副本就按类型选对的深拷贝,别让共享的引用造成意外联动。下面这张图,是这次深拷贝坑的成因与解法:
第四件事:几种深拷贝方式对比表
这次踩坑后,我把几种深拷贝方式对比成一张表。
| 方式 | Date/Map/Set | 循环引用 | 函数/Symbol | 适用 |
|---|---|---|---|---|
| JSON.parse(JSON.stringify()) | ✗ 丢失/变形 | ✗ 报错 | ✗ 丢弃 | 纯JSON数据 |
| structuredClone | ✓ 保留 | ✓ 支持 | ✗ 不支持 | ★ 多数场景(内置) |
| lodash cloneDeep | ✓ 保留 | ✓ 支持 | 部分 | 需广泛支持/老环境 |
| 浅拷贝 {...obj} | 第一层保留 | — | 第一层保留 | 只需拷一层 |
这张表把几种深拷贝钉清了。核心是:没有一个"万能"的深拷贝——每种方式都有它"能处理什么、不能处理什么"的边界;JSON.parse(JSON.stringify()) 之所以坑人,正是因为它被广泛当成"万能深拷贝",而人们不知道它的边界(只支持纯 JSON 类型);选对方法的前提,是知道"我要拷贝的数据里有什么类型"和"每种方法的能力边界"。它给我的最大启发是:几乎每一个"看起来通用"的工具/方法,都有它不言自明却真实存在的"适用边界/前提条件"——"它在我试过的场景里好用" 不等于 "它在所有场景都好用";很多坑, 就源于"把一个有边界的工具, 当成了无边界的万能工具, 用到了它边界之外的场景"。这给了我一种使用工具的清醒:用任何一个工具/方法/库前(尤其要把它当"通用方案"反复用时),都要主动搞清它的"适用边界"——它假设了什么前提?在什么情况下会失效或出错?——而不是"试了一次好用, 就当它万能、到处套用";"了解工具的边界、在边界内使用它",是用好工具而不被它坑的基本功。认清没有万能的深拷贝、了解每种工具的能力边界并在边界内使用——是这个坑带给我的认知。
第五件事:这次事故暴露的"流行做法不等于正确做法"
这次让我反思更深一层:我用 JSON.parse(JSON.stringify()),很大程度上是因为"大家都这么用、网上到处都是"。我把"流行/顺手"和"正确/恰当"对比成表。
| 维度 | JSON深拷贝(流行/顺手) | 按类型选对方法(正确/恰当) |
|---|---|---|
| 认知成本 | 低(人人都知道) | 要懂数据类型和方法边界 |
| 纯JSON数据 | 好用 | 好用 |
| 含特殊类型 | 悄悄出错 | 正确 |
| 风险 | 边界外静默丢数据 | 可控 |
| 本质 | 流行的、有局限的偏方 | 理解后的恰当选择 |
这张表道出了一个常见的认知误区。核心是:JSON.parse(JSON.stringify()) 是个极其流行、被无数博客和回答推荐的深拷贝"偏方"——可"流行、大家都用" 并不等于 "正确、适合你的场景";它流行,是因为它简单、好记、对最常见的纯 JSON 场景管用;但它的"局限"很少被一起提及,于是大量人不知边界地照搬,在含特殊类型时翻车。它给我的深刻启发是:"一个做法很流行、到处都能搜到" 不能作为 "它正确、适合我" 的充分理由——流行的东西, 往往是"简单好传播"的, 而"简单好传播"和"严谨正确"是两码事;很多流行做法是"够用的偏方"、是"省略了前提和边界的简化版",照搬时若不了解它省略掉的那些前提, 就会踩坑。这给了我一种对待"流行做法/最佳实践"的审慎:采纳一个"流行做法"时,不要止步于"大家都这么做",而要多问一步"它为什么这么做?它的前提和适用边界是什么?它适合我的具体场景吗?"——理解它背后的道理和局限, 再决定用不用、怎么用;"不盲从流行, 理解其原理与边界后再恰当采用",是从'copy-paste 工程师'走向'真正懂的工程师'的关键。认清流行不等于正确、理解原理与边界后再采纳流行做法——是这个深拷贝坑带给我的工程态度。
第六件事:要深拷贝一个对象时,我现在的自检习惯
现在每当我要深拷贝一个对象,我都会先按这张图问自己:
这张图的精髓,是"先分清深浅、再看含不含特殊类型,按类型选对深拷贝方法"。只一层浅拷贝但记住嵌套共享、含 Date/Map/SetstructuredClone/cloneDeep、纯 JSONJSON 方式够用。这套习惯,让我从"深拷贝一律 JSON.parse(JSON.stringify())"变成了"先看数据含啥类型、选对的深拷贝"——核心始终是:含 Date/Map/Set/循环引用的对象用 structuredClone 或 cloneDeep,纯 JSON 才用 JSON 方式,分清浅拷贝和深拷贝,别拿有局限的方法当万能深拷贝。
我立下的几条规矩
这场"深拷贝把 Date 变成字符串、字段还丢了"的事故,换来了我写 JavaScript 时,刻进骨子里的几条铁律:
- JSON.parse(JSON.stringify()) 深拷贝受限于 JSON 能表达的类型。只适合纯 JSON 数据。
- 它会:Date 变字符串、undefined/函数/Symbol 丢弃、Map/Set 变空对象、NaN 变 null、循环引用报错。
- 含 Date/Map/Set/循环引用的对象,用 structuredClone(内置)或 lodash cloneDeep。
- 浅拷贝 {...obj} 只拷第一层,嵌套对象仍共享引用,别当深拷贝用。
- JS 对象/数组是引用类型,拷贝引用 ≠ 拷贝对象。
- 比较对象 === 比的是引用,不是内容。内容比较要深比较。
- 用任何工具前搞清它的能力边界,流行不等于正确。理解原理与局限再用。
写在最后
回头看,这场由"一个流行的深拷贝偏方"引发的、Date 变字符串的事故,真正教给我的,远不止"含特殊类型用 structuredClone"这一个技巧。它让我对"一个被'所有人都在用'的、看似可靠的做法, 背后可能藏着一个'所有人都默认成立、却很少被说破'的前提; 当你的场景悄悄越出了那个前提, 它就会背叛你",有了一次刻骨的体会。我栽跟头,是因为我把 JSON.parse(JSON.stringify()) 当成了一个"无条件可靠的万能深拷贝"——我见所有人都这么用、自己用着也一直没出过事,便默认它在任何情况下都对。可我从没意识到,它的"可靠",是建立在一个隐含前提之上的:"被拷贝的对象, 只含 JSON 能表达的那几种类型";过去它一直对,只是因为我过去拷贝的恰好都是满足这个前提的纯数据;而这次,我的对象里多了一个 Date——悄悄地越出了那个我从不知道其存在的前提边界,于是它就静默地背叛了我。这让我领悟到一个关于"可靠性的前提"的深刻认知:几乎没有什么做法是"无条件可靠"的——它们的"可靠",几乎总是有前提的、有适用范围的;而最危险的, 是那些"前提一直被满足, 以至于你忘了(或从不知道)它有前提"的做法;"当一个一直好用的东西突然出错时, 往往是你的场景悄悄越出了它那个'隐含的、被默认满足的前提'"。这给了我一种使用任何"可靠做法"的根本审慎:采用并依赖一个做法时,要努力去搞清"它的可靠, 依赖于哪些前提?我现在的场景, 还满足这些前提吗?"——尤其当场景发生变化(数据多了新类型、规模变了、并发上来了)时, 要回头检查"那些我依赖的做法, 它们的前提还成立吗?";"知道自己依赖的东西的'可靠边界', 并在场景变化时重新审视边界是否被突破",是避免'一直好用的东西突然背叛你'的关键意识。认清可靠都是有前提的、场景越出隐含前提时一直好用的东西会背叛你——这,是我用一次深拷贝丢类型的事故,换来的、关于 JavaScript、也关于如何审视一切"可靠做法"前提的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次深拷贝带 Date 的对象时,想起换用 structuredClone,那我对着那个 getTime is not a function 排查的这段时间,就值了。
—— 别看了 · 2026