你可能也遇到过这种情况写好的表格编辑页改了一条数据的备注结果界面上另外几条也跟着变了刷新页面后数据库里也一团糟。前几天我同事就在这个坑里折腾了大半个下午最后定位到问题根源竟然只是一行浅拷贝代码。很多人一听到深拷贝觉得不就是JSON.parse(JSON.stringify())么真到排查问题时会发现远没那么简单。这篇文章就围绕深拷贝的实现方法这件事把浅拷贝和深拷贝的区别、JSON 方案的坑、手写递归深拷贝的完整演进、特殊类型处理、现代原生方案structuredClone以及最后在项目里到底该怎么选型一次讲透。无论你是在面试前临时抱佛脚还是项目里正被某个拷贝 bug 缠住这篇文章都值得你从头看一遍。1. 浅拷贝和深拷贝先搞清楚数据在内存里到底怎么存的1.1 赋值、浅拷贝、深拷贝三兄弟的区别JavaScript 的数据类型分成两大类原始类型和引用类型。原始类型包括string、number、boolean、null、undefined、symbol、bigint它们在变量里存的是实实在在的值赋值时复制一份互不影响。引用类型就完全不一样了Object、Array、Function、Date、Map、Set这些数据变量里存的是一个指向堆内存的引用你可以把它理解为一把钥匙。当你执行const b a时复制的是这把钥匙不是把整间屋子重新盖一遍。于是a和b拿着同一把钥匙开的都是同一间屋子。浅拷贝就是只把第一层拷贝了一份新对象但嵌套的引用类型属性仍然共享同一个引用。深拷贝则是把所有层级的引用类型全部递归复制生成一个与原对象完全独立、互不干扰的新对象。1.2 常见的浅拷贝操作和它们的隐蔽特征先看一段最典型的代码const arr [{ name: 张三, tags: [前端] }]; const newArr [...arr]; newArr[0].tags.push(全栈); console.log(arr[0].tags); // [前端, 全栈]展开运算符...看着像是复制了一份新数组实际上只是让newArr成为一个新的数组容器里面装的每个对象仍然是原来那些对象的引用。Object.assign()、Array.prototype.slice()、Array.prototype.concat()全部都是这个套路。更隐蔽的是 Vue 和 React 项目里常见的这种操作const formData { ...this.row }; // 以为拷贝了一份表单数据这样拷出来第一层确实是新的但如果表单里有嵌套的对象类型字段你在表单里改的其实是原行数据。弹窗一提交表格数据提前被篡改Bug 就是这样悄悄出现的。1.3 怎么快速判断一个拷贝是深是浅判断方法其实很简单拷贝之后往深层的嵌套属性里写值看原对象有没有被改动。原对象动了就是浅拷贝原对象纹丝不动才是深拷贝。const original { a: { b: 1 } }; const shallow { ...original }; shallow.a.b 999; console.log(original.a.b); // 999浅拷贝被连坐了 const deep deepClone(original); // 假设用了正确实现 deep.a.b 123; console.log(original.a.b); // 999深拷贝互不影响记住这个验证思路比背任何概念都管用。接下来我们看为什么很多人第一反应是JSON.parse(JSON.stringify())。2. JSON.parse(JSON.stringify())三行代码省事五个大坑埋人2.1 它为什么能实现伪深拷贝JSON.stringify()把对象序列化成字符串JSON.parse()再把字符串还原成全新的对象。因为中间经过了字符串这个中转站原始对象和最终对象之间没有任何引用关系所以确实能实现深拷贝的效果。写起来也极简单const clone JSON.parse(JSON.stringify(original));问题在于这个方案并不是对所有 JavaScript 值都友善。JSON 是数据交换格式它只认识普通对象、数组、字符串、数字、布尔值和 null。JavaScript 里很多重要的类型在序列化阶段就会被静默处理掉。2.2 五个大坑实测表现我在控制台里实际跑了一遍把各种类型放进去序列化结果整理成了一张表看完你大概就能理解为什么我说它埋人了输入内容JSON.stringify 之后的输出问题说明{ fn: () {} }{}函数被直接丢弃{ u: undefined }{}undefined属性被静默删除[undefined, function(){}][null, null]数组里的undefined和函数变成null{ s: Symbol(x) }{}Symbol 被丢弃new Date()2024-05-12T08:00:00.000ZDate 变成字符串不再是 Date 实例/abc/g{}正则变成空对象NaN/Infinitynull特殊数字变成 null10n直接抛错TypeErrorBigInt 无法被 JSON 序列化循环引用对象直接抛错TypeError: Converting circular structure to JSON连跑都跑不了第一次看到这个表的时候我的反应是这简直是一个JavaScript 特殊值消灭器。尤其是函数被静默丢弃这一点在项目里非常致命。如果你拷贝的对象里某个字段是函数序列化后这个字段直接消失后续代码一调用就报undefined is not a function。2.3 那 JSON 方案真的一无是处吗也不能这么极端。JSON 方案的定位应该是面向纯数据的快速拷贝。如果某个对象的数据来源就是接口返回的 JSON或者用于埋点上报、存储到 localStorage那它本身就是纯 JSON 结构用这个方案完全没问题而且性能非常好。我在实际项目里的判断标准很简单对象里有没有函数、Date、RegExp、Map、Set、BigInt没有那就放心用 JSON有任何一个就立刻换方案。技术选型最怕的不是方案有缺点而是你不知道方案的缺点在什么场景下会爆发。2.4 顺手加个补丁用 replacer 处理 BigInt如果只是偶尔遇到 BigInt可以在第二个参数里加一个 replacerconst clone JSON.parse( JSON.stringify(obj, (key, value) typeof value bigint ? value.toString() : value ) );这样大整数会变成字符串至少不报错了。但这个补丁也改变不了函数和循环引用的问题所以它只适合特定场景别指望它能包治百病。3. 手写递归深拷贝从入门版本到能处理循环引用的完整演进3.1 第一版思路最直接的递归遍历手写深拷贝的经典递归版本大概是这样的function deepClone(source) { if (typeof source ! object || source null) { return source; // 原始类型直接返回 } const target Array.isArray(source) ? [] : {}; for (const key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] deepClone(source[key]); } } return target; }思路非常清晰遇到对象就创建新容器然后逐层递归复制每一个属性。它解决的是核心问题——嵌套对象里的引用也要重新生成一份。但第一版的问题也很明显遇到循环引用就死给你看。const obj {}; obj.self obj; deepClone(obj); // RangeError: Maximum call stack size exceeded为什么会爆栈因为obj.self指向它自己递归函数在复制self属性时会发现它仍然是对象于是继续往self.self走无限递归下去直到调用栈被撑爆。3.2 第二版用 WeakMap 干掉循环引用要解决循环引用核心思路是记录哪些对象已经被拷贝过了。当一个对象再次出现时直接返回之前拷贝的结果而不是重新递归。function deepClone(source, cache new WeakMap()) { if (typeof source ! object || source null) { return source; } if (cache.has(source)) { return cache.get(source); } const target Array.isArray(source) ? [] : {}; cache.set(source, target); for (const key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] deepClone(source[key], cache); } } return target; }关键逻辑就两行cache.set(source, target)在开始拷贝当前对象前先注册原对象 - 新对象的映射cache.has(source)在每次递归开始时检查当前对象是不是已经在拷贝过程中了。这里有个技术点值得展开为什么要用 WeakMap而不是 MapWeakMap 的键是弱引用不会阻止垃圾回收。这意味着深拷贝函数执行结束、栈上的临时变量全部释放后WeakMap 里的[原对象, 新对象]映射可以随时被 GC 清理。如果换成Map它会一直持有原对象和新对象的强引用在原对象已经不再被业务使用的情况下这部分内存仍然无法被回收。在循环引用场景下老的 Map 方案还会出现一个经典问题拷贝完对象之后cache里的引用还拽着原对象内存迟迟归还不了。用 WeakMap 就是为了避免这种隐患。3.3 第三版用 Reflect.ownKeys 补齐 Symbol 和不可枚举属性第一版和第二版都用for...in遍历属性它只能拿到可枚举的字符串键。这意味着对象上的Symbol属性不会被拷贝不可枚举属性不会被拷贝一个更完备的做法是使用Reflect.ownKeys()它返回对象自身的所有键包括字符串键和 Symbol 键可枚举的不可枚举的一网打尽function deepClone(source, cache new WeakMap()) { if (typeof source ! object || source null) { return source; } if (cache.has(source)) { return cache.get(source); } const target Array.isArray(source) ? [] : {}; cache.set(source, target); for (const key of Reflect.ownKeys(source)) { target[key] deepClone(source[key], cache); } return target; }不过要注意for...in遍历字符串键时只会遍历可枚举属性而Reflect.ownKeys会把不可枚举属性也复制过去。对深拷贝来说这更符合完完整整克隆一份的语义。但如果你用的是工具库比如 lodash 的cloneDeep它内部其实会更精细地处理属性描述符这是后话。这里还能再往前走一步用Object.getOwnPropertyDescriptors()获取完整的属性描述符再通过Object.defineProperties定义到新对象上从而连不可写不可枚举getter/setter这些属性特征一起保留。这一层比较深普通业务代码不太需要但如果你在做底层工具库或者面试聊到深拷贝的极致实现能说出这一步会很加分。4. 进阶类型逐一攻破Date、RegExp、Map、Set、Symbol 怎么拷4.1 判断类型不再用 typeofObject.prototype.toString 的妙用前面几版的递归深拷贝对Date、RegExp、Map、Set这些类型完全无能为力。typeof对它们统一返回object而在后面的遍历逻辑里这些对象会被当成普通{}拷出来是一堆空壳。真正可靠的类型判断方式是借用一个老技巧function getType(value) { return Object.prototype.toString.call(value).slice(8, -1); } getType(new Date()); // Date getType(/abc/g); // RegExp getType(new Map()); // Map getType(new Set()); // Set getType(null); // Null getType(undefined); // Undefined getType(Symbol(a)); // Symbol原理是Object.prototype.toString这个通用方法会读取对象内部的[[Symbol.toStringTag]]属性返回[object 类型名]格式的字符串。绝大多数内置类型都支持这个机制它比instanceof更可靠因为instanceof在跨 iframe、跨 window 的场景下会失效。4.2 五种特殊类型的拷贝策略对照类型拷贝策略说明Datenew Date(source.getTime())用时间戳重新创建避免直接new Date(source)在不同实现下的兼容问题RegExpnew RegExp(source.source, source.flags)用匹配文本和标志位重新构造Map新建空 Map遍历source的 entries 递归拷贝key 和 value 都要深拷贝Set新建空 Set遍历source的 values 递归拷贝每个 value 都要深拷贝ArrayBuffersource.slice()ArrayBuffer 的 slice 本身就返回新的 ArrayBuffer在完整实现里getType()的结果就是分支判断的依据。我给出一个整合后的示例它在前面的递归基础上增加了对Date、RegExp、Map、Set的处理function deepClone(source, cache new WeakMap()) { if (typeof source ! object || source null) { return source; } if (cache.has(source)) { return cache.get(source); } const type Object.prototype.toString.call(source).slice(8, -1); let target; switch (type) { case Date: target new Date(source.getTime()); break; case RegExp: target new RegExp(source.source, source.flags); break; case Map: target new Map(); cache.set(source, target); source.forEach((value, key) { target.set(deepClone(key, cache), deepClone(value, cache)); }); return target; case Set: target new Set(); cache.set(source, target); source.forEach((value) { target.add(deepClone(value, cache)); }); return target; case Array: target []; break; case Object: // 用 Object.create 保留原型 target Object.create(Object.getPrototypeOf(source)); break; default: // ArrayBuffer 等类型按需处理 if (source instanceof ArrayBuffer) { target source.slice(0); cache.set(source, target); return target; } return source; } cache.set(source, target); for (const key of Reflect.ownKeys(source)) { target[key] deepClone(source[key], cache); } return target; }4.3 原型要不要保留自定义类的实例怎么处理上面代码里有一行很关键target Object.create(Object.getPrototypeOf(source))。这行保证了拷贝出来的普通对象仍然拥有原来的原型。比如一个类实例person原型上有sayHello()方法拷贝出来的对象也还是同一个类的实例调用方法不会出问题。如果这里你图省事写成target {}那么类实例拷贝后会变成一个普通的Object原型上的方法全部丢光。假设有代码依赖person instanceof User这样的判断整个逻辑就崩了。当然是否保留原型取决于使用场景。如果你只是想深拷贝一份纯数据不希望额外保留任何原型链那也可以用target {}。但在手写工具函数时保留原型是更通用的行为也更接近 lodash 这些成熟库的选择。5. 现代浏览器的原生答案structuredClone API5.1 用起来有多简单如果你不想自己维护一套手写深拷贝逻辑现代浏览器已经提供了一个原生 API 叫structuredClone。它从 2022 年前后开始被主流浏览器广泛支持Node.js 17 也内置了。用法简单到不能再简单const clone structuredClone(original);它是浏览器底层结构化克隆算法的直接暴露支持的类型非常全Date、RegExp、Map、Set、ArrayBuffer、Blob、File、ImageData等等都能正确处理。我在实际项目里用它处理接口返回的复杂数据明显比手写递归代码更省心性能也好得多因为它是在原生层面实现的内存复制。5.2 它有哪些明确不支持的场景原生方案好用但绝对不是什么都能拷。我整理了几个典型的雷区场景表现函数抛出DataCloneErrorSymbol值为 Symbol 时抛出DataCloneError作为属性键时被静默忽略DOM 节点抛出DataCloneError属性描述符拷贝后不保留getter/setter 与访问器特性丢失原型链不保留自定义对象的原型拷贝结果偏向普通对象WeakMap / WeakSet直接抛出DataCloneError特别注意前两条structuredClone不是JSON.stringify那种遇到不支持的属性就静默忽略它遇到函数和 Symbol 值会直接抛异常。这意味着如果你拷贝一个带函数方法的业务对象代码会当场崩掉。另外它虽然会拷贝对象属性但不会保留访问器描述符比如 getter 会在拷贝过程中被调用然后拿返回值当普通属性存进新对象。5.3 有没有让 structuredClone 更通用的用法有一个实际可行的组合思路用structuredClone作为默认方案外面包一层try...catch捕获到DataCloneError时再降级到自研的递归深拷贝。这个方案兼顾了性能和完备性function safeDeepClone(source) { try { return structuredClone(source); } catch (error) { // 手写深拷贝兜底 return legacyDeepClone(source); } }把降级逻辑交给异常捕获来触发主路径保持在原生方案的高速上。个人经验是真正会走到降级分支的数据量并不多但兜底函数能让你在遇到函数、DOM 等特殊数据时不至于手忙脚乱。6. 引入第三方库与最终选型项目里到底该选哪种6.1 lodash 的 cloneDeep 为什么被当作业界标准聊到深拷贝避不开一个名字lodash.cloneDeep。它不是性能最好的方案但它是覆盖场景最全、兼容性最稳的方案之一长期被当作 Web 项目里的标准答案。为什么这么说因为 lodash 的cloneDeep在内部处理了大量边界情况循环引用的智能探测Date、RegExp、ArrayBuffer、TypedArray、Map、Set等类型的完整支持保留原型和属性描述符BufferNode 环境等平台特定类型的适配对Symbol键属性的处理这些边界情况自己手写一套可能要花大半天还要经过长时间踩坑才能稳定下来。如果项目里本来就引用了 lodash直接用_.cloneDeep(data)是最省事、最稳妥的选择这一点没有任何心理负担。顺便说一下近两年开始流行的一些更轻量的工具库比如es-toolkit、radash也提供了自己的深拷贝实现它们在体积上面有优势。选择的时候重点关注它是否覆盖了你业务里会用到的所有类型别只看包大小。6.2 实际场景选型参考表我根据自己的实际经验整理了一张表可以直接对照着选场景推荐方案原因接口返回的纯 JSON 数据JSON.parse(JSON.stringify())轻量、无依赖、速度快现代浏览器内部功能需要 Dates/Maps/Blob 等structuredClone原生支持性能最好需要处理循环引用 常规业务对象手写递归 WeakMap灵活可控无外部依赖需要完整特殊类型支持不想自己造轮子lodash.cloneDeep或类似工具库覆盖全面社区验证充分对象包含函数、Symbol、自定义原型且不能抛错自研递归兜底保留函数和原型行为可控6.3 生产与性能大数据量下别踩的坑最后必须提醒一件事深拷贝是有成本的。它的复杂度为 O(n)n 是对象节点总数。一个 5MB 的 JSON 对象做深拷贝耗时也许只有几十毫秒但如果你的数据是几十 MB 甚至上百 MB而且是在用户点击的关键路径上触发页面卡顿会非常明显。电视剧、报告、日志类大对象都要考虑这一点。我的一个实际教训是有个项目在做历史快照对比的时候直接对整棵状态树做JSON.parse(JSON.stringify())数据一涨上去用户明显感觉按钮按了之后要过一两秒才有反应。后来改成分层快照加引用对比就顺畅了。这类问题的核心不是深拷贝本身该不该用而是它不应该在每次渲染或者每次交互时都被调用。在 React/Vue 项目里如果只是要更新一个字段优先考虑不可变更新的写法比如展开运算符配合层级修改或者使用immer这类的库。深拷贝是最后的手段它解决的是需要一份完全独立的数据的问题而不是局部更新状态的问题。最后说说我的个人选型习惯。如果是写业务代码我先问自己三个问题这份数据里面有没有函数和特殊类型有没有循环引用我能不能接受引入一个函数来搞定一切如果答案分别是不需要、不需要、能接收那JSON.parse(JSON.stringify())就够了。如果是需要保留函数、Symbol 和原型我会直接用自己做的一套递归深拷贝。要是项目里已经装了 lodash那我毫不犹豫用_.cloneDeep不为别的就因为它已经替我把各种边界条件都踩平了。工具没有绝对的好坏搞清楚每种方案的能力边界和适用条件比背一百个版本的实现都要值钱。