
1. 浅拷贝与深拷贝先把概念彻底拆清楚深拷贝这个话题前端面试几乎逢面必考日常工作里也经常踩坑。先说个我自己的经历有一次做状态管理手一抖用了浅拷贝去更新一个嵌套很深的配置对象结果改了副本原对象的值也跟着变了页面数据全乱套排查了半天才发现是引用共享的问题。从那以后我对“拷贝”这件事就格外上心。要理解深拷贝先得把浅拷贝和深拷贝的区别彻底搞明白。JavaScript里对象是引用类型变量名存的是内存地址的引用不是真实数据本身。你写const a { name: demo }; const b a;这行代码b和a指向的是同一个内存对象改b的属性a同步变化因为它们本就是一个东西。浅拷贝的意思是只拷贝对象的第一层属性。如果属性值是基本类型number、string、boolean、null、undefined、symbol、bigint直接复制值如果属性值是引用类型对象、数组、函数复制的是引用地址。所以浅拷贝的结果嵌套层级的对象依然是共享的。深拷贝则是递归地把每一层都复制一份生成一个完完全全独立的新对象原对象和拷贝对象之间没有任何引用关系改哪个都不影响另一个。举个直观例子const original { user: { name: 张三, skills: [JavaScript, CSS] } }; // 浅拷贝 const shallow { ...original }; shallow.user.name 李四; console.log(original.user.name); // 李四被改了 // 深拷贝 const deep JSON.parse(JSON.stringify(original)); deep.user.name 王五; console.log(original.user.name); // 李四不受影响这就是最核心的区别浅拷贝管住第一层深拷贝管住所有层。理解了这个再看实现方式就顺理成章了。2. JSON序列化方案最便捷的深拷贝但陷阱也多2.1 基本用法与适用场景JSON.parse(JSON.stringify(obj))是大多数人最早接触的深拷贝方式。思路很简单先把对象序列化成JSON字符串再把这个字符串解析成一个全新的对象。因为中间经过了一层纯字符串的传递原来的引用关系被彻底打断所以确实实现了一份“独立的数据快照”。const source { name: 项目A, version: 2.1.0, dependencies: [vue, pinia], owner: { name: 张三, age: 30 } }; const copy JSON.parse(JSON.stringify(source)); copy.owner.age 31; console.log(source.owner.age); // 30互不影响这个方案对纯数据的场景非常可靠日常开发中后端接口返回的数据、需要缓存到本地或传给iframe的配置信息大多都是纯JSON兼容的数据结构用这一行代码就够了。我经常在需要“快速把表单数据复制一份做草稿”的时候用它干净利落。2.2 JSON方案处理不了什么如果是复杂对象JSON方案会暴露出大量短板。我把常见的问题整理成一张表数据类型JSON.stringify 处理结果影响说明Date转成ISO字符串拷贝后丢掉了Date类型变成普通字符串RegExp变成空对象 {}正则完全丢失Function直接丢弃对象里的方法会消失undefined属性被删除普通对象里的undefined属性直接没了Symbol属性被删除Symbol键名和值都没了NaN / Infinity变成 null数值精度信息丢失BigInt直接报错TypeError: Do not know how to serialize a BigIntMap / Set变成空对象 {}内部数据全部丢失循环引用直接报错TypeError: Converting circular structure to JSON我在实际项目里被坑得最深的一次是处理一个包含Map的状态对象。数据里有几个Map用来存字典映射用JSON深拷贝之后Map变成了空对象所有字典查询全返回undefined页面直接白屏。排查了很久才发现是序列化把Map结构摧毁了。还有一个高频踩坑点是undefined。比如接口返回的数据里有可选字段remark: undefined你用JSON深拷贝之后这个字段直接消失了。如果下游代码判断remark in obj结果就完全不同。所以我的建议是JSON方案只适合“纯数据对象”也就是可以被JSON格式完整表达的数据。只要对象里出现函数、日期、正则、Map、Set、BigInt、循环引用这些“非标准JSON元素”就别用它。3. 手写递归深拷贝从零掌握核心原理3.1 第一版只处理普通对象和数组理解深拷贝最好的方式是自己动手写一个递归版本。这个版本虽然简单但能让你把“递归拷贝”的思维建立起来。function deepClone(source) { // 基本类型直接返回包括 null if (source null || typeof source ! object) { return source; } // 根据原对象类型创建容器 const result Array.isArray(source) ? [] : {}; // 遍历所有可用属性 for (let key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { result[key] deepClone(source[key]); } } return result; }这个递归的逻辑链条是如果当前值不是对象直接返回如果当前值是数组就创建数组否则创建普通对象再遍历当前值的每个自有属性递归调用deepClone处理每个属性值。这样一层一层往下走直到所有叶子节点都是基本类型为止。hasOwnProperty.call(source, key)这一步很关键。直接用for...in会把原型链上可枚举的属性也遍历出来加上这个判断才能保证只拷贝实例自身的属性。3.2 升级版解决循环引用用上面的递归版本去拷贝一个循环引用的对象会直接爆栈。什么叫循环引用就是对象A的某个属性指向了A自身或者两个对象互相引用。在树形结构、链表结构、全局状态中很常见。const circular { name: loop }; circular.self circular; // 指向自己 deepClone(circular); // RangeError: Maximum call stack size exceeded原因很好理解递归函数碰到circular.self发现是对象于是继续递归它的属性又碰到self又继续递归……永远没有出口。解决办法是用一个缓存通常用WeakMap或Map记录已经拷贝过的源对象。每次递归前先查缓存如果源对象已经拷贝过直接返回上次拷贝的结果不再进入递归。function deepClone(source, map new WeakMap()) { if (source null || typeof source ! object) { return source; } if (map.has(source)) { return map.get(source); } const result Array.isArray(source) ? [] : {}; map.set(source, result); for (let key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { result[key] deepClone(source[key], map); } } return result; }这里用WeakMap而不是Map的原因WeakMap的键是弱引用不会影响垃圾回收。拷贝结束后如果原对象不再被使用WeakMap里的键值对可以被回收不会造成内存泄漏。这一点在长生命周期的大对象上差异明显。4. 进阶方案特殊类型逐个击破递归处理普通对象和数组只是入门真实业务里的对象类型五花八门。这一节我们把常见特殊类型全部过一遍。4.1 Date、RegExp、Function、Symbol不同类型需要的处理方式完全不同逐个来说。Date对象不能通过遍历属性来拷贝它的时间信息存储在内部槽位中。正确做法是新建一个相同时间戳的Date实例if (source instanceof Date) { return new Date(source.getTime()); }RegExp同理需要把正则的源码和flags取出来重新创建if (source instanceof RegExp) { return new RegExp(source.source, source.flags); }Function通常不需要深拷贝也拷贝不出真正独立的东西。函数在JavaScript里就是可执行对象判断两个函数“相等”没有太大意义。工程上一般直接复用同一个函数引用即可。如果你确实需要“拷贝”函数常见的做法是把函数转成字符串再执行// 不推荐仅作了解 const fnCopy new Function(return source.toString())();这种方式有作用域丢失的风险闭包、上层变量全部会失效不建议在生产环境使用。碰到函数直接返回原引用通常是最合理的选择。Symbol也有两种情况。如果是Symbol作为属性值可以用Symbol(source.description)创建一个新的Symbol如果是Symbol作为属性键for...in遍历不到它需要配合Object.getOwnPropertySymbols或者Reflect.ownKeys才能拿到。// 处理Symbol值 if (typeof source symbol) { return Symbol(source.description); }4.2 Map 和 Set 的完整拷贝Map和Set的底层结构不是普通属性必须通过迭代器把数据取出来再逐一放入新实例。Map还要注意键和值都可能引用对象需要递归处理if (source instanceof Map) { const result new Map(); source.forEach((value, key) { result.set(deepClone(key, map), deepClone(value, map)); }); return result; } if (source instanceof Set) { const result new Set(); source.forEach((value) { result.add(deepClone(value, map)); }); return result; }Map的键也做深拷贝可能有人觉得没必要。但如果你遇到键正好也是一个对象的情况不深拷贝的话新Map里和旧Map里的键指向同一个对象严格说就不算彻底隔离。稳妥起见键值两个都递归处理。4.3 原型链与原型的处理普通对象的原型链怎么处理新手很容易忽视这个问题。其实很多业务对象是通过class创建的拷贝后理想状态是保留原型上的方法否则拷贝出来的对象只有数据没有能力。一种做法是显式设置新对象的原型const result Object.create(Object.getPrototypeOf(source));另一种做法是用Reflect.ownKeys获取所有自有属性包括不可枚举属性和Symbol键然后通过Object.getOwnPropertyDescriptor保留属性描述符比如只读、不可枚举等function deepClone(source, map new WeakMap()) { if (source null || typeof source ! object) return source; if (source instanceof Date) return new Date(source.getTime()); if (source instanceof RegExp) return new RegExp(source.source, source.flags); if (map.has(source)) return map.get(source); // 创建保留原型的空对象 const result Object.create(Object.getPrototypeOf(source)); map.set(source, result); // 获取所有自有属性包括Symbol和不可枚举属性 const keys Reflect.ownKeys(source); for (const key of keys) { const descriptor Object.getOwnPropertyDescriptor(source, key); if (descriptor) { Object.defineProperty(result, key, { ...descriptor, value: deepClone(source[key], map) }); } } return result; }用Reflect.ownKeys配合Object.getOwnPropertyDescriptor是把“拷贝”升级为“复制完整属性语义”的关键一步。这样写出来的拷贝连getter和setter的行为都可以保留。不过要提醒一点如果自定义的getter有副作用这个方案会触发副作用使用时需要评估对象是否适合这种深度复制。5. 工程实践structuredClone 与工具库选型5.1 浏览器的原生方案 structuredClone很多人不知道现代浏览器已经内置了structuredClone方法可以直接完成深拷贝。这是HTML规范提供的结构化克隆算法底层是浏览器的引擎实现性能和覆盖范围都很优秀const copy structuredClone(original);一行代码支持类型非常广Array、ArrayBuffer、Map、Set、Date、RegExp、Blob、File、ImageData、TypedArray等等。我在Chrome和Node.js 17上实测过处理几MB的数据也没有任何性能压力。但structuredClone也有自己的限制不能拷贝函数的源码函数属性会被忽略或报错不同环境行为有差异不能拷贝DOM节点会抛DOMExceptionSymbol属性会被忽略原型不会保留class实例会变成一个普通对象只有实例属性被复制实际项目中如果你的目标数据是“可序列化的结构化数据”structuredClone是一个非常省心的选择比JSON方案覆盖能力强得多。判断路径很简单数据来自接口、配置、缓存用structuredClone或JSON都可以数据里有自定义class实例、函数、DOM就得靠下面说的工具库。需要检查运行环境是否支持的话const hasStructuredClone typeof structuredClone function; if (hasStructuredClone) { const copy structuredClone(original); }5.2 lodash.cloneDeep 的最佳实践工程中用到最多、社区验证做得最充分的还得数 lodash 的cloneDeep。它解决了上面提到的几乎所有问题循环引用、Date、RegExp、Map、Set、Symbol、原型链内置实现里全都处理了。用法非常简单import { cloneDeep } from lodash-es; const copy cloneDeep(original);lodash之所以稳是因为它在一个统一的克隆系统里分层实现了不同数据类型的处理策略并且经过了大量项目多年的边界测试。如果你不想引整个lodash可以用lodash.clonedeep这个单独的包体积会小很多。选择cloneDeep还是原生方案我的建议是项目里有lodash依赖直接用cloneDeep没必要自己重复造轮子没有lodash且数据结构在structuredClone支持范围内优先用structuredClone两者都不满足才需要自己写一个定制版深拷贝5.3 手写版深拷贝的完整代码参考如果业务里有特殊需求比如只拷贝某些字段、对某些类型做特殊转换手写一个定制版是必要的。下面给出一份融合了前面所有思路的完整实现适合作为团队内部复用的基础版本function deepClone(source, map new WeakMap()) { // 基础类型直接返回 if (source null || typeof source ! object) { return source; } // 函数直接复用引用 if (typeof source function) { return source; } // Symbol值创建新Symbol if (typeof source symbol) { return Symbol(source.description); } // 处理循环引用 if (map.has(source)) { return map.get(source); } let result; // 特殊对象类型处理 if (source instanceof Date) { result new Date(source.getTime()); } else if (source instanceof RegExp) { result new RegExp(source.source, source.flags); } else if (source instanceof Map) { result new Map(); map.set(source, result); source.forEach((value, key) { result.set(deepClone(key, map), deepClone(value, map)); }); return result; } else if (source instanceof Set) { result new Set(); map.set(source, result); source.forEach((value) { result.add(deepClone(value, map)); }); return result; } else if (Array.isArray(source)) { result []; map.set(source, result); for (const item of source) { result.push(deepClone(item, map)); } return result; } else { // 普通对象保留原型 result Object.create(Object.getPrototypeOf(source)); map.set(source, result); // 使用Reflect.ownKeys获取包括Symbol和不可枚举属性在内的所有自有键 for (const key of Reflect.ownKeys(source)) { const descriptor Object.getOwnPropertyDescriptor(source, key); if (descriptor) { Object.defineProperty(result, key, { ...descriptor, value: deepClone(source[key], map) }); } } } return result; }这个版本已经能覆盖大部分业务场景。实际使用中如果遇到Error对象、ArrayBuffer、Promise等更偏底层的类型可以继续在特殊对象类型处理的地方追加分支。看需求来不需要一步到位。6. 高频问题排查与性能优化实录6.1 拷贝后修改副本却影响原对象这是最常见的排查场景。现象改拷贝对象的深层属性原对象跟着变。问题几乎都是用了浅拷贝或错误的深拷贝方式。逐个排查这几个位置确认用的是深拷贝而不是Object.assign、展开运算符或Array.prototype.slice确认对象里没有函数、Date、RegExp等JSON方案失效的类型确认没有循环引用否则structuredClone和JSON方案都会挂写个简单的验证代码const copy deepClone(original); copy.a.b.c changed; // 原对象要不受任何影响 if (original.a.b.c changed) { console.log(拷贝不彻底仍然是浅拷贝); }6.2 为什么JSON方式拷贝的Map变成了空对象这个问题我在前面已经提到过亲自踩过坑所以再强调一次。JSON.stringify在序列化Map时不会调用Map的迭代器而是把它当作一个不含自有属性的普通对象序列化结果就是{}。然后JSON.parse解析回来自然就是一个空对象。同类问题还有Set变空对象Date变成字符串RegExp变成空对象undefined字段直接消失定位方法很简单把拷贝前后的数据用JSON.stringify打印出来对比或者用Object.prototype.toString看最终类型。如果数据里有这些非JSON类型立刻换用structuredClone或cloneDeep。6.3 循环引用在JSON方案下报错怎么办TypeError: Converting circular structure to JSON这个报错信息很直白对象存在循环引用JSON.stringify处理不了。实际业务中循环引用出现最频繁的场景树形结构里父节点引用子节点子节点又用parent字段指回父节点或者全局store里某个模块引用了store本身。处理办法是按实际情况选择用structuredClone或cloneDeep它们原生支持循环引用如果必须用JSON方式序列化前手动断掉循环字段比如在遍历时跳过parent这种反向指针使用JSON.stringify的replacer参数遇到已知循环字段返回 undefinedconst safeData JSON.parse(JSON.stringify(node, (key, value) { if (key parent || key self) return undefined; return value; }));这个方式适合“明确知道哪些字段可能有循环”的场景但它只能救急不能作为通用方案。6.4 大对象拷贝的性能优化深拷贝本质上是递归遍历性能与对象规模和嵌套深度直接相关。超大对象几MB甚至几十MB拷贝时会有明显的耗时。我实测过一份约5MB的配置对象递归深拷贝大约耗时在几十毫秒级别可接受但如果放到高频调用路径上就会卡顿。优化思路有几个方向能缓存就缓存如果数据不常变拷贝一次存起来复用避免每次调用都重新拷贝避免不必要的深拷贝有些场景用不可变数据结构比如immer的produce能避免重复拷贝整个大对象优先用native实现structuredClone是浏览器底层实现的多数情况下比纯JavaScript递归快在支持环境里优先选择按需拷贝只拷贝真正会修改的路径而不是整个对象。前端状态管理里“路径拷贝”就是这种思路还遇到过一种情况对象里某个字段是超大数组但业务上只读不改。针对这种字段可以直接复用引用不递归拷贝可以省下大量时间。手写深拷贝时在指定字段上做“浅拷贝策略”很容易这也是手写方案的一个重要优点。6.5 特殊对象类型的坑位速查最后整理一份深拷贝的避坑速查表方便排障的时候对照数据类型常见错误表现正确方案Date变字符串new Date(source.getTime())RegExp变空对象new RegExp(source.source, source.flags)Function报错或丢失直接复用引用Map变空对象new Map() 配合递归setSet变空对象new Set() 配合递归addSymbol键普通遍历丢失Reflect.ownKeys / Object.getOwnPropertySymbolsBigIntJSON方案直接报错手动判断大整数类型处理WeakMap / WeakSet无法枚举放弃深拷贝直接复用或使用结构化克隆Error变成空对象自定义拷贝 message、name、stackPromise变成空对象直接复用引用DOM元素structuredClone抛异常直接复用引用class实例原型丢失Object.create(Object.getPrototypeOf(source))在业务里碰到这些类型先判断这个字段值到底需不需要真正隔离如果只是“展示用”或者“只读”复用引用是完全合理的选择。7. 手写实现还是工具库怎么选才不后悔这个问题几乎每个项目都会纠结一次。我的个人经验按项目阶段和需求复杂度来定就好。一个小项目、临时脚本数据是纯接口返回的JSON对象用JSON.parse(JSON.stringify())或者structuredClone就够了。一个中大型前端项目状态管理里到处是嵌套对象、Map、Set、自定义类实例直接引入cloneDeep不要自己造轮子省下的是排坑的时间成本。如果你在面试或者为团队做公共工具函数手写一个考虑周全的深拷贝非常加分能体现你对引用、原型、属性描述符、迭代器的理解深度。这个手写版本的价值不在于“替代lodash”而在于你真正理解了深拷贝的每一个细节。我自己的做法是默认用cloneDeep或structuredClone遇到不符合它们处理范围的场景才写一个定制深拷贝函数并且只在工具函数库中维护这一个入口不在业务代码里到处复制粘贴手写逻辑。这样可以统一行为出问题也只需要在一个地方修。再说一个团队内部容易被忽视的点深拷贝选择应该沉淀为规范。看过太多项目里同一个文件里既有人用JSON、又有人用展开运算符、还有人自定义了一个只处理普通对象的递归最后排起bug来各怀鬼胎。统一一种推荐方案标注好适用边界比任何“最优解”都更能减少线上故障。这也是我做了这么多年前端最想提醒同行的一点。