先记住这个答案
手写深拷贝时,先判断值是否为 null 或非对象,是则直接返回;否则用 Array.isArray 判断是数组则创建 [],否则创建 {},然后 for...in 遍历可枚举属性并用 hasOwnProperty 过滤出自有属性,递归调用自身复制每个属性值。这样能保证修改副本不影响原对象,但无法处理循环引用和 Date 等特殊内置对象。
- typeof null 为 object,需显式判断 null
- 使用 Array.isArray 区分数组与普通对象
- 递归复制仅限自有可枚举属性
递归深拷贝的类型判断与复制路径
深拷贝的第一步是区分可直接返回的值与需要递归复制的引用容器。可直接返回的值包括 number、string、boolean、undefined、symbol、bigint 等原始值,以及函数(因为函数通常无需深拷贝,直接共享引用)。null 的 typeof 为 'object',但值本身不可扩展,也应直接返回。因此用 source === null || typeof source !== 'object' 作为直接返回条件,能覆盖以上所有情况。
对于引用值,先判断是否为数组:Array.isArray(source) 为真则返回合法结果数组,否则返回普通对象。然后遍历可枚举键并用 hasOwnProperty 过滤出自有键,递归调用自身复制每个属性值,最终得到全新的结构。递归终止条件是遇到底层原始值,保证每个层级都被新创建。
function deepClone(source) {
if (source === null || typeof source !== 'object') 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;
}
// 边界 1:嵌套普通对象
const obj1 = { a: 1, b: { c: 2 } };
const copy1 = deepClone(obj1);
copy1.b.c = 99;
console.log('case1 original:', JSON.stringify(obj1));
console.log('case1 cloned :', JSON.stringify(copy1));
// 边界 2:数组包含对象与子数组
const obj2 = [1, { x: 2 }, [3, 4]];
const copy2 = deepClone(obj2);
copy2[1].x = 88;
console.log('case2 original:', JSON.stringify(obj2));
console.log('case2 cloned :', JSON.stringify(copy2));
// 边界 3:原始值、null、undefined 直接返回
const obj3 = { num: 1, nul: null, undef: undefined };
const copy3 = deepClone(obj3);
console.log('case3 original:', JSON.stringify(obj3));
console.log('case3 cloned :', JSON.stringify(copy3));
console.log('case3 copy.num === obj3.num:', copy3.num === obj3.num);查看输出与解释
case1 original: {"a":1,"b":{"c":2}}
case1 cloned : {"a":1,"b":{"c":99}}
case2 original: [1,{"x":2},[3,4]]
case2 cloned : [1,{"x":88},[3,4]]
case3 original: {"num":1,"nul":null}
case3 cloned : {"num":1,"nul":null}
case3 copy.num === obj3.num: true时间 O(n),n 为所有可枚举节点数;空间 O(d),d 为递归调用栈深度。边界 3 中 undefined 属性因 JSON.stringify 被忽略,但实际存在且副本保持相同值。
表单状态复制与隔离更新
假设一个配置编辑器,用户修改某一层级配置后需要“保存副本”用于对比,同时临时草稿状态独立变化。若仅用 Object.assign 浅拷贝,修改内层对象会污染原数据,因此必须深拷贝。使用上述 deepClone 复制 { theme: { color: { primary: '#333' } }, list: [ { id: 1 } ] },得到完全独立的副本。
在副本上修改 theme.color.primary 或新增数组元素,原始对象不受影响。由于递归遍历不引入任何额外依赖,适用于浏览器或 Node 环境。若数据规模在数千层级以内,调用栈安全;但同一层级属性过多不影响。
实现失效的边界与已知限制
当前实现会忽略非枚举属性、Symbol 键以及原型链上的属性,因为 for...in 只遍历可枚举属性且配合 hasOwnProperty 过滤了继承链。访问器属性会读取其 getter 返回值再拷贝,不会保留 setter。Object.create(null) 创建的无原型对象也能复制,但目标对象是 {},丢失了 null 原型。
遇到循环引用时递归会无限展开直至栈溢出,遇到 Date、Map 等内置类型会产生空对象或错误键。解决方案是引入 WeakMap 记录已访问对象,或对每种内置类型独立分支处理。这些扩展会增加实现复杂度,但基础版本已经覆盖了前后端最常见的 JSON 可序列化数据。
容易答错的地方
- typeof null 误判为对象
- 常见错误是不检查 null,直接写
typeof source === 'object',导致 null 被当作普通对象创建空对象。必须加source === null ||前置判断。 - for...in 遍历到原型链属性
- 忘记
hasOwnProperty时会把构造器原型上的属性复制进克隆,让副本拥有意外键。应始终使用Object.prototype.hasOwnProperty.call过滤。
面试官还会怎么问?
为什么深拷贝不复制函数?
函数通常无需深拷贝,共享引用即可。大多数深拷贝目标关注数据状态,而非行为。若需隔离函数,须自定义策略,但 JSON 无法表达函数,故常跳过。
嵌套层级很深时会爆栈吗?
递归深度受调用栈限制,典型浏览器约一万层。若预计数据层级极深,应改用迭代加栈实现。不过普通业务数据远低于该阈值。
如何让拷贝保留 Symbol 键?
for...in 无法遍历 Symbol,需用 Object.getOwnPropertySymbols(source) 额外递归复制。这属于基础实现之外的扩展,适用于需要符号键的场景。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。