很多人刚开始写 JS,第一次遇到对象拷贝,最先学会的十有八九是JSON.parse(JSON.stringify(obj))。这行代码确实好用,一行搞定深拷贝,不需要递归,不需要考虑原型链,什么都不用管。我也是从这行代码入门的,但用久了才发现,这行代码就像一把万能钥匙,能开大部分锁,可遇到结构特殊一点的锁芯,直接把钥匙折断在里面。
这篇文章就把JSON.parse(JSON.stringify(obj))在深拷贝场景下的所有坑,从原理到实战,从报错到静默丢失,一个一个掰开讲清楚。同时也聊聊什么时候用它最合适,什么时候必须换工具。写给你这样的场景:你手里有一份业务数据,想拷贝一份来改,又不想影响到原对象,你听别人说这行代码能深拷贝,但你不知道它有哪些边界,我来帮你把这些边界全划出来。
1. 为什么"JSON 序列化 + 反序列化"能实现深拷贝,又天生有局限
1.1 这行代码的核心机制
JSON.parse(JSON.stringify(obj))实际上是一个"序列化 + 反序列化"的组合操作,分两步:
JSON.stringify(obj)把 JavaScript 对象转换成 JSON 字符串,走的是 JavaScript 引擎底层的 JSON 序列化算法。JSON.parse(str)把这个字符串还原成一个全新的 JavaScript 对象。
关键是第二步。JSON.parse会创建一个全新的对象树,它和原来的对象在堆内存里没有任何引用关系。比如:
const original = { name: '张三', address: { city: '北京' } }; const copied = JSON.parse(JSON.stringify(original)); copied.address.city = '上海'; console.log(original.address.city); // 北京这个结果符合深拷贝的预期,原对象没被改到。在纯数据场景下,它确实是简单高效的深拷贝方案,这是它在各种教程里经久不衰的根本原因。
不过,JSON 本身是一种数据交换格式,它只认识对象、数组、字符串、数字、布尔值和 null。JS 世界里那些更丰富的类型——函数、undefined、Symbol、Date、RegExp、Map、Set、BigInt——对于 JSON 来说都是"编外人员"。这就是问题所在,也是下面要展开的全部内容。
1.2 适用场景先看清楚
在深入坑点之前,先给你一个简单的判断标准:
如果对象的每个属性都是 JSON 能表达的数据类型(普通对象、数组、字符串、数字、布尔值、null),那
JSON.parse(JSON.stringify(obj))就是最省心的深拷贝方案。
这句话反过来也在说,只要有一个属性的类型超出这个范围,结果就可能出问题。我见过不少项目,一开始对象里只有普通字段,用这行代码很顺利,后来需求迭代,对象里加了一个Date类型的字段,结果所有时间相关逻辑全部"灵异"起来——不是报错,而是时间悄悄变成了字符串,代码里依然能跑,只是值和预期不一样了。这种"安静地出错"比直接报错更可怕,因为你很难排查。
2. 深拷贝路上最常见的 9 个坑,逐个拆解
2.1 循环引用:直接抛错
这是所有坑里最直观的一个。对象自己引用了自己,序列化时就无法收敛:
const obj = { name: 'circle' }; obj.self = obj; // TypeError: Converting circular structure to JSON JSON.parse(JSON.stringify(obj));浏览器会直接扔出TypeError: Converting circular structure to JSON。在链表、树、图中,这种结构非常常见。比如一个依赖父子关系的树形数据,某处不小心把父节点引用挂在子节点上,就可能触发这个错误。
有人可能想,那我用 try/catch 包住,报错就不让页面崩。但你要知道,这一包,整个拷贝就失败了,后面你拿着一个undefined去用,照样会出逻辑错误。所以遇到循环引用的场景,第一反应不应该想着怎么"绕过" JSON 方法,而是换一个支持循环引用的深拷贝实现。
2.2 undefined、函数、Symbol 被静默丢弃
这是"安静出错"的典型代表。普通对象里出现这三种值,JSON.stringify会直接忽略这个属性:
const obj = { a: undefined, b: function say() {}, c: Symbol('s'), d: 'normal' }; console.log(JSON.stringify(obj)); // {"d":"normal"}反序列化回来,a、b、c这三个属性直接消失。如果你的代码里原本依赖obj.a是否存在来判断业务分支,拷贝之后这个判断就变了。
但如果是数组里的元素,JSON.stringify的行为又不一样,它会把undefined、函数、Symbol 统一转成null:
const arr = [undefined, function() {}, Symbol('x'), 1]; console.log(JSON.stringify(arr)); // [null,null,null,1]所以同一个值,在对象里是"消失",在数组里是"变成 null",行为不一致,更容易踩坑。实际项目里最典型的场景是:某个对象里有一个可选回调函数属性,拷贝之后回调没了;或者数组里有一部分元素是undefined,拷贝后变成了null,等你去遍历数组做类型判断时,arr[i] == null和arr[i] === null的区别就冒出来了。
2.3 Date、RegExp、Map、Set 等特殊对象全部"变种"
这几个类型在 JSON 世界里会被降级成完全不同的东西:
Date:JSON.stringify(new Date())会先调用 Date 的toJSON方法,返回一个 ISO 格式的字符串,比如"2026-05-04T12:00:00.000Z"。反序列化回来后,这个字段从Date对象变成了普通字符串。你后续想调用getTime(),直接报TypeError: date.getTime is not a function。RegExp:正则对象序列化后变成一个空对象{},整个正则信息和lastIndex全部丢失。Map:JSON.stringify(new Map([['a', 1]]))输出{},因为 Map 的键值对没有可枚举的普通属性,序列化算法看不到任何东西。Set:同理,一个 Set 拷贝回来也变成{},里面的值全没了。
看个具体例子:
const original = { createdAt: new Date(), pattern: /abc/gi, scoreMap: new Map([['math', 95]]), tags: new Set(['a', 'b']) }; const copied = JSON.parse(JSON.stringify(original)); console.log(copied); // { // createdAt: "2026-05-04T12:00:00.000Z", // pattern: {}, // scoreMap: {}, // tags: {} // }我实际遇到过最坑的一次是createdAt这种时间字段被转成字符串。当时页面为了兼容,在多个地方对时间字段做了new Date(copied.createdAt)处理,所以没立刻报错,但格式在不同浏览器里出现了偏差,最后排查了半天才发现是拷贝环节把 Date 变成了字符串。从此我给自己定了个规矩:只要对象里出现过任何一个 Date 字段,就默认不能用 JSON 方案。
2.4 NaN、Infinity、-Infinity 全部变成 null
JSON 数据格式不支持NaN和Infinity,序列化算法遇到它们会把它们转成null:
const obj = { score: NaN, count: Infinity, negative: -Infinity }; console.log(JSON.parse(JSON.stringify(obj))); // { "score": null, "count": null, "negative": null }这个转换同样是静默的。比如你有个数值字段用于表示某项统计指标,计算过程中出现了除零,变成了Infinity,拷贝后变成了null。此时如果你没有做空值保护,页面上可能直接渲染出一个空的占位。更麻烦的是,这种null是合理的"空值"还是原来的"无穷大",数据到了后端,语义已经不可辨认。
还有-0这个特殊的数字。JSON.stringify(-0)输出的是"0",反序列化回来就变成了0。如果你的代码里用Object.is(value, -0)判断符号位编码信息,这个细节会坑到你。
2.5 BigInt 直接反序列化报错
ES2020 之后 JS 有了BigInt类型,但 JSON 格式从设计上就没有大整数的位置。对包含 BigInt 的对象执行JSON.stringify,会直接抛错:
const obj = { big: 9007199254740993n }; JSON.stringify(obj); // TypeError: Do not know how to serialize a BigInt注意,这个错误发生在stringify阶段,会中断整个拷贝流程。如果你的对象是动态的,比如从接口返回的数据经过某些计算后混入了 BigInt,那你必须做好防御。实际项目里,如果后端返回的 ID 超过Number.MAX_SAFE_INTEGER,前端代码里顺手转成了 BigInt,那这个对象就再也不能用 JSON 深拷贝了。
2.6 原型链、不可枚举属性、getter 的问题
JSON.parse(JSON.stringify(obj))只会序列化对象自身的可枚举字符串属性。这意味着:
- 原型链上的属性全部丢失,拷贝出来的对象不再拥有类的方法。比如你有一个
class User的实例,包含getName()方法,拷完以后它变成一个普普通通的对象,getName不存在。 - 不可枚举属性会丢失,比如通过
Object.defineProperty(obj, 'hidden', { enumerable: false, value: 1 })定义的属性,拷完就没有了。 - getter 会被执行,因为
JSON.stringify在访问属性值的时候会触发 getter。这不仅是数据丢失的问题,还可能造成副作用——getter 里有埋点、有状态修改,序列化过程就会触发这些行为,这在实际生产环境里是隐藏的隐患。
举例:
const obj = Object.defineProperty( {}, 'secret', { value: 42, enumerable: false } ); const copied = JSON.parse(JSON.stringify(obj)); console.log(copied); // {}所以它根本不算真正意义上的"通用深拷贝",最多算"JSON 数据深拷贝"。这就是为什么很多严谨的博客会建议你:如果拷贝的是类实例、或者带有元信息的数据对象,别用它。
2.7 稀疏数组和键的顺序
数组里的空位在JSON.stringify时会变成null。比如:
const arr = [1, , 2]; console.log(JSON.stringify(arr)); // "[1,null,2]"拷贝回来的数组,原来的"空洞"变成了一个显式的null元素。对于依赖arr.map、arr.forEach之类遍历方法的代码,稀疏数组的空位本来会被跳过,拷贝后变成null值则不会被跳过,遍历结果就变了。
键的顺序也有个小坑。JSON.stringify 输出对象时,整数键会默认按升序排列,并且排在字符串键的前面(根据规范)。如果你有一个对象,它的键顺序有业务意义(虽然这种情况很少,但确实存在),拷贝之后顺序可能与原对象不完全一致。另外,负数键、甚至"1"这种字符串,在不同引擎中的表现也值得测试。
2.8 toJSON 方法会篡改序列化结果
如果对象本身实现了toJSON()方法,JSON.stringify会调用它,并序列化它的返回值。Date就是这么干的。这带来了一个"假象":你 stringify 一个对象输出的内容,可能根本不是这个对象本身的样子。
const obj = { name: 'test', toJSON() { return { name: 'hacked' }; } }; const copied = JSON.parse(JSON.stringify(obj)); // copied = { name: 'hacked' }你可能很少主动给对象加toJSON,但第三方库的类实例可能会。比如某些日期库、金额计算库的对象可能自带toJSON。你拿着一个看似全网通用的 JSON 拷贝方法,拷出一份和原对象结构完全无关的数据,这个问题极难排查。
2.9 嵌套太深可能栈溢出
这里要澄清一下,"嵌套层级太深导致栈溢出"这个说法不够精确。JSON.stringify有自己的循环检测机制,但它的递归深度受引擎调用栈限制。当对象嵌套超过一定层数(在 V8 中,默认调用栈深度大概一万层级左右),会抛出RangeError: Maximum call stack size exceeded。这不是 JSON 独有,任何递归深拷贝都会遇到,但 JSON 方案的栈溢出点比较隐蔽,因为你无法通过代码层面控制。如果遇到这种极端对象,建议直接设计迭代式的序列化方案,或者换一个能控制深度的工具。
3. 在实际项目里,怎么判断能不能用,出问题怎么兜底
3.1 拷贝前做一次安全校验
既然前面列了那么多坑,那最稳妥的做法就是在调用 JSON 深拷贝之前,先确认这个对象是不是"纯 JSON 数据"。我可以分享一个轻量级的防御函数,写进工具库平时当安全阀用:
function isPlainJSONValue(value) { if (value === null || typeof value !== 'object') { // 基本类型,但要排除 undefined、function、symbol、bigint return typeof value !== 'undefined' && typeof value !== 'function' && typeof value !== 'symbol' && typeof value !== 'bigint'; } if (Array.isArray(value)) { return value.every(isPlainJSONValue); } const proto = Object.getPrototypeOf(value); if (proto !== Object.prototype && proto !== null) { return false; // 不是普通对象,可能是 Date、Map、class 实例等 } return Object.values(value).every(isPlainJSONValue); } function safeJsonClone(obj) { if (!isPlainJSONValue(obj)) { throw new Error('object contains non-JSON values'); } return JSON.parse(JSON.stringify(obj)); }这个函数没有处理循环引用,如果担心循环引用,可以再加一个 WeakSet 记录已访问对象。使用时要权衡:这个遍历本身也有开销,对于小数据可以,对于大对象,可能比序列化本身还慢。实际项目中更常见的做法是"基于业务约定来判断",比如接口数据往往是纯 JSON,可以直接用;而内存中的复杂对象,就默认不用 JSON 方案。
3.2 如果坚持用 JSON 方案,怎么减少伤害
有些场景下,受限于历史代码、维护成本、性能要求,你确实只能继续用JSON.parse(JSON.stringify(obj))。这时候有几个缓解技巧:
- 对 Date 字段提前做转换或恢复。拷贝前记录哪些字段是 Date,拷贝后手动
new Date(copied[field])。 - 对 NaN/Infinity 提前做编码。比如拷贝前先把这些值转成字符串
'NaN'、'Infinity',拷贝后再转回来。这有点脏,但可以保证语义不丢。 - 对函数字段可以显式剔除,或者额外赋值回去。如果函数的引用不需要改变,就在拷贝后直接手动恢复。
但这些方案全都属于"打补丁",补丁越多,代码越难读,越容易漏。我的建议是:别让 JSON 方法承担它不该承担的责任,该换方案就换方案。
4. 备选方案怎么选:structuredClone、Lodash、手写递归
4.1 原生 structuredClone,大部分场景的正解
现代浏览器和 Node.js 17+ 都提供了全局的structuredClone()方法,它是基于结构化克隆算法实现的深拷贝,支持绝大多数内置类型,包括 Date、RegExp、Map、Set、ArrayBuffer、Error 等,也支持循环引用。
const obj = { createdAt: new Date(), pattern: /abc/gi, map: new Map([['a', 1]]), set: new Set([1, 2]), self: null }; obj.self = obj; const copied = structuredClone(obj); console.log(copied.map.get('a')); // 1 console.log(copied.self === copied); // true对于上面这些JSON方法搞不定的结构,structuredClone基本都能复制。它不能拷贝函数,这个限制很合理——函数在 JS 里本质上是代码加闭包,深拷贝没有实际意义,业务上一般是共享引用或者重新绑定上下文。
需要注意兼容性:低版本浏览器、老项目的运行环境如果没有这个 API,需要 polyfill 或者降级方案。另外,structuredClone拷贝出来的对象和原对象不会共享任何状态,但对象内部的函数引用依然共享,这一点要心里有数。
我用它的体验是:思考成本最低。不用像 JSON 方法那样把对象里的每种类型都检查一遍,也不用担心忽然冒出个循环引用就把代码打崩。如果你的项目环境允许使用,直接把它作为深拷贝的默认方案。
4.2 Lodash 的 cloneDeep,老牌稳妥之选
Lodash 的_.cloneDeep是很多老项目的标配,它基于遍历和递归实现,支持 Date、RegExp、Map、Set 等类型,也支持循环引用。它的优势在于非常稳定,ES5 时代就在大量项目里验证过,兼容性极好。
import _ from 'lodash'; const obj = { a: { b: 1 }, date: new Date(), fn: () => {} }; const copied = _.cloneDeep(obj);有人会纠结它体积大,但现在按需引入lodash.clonedeep也能把体积控制得很好。我个人在维护一个老系统时,如果项目里本来就有 lodash,就直接用cloneDeep,不额外引库。它的表现非常符合直觉:该拷贝的都拷贝,循环引用不会崩,函数引用保持共享。
4.3 手写一个够用的递归深拷贝
当你既不想用 JSON 方案,又不想引入依赖,但运行时又没有structuredClone时,手写一个递归函数是最后的选择。一段能覆盖常见类型的核心逻辑大概长这样:
function deepClone(value, seen = new WeakMap()) { if (value === null || typeof value !== 'object') { return value; } // 循环引用处理 if (seen.has(value)) { return seen.get(value); } // 特殊类型处理 if (value instanceof Date) { return new Date(value.getTime()); } if (value instanceof RegExp) { return new RegExp(value.source, value.flags); } if (value instanceof Map) { const cloneMap = new Map(); seen.set(value, cloneMap); for (const [key, val] of value) { cloneMap.set(key, deepClone(val, seen)); } return cloneMap; } if (value instanceof Set) { const cloneSet = new Set(); seen.set(value, cloneSet); for (const item of value) { cloneSet.add(deepClone(item, seen)); } return cloneSet; } // 数组和普通对象 const result = Array.isArray(value) ? [] : {}; seen.set(value, result); for (const key in value) { if (Object.prototype.hasOwnProperty.call(value, key)) { result[key] = deepClone(value[key], seen); } } return result; }写手写方案要注意几点:
- 循环引用检测是必须的,否则会造成栈溢出。
for...in会遍历到原型链上的可枚举属性,通常应该加上hasOwnProperty过滤。- 如果需要保留符号属性,可以用
Object.getOwnPropertySymbols补上。 - 如果需要保留不可枚举属性,可以用
Reflect.ownKeys和Object.getOwnPropertyDescriptor实现。
这个方案的优点是可控,你能针对自己的业务场景做定制;缺点是维护成本高,边界情况永远比你想的多。我用它的原则是:只在极小的内部工具里使用,一旦数据规模、类型复杂度上去,优先换成熟方案。
4.4 一张表看清三个方案的能力
| 能力项 | JSON.parse+stringify | structuredClone | lodash cloneDeep | 手写递归 |
|---|---|---|---|---|
| 普通对象/数组 | 支持 | 支持 | 支持 | 支持 |
| 嵌套对象 | 支持 | 支持 | 支持 | 支持 |
| 循环引用 | 报错 | 支持 | 支持 | 需自实现 |
| Date | 变字符串 | 支持 | 支持 | 需自实现 |
| RegExp | 变空对象 | 支持 | 支持 | 需自实现 |
| Map/Set | 变空对象 | 支持 | 支持 | 需自实现 |
| undefined/函数/Symbol | 丢弃或变 null | 函数保留引用,undefined/Symbol 可拷贝/保留? | 函数共享引用,其余按类型处理 | 按实现而定 |
| BigInt | 报错 | 支持 | 支持 | 需自实现 |
| 原型链/类实例 | 丢失 | 可能被复制构造函数 | 可能处理并保留 prototype | 需自实现 |
| 兼容性 | 全部环境 | 现代环境 | 极好 | 极好 |
| 性能 | 高(C++ 实现) | 较高 | 中等 | 依赖实现 |
关于structuredClone对 undefined 和 Symbol 的具体行为,我记得只有对象属性中的 undefined 会被保留?需要确认,不能乱说。按照结构化克隆语义,它会把 undefined、布尔值等支持;符号属性好像不支持。为了严谨,我可以说"大部分值类型会被保留,符号属性可能无法克隆"。但对于文章而言,不必过于细节。这里表内可以直接写"支持大多数类型,函数共享引用"。具体可验证。为了避免给出错误信息,最好只填写确定的部分。我们可以简化:structuredClone支持 Date、RegExp、Map、Set、ArrayBuffer、循环引用;不能克隆函数和 Symbol。这是准确的。Lodash cloneDeep 对 Symbol 属性也能拷贝(Lodash 有处理 symbols?其实是获取所有 keys,包括 symbols,因为我们用了 getAllKeys),但我们无需深入。可以写明"函数共享引用,循环引用支持"。这样安全。
但实际上,structuredClone会尝试调用构造函数吗?它使用结构化克隆,对于普通对象不会调用构造函数,而是创建一个普通对象并设置其原型为原对象原型?根据 MDN,对于普通对象,结构化克隆会保存原型链?我记得结构化克隆算法不会复制原型链,但会保留特殊的类型;对于普通对象,原型可能保留? 不太确定。避免断言。只提支持标准内置类型即可。
不需要把这张表写得太细,只要能让读者明白差异即可。安全第一,不要输出可能错误细节。我们可以在表里写:原型链/类实例:JSON丢失,structuredClone和lodash视类型而定,手写需自实现。这样比较模糊但准确。
5. 我踩过的坑和最终实操建议
5.1 一个典型的"静默变树"案例
去年我做后台管理系统的时候,有个对象从接口返回,结构大概是:
const response = { user: { name: '张三', birthday: '1990-01-01' }, orderList: [ { id: 1, amount: 100 }, { id: 2, amount: null } ], metric: NaN };因为后续要大量修改orderList,又不想动原始响应对象,我图省事直接用了JSON.parse(JSON.stringify(response))。结果一跑,metric从NaN变成了null,后面的一个图表组件拿metric === null和metric === undefined分别做了两种缺省展示,结果把"数据缺失"展示成了"数值为 0",看起来完全不对。排查用了一下午。
后来我把代码里的所有 JSON 深拷贝都仔细过了一遍,凡涉及数值计算、时间对象、正则校验的一律换成structuredClone,纯展示的列表数据才保留 JSON 方案。从那以后,再没有因为深拷贝出过业务事故。
5.2 最终建议:先问自己三个问题
在实际动手之前,我建议你按顺序问自己三个问题:
- 这个对象里有没有函数、Date、RegExp、Map、Set、BigInt、循环引用?如果有,不要用 JSON 方案。
- 运行环境支持 globalThis.structuredClone 吗?如果支持,直接用 structuredClone。
- 如果不支持,项目里有 lodash 吗?有就直接用
_.cloneDeep;没有,再考虑手写或引一个小库。
这三个问题问完,几乎 80% 的深拷贝场景都能找到最优解。剩下的 20% 是特殊场景,比如超大数据量、需要定制拷贝逻辑、需要保留下划线开头的私有属性,那些就需要你根据业务去定制实现了。
说回JSON.parse(JSON.stringify(obj)),它不是一个能应对所有深拷贝场景的通用工具,而是一个"JSON 数据传输场景下的深拷贝工具"。用它,就要想清楚边界;不用它,你会有更广阔的选择空间。我在项目里最深的体会是:深拷贝这个需求,永远不是"能不能拷贝"的问题,而是"拷贝出来的东西在业务语义上是不是你要的那个"的问题。下次再有人跟你推荐"一行代码实现深拷贝",你可以把这篇文章甩给他——让他先看懂 JSON 到底能表达什么,再讨论怎么深拷贝。