到structuredClone)
很多人刚开始写 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 统一转成nullconst 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 世界里会被降级成完全不同的东西DateJSON.stringify(new Date())会先调用 Date 的toJSON方法返回一个 ISO 格式的字符串比如2026-05-04T12:00:00.000Z。反序列化回来后这个字段从Date对象变成了普通字符串。你后续想调用getTime()直接报TypeError: date.getTime is not a function。RegExp正则对象序列化后变成一个空对象{}整个正则信息和lastIndex全部丢失。MapJSON.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 全部变成 nullJSON 数据格式不支持NaN和Infinity序列化算法遇到它们会把它们转成nullconst 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.parsestringifystructuredClonelodash 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 到底能表达什么再讨论怎么深拷贝。