深拷贝的目标是复制一个对象的值,而不是复制引用。但JavaScript里对象的成员类型非常复杂,一旦出现Date、RegExp或Map,很多看似可用的方案都会悄悄改变语义。更隐蔽的是,属性描述符、Symbol键和原型链在常规拷贝中经常被忽略,最终得到的对象虽然能用,行为却和原对象不一致。

一、JSON序列化方案会静默丢掉哪些数据
JSON.parse(JSON.stringify(obj))是流传最广的深拷贝写法,但它的限制远不止不能处理函数。JSON.stringify会调用对象上的toJSON方法,如果某个对象自己定义了toJSON,序列化结果可能与原值完全不同。例如Date实例会被转成ISO字符串,RegExp实例会变成空对象,Map和Set实例会变成没有任何键值对的普通对象。这些变化在复制后不会报警,而是静默发生,排查起来非常困难。
另一个容易被忽略的问题是undefined和Symbol。如果对象属性值为undefined,JSON.stringify会直接忽略该属性;如果属性键是Symbol,也会被跳过。数组中的undefined会被转成null,而不是保持undefined。下面这段代码展示了几个典型差异。
const source = {
date: new Date(),
pattern: /ab/g,
map: new Map([['name', 'demo']]),
missing: undefined,
[Symbol('id')]: 1
};
const copy = JSON.parse(JSON.stringify(source));
console.log(copy.date); // 字符串,不再是Date实例
console.log(copy.pattern); // {}
console.log(copy.map); // {}
console.log('missing' in copy); // false
console.log(copy[Symbol('id')]); // undefined
可以看到,JSON方案只适合处理纯JSON数据,也就是由普通对象、数组、字符串、数字、布尔值和null组成的结构。只要数据中混入了一点非JSON类型,就应该放弃这个方案。否则表面上看拷贝成功了,实际已经产生了数据偏差,后续逻辑可能因此出现难以复现的问题。
二、循环引用会让递归拷贝直接崩溃
自己写递归深拷贝时,最经典的错误就是没有处理循环引用。当一个对象通过自身属性直接或间接指向自己时,递归会无限进行下去,最终导致调用栈溢出。即使对象树不是完全循环,只是存在共享引用,也可能让拷贝结果变得不符合预期:原本指向同一个子对象的两个属性,在拷贝后变成了两个独立对象。
解决循环引用的标准做法是引入一个缓存结构,例如WeakMap。每次进入深拷贝函数时,先检查当前对象是否已经复制过,如果复制过就直接返回缓存中的副本。这样不仅能中断循环,还能保持共享引用关系。下面的例子展示了使用WeakMap处理循环引用的基本写法。
function deepClone(value, seen = new WeakMap()) {
if (typeof value !== 'object' || value === null) {
return value;
}
if (seen.has(value)) {
return seen.get(value);
}
const target = Array.isArray(value) ? [] : {};
seen.set(value, target);
for (const key of Reflect.ownKeys(value)) {
target[key] = deepClone(value[key], seen);
}
return target;
}
const a = { name: 'a' };
a.self = a;
const cloneA = deepClone(a);
console.log(cloneA.self === cloneA); // true
WeakMap比Map更适合这个场景,因为它的键是弱引用,不会阻止原对象被垃圾回收。如果使用普通Map,缓存会一直持有原对象和副本的引用,可能导致内存泄漏。实现深拷贝时还要注意数组也是对象,需要先判断Array.isArray,否则数组会被复制成普通对象,丢失length和其他数组行为。
三、特殊内置对象和原型链的克隆难题
深拷贝不止要考虑普通对象和数组。Date、RegExp、Map、Set、ArrayBuffer、Blob、File等内置类型都有自己的内部槽和原型方法,用普通对象字面量复制会丢失这些行为。比如直接复制Date得到的结果只是一个空对象,调用getTime会直接报错;复制RegExp得到空对象,test方法也不存在。正确处理方式是根据原对象的构造器创建同类型实例,再把内部数据填进去。
原型链同样不能忽略。如果原对象是通过class或构造函数创建的,拷贝出来的普通对象会失去原型方法。例如一个Person实例经过简单递归复制后,instanceof Person会返回false,调用原型上的方法也会失败。因此稳健的深拷贝需要保留原型链,可以通过Object.getPrototypeOf获取原型,再用Object.create或构造器创建目标对象。下面的代码展示了一个支持Date、Map、Set和原型链的简化版本。
function cloneSpecial(value, seen = new WeakMap()) {
if (typeof value !== 'object' || value === null) return value;
if (seen.has(value)) return seen.get(value);
let target;
if (value instanceof Date) {
target = new Date(value.getTime());
} else if (value instanceof RegExp) {
target = new RegExp(value.source, value.flags);
} else if (value instanceof Map) {
target = new Map();
seen.set(value, target);
for (const [k, v] of value) {
target.set(cloneSpecial(k, seen), cloneSpecial(v, seen));
}
return target;
} else if (value instanceof Set) {
target = new Set();
seen.set(value, target);
for (const item of value) {
target.add(cloneSpecial(item, seen));
}
return target;
} else {
const proto = Object.getPrototypeOf(value);
target = Object.create(proto);
}
seen.set(value, target);
for (const key of Reflect.ownKeys(value)) {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (descriptor.value !== undefined || 'value' in descriptor) {
descriptor.value = cloneSpecial(descriptor.value, seen);
}
Object.defineProperty(target, key, descriptor);
}
return target;
}
const source = {
date: new Date(),
map: new Map([['key', { count: 1 }]]),
pattern: /ab/gi
};
const copy = cloneSpecial(source);
console.log(copy.date instanceof Date); // true
console.log(copy.map.get('key') !== source.map.get('key')); // true
上面这段代码还存在一些限制,例如没有处理ArrayBuffer、DataView、Error等类型,也没有覆盖所有内置对象。实际项目中更推荐使用成熟库,或者在明确数据结构范围的前提下写针对性克隆函数。万能深拷贝往往代码量巨大,而且任何新出现的类型都可能成为新的漏洞。
四、属性描述符、Symbol键与不可枚举属性
很多深拷贝实现只使用for...in或者Object.keys来遍历属性,这会把不可枚举属性、Symbol键和访问器属性全部漏掉。Object.keys只返回自身可枚举的字符串键,for...in还会遍历原型链上的可枚举属性,两者都无法覆盖完整信息。更准确的做法是使用Reflect.ownKeys,它一次性返回所有字符串键和Symbol键,无论是否可枚举。
拿到键之后,还不能简单地用赋值方式复制属性值。如果原属性是一个访问器属性,赋值会把getter的结果写死成数据属性,丢失原有的访问行为。正确做法是读取属性描述符,通过Object.getOwnPropertyDescriptor获取每个键的配置,再用Object.defineProperty写回目标对象。这样可写性、可枚举性、可配置性以及getter和setter都能完整保留。下面的代码演示了如何复制访问器属性。
const source = {};
Object.defineProperty(source, 'fullName', {
get() {
return this.first + ' ' + this.last;
},
set(value) {
[this.first, this.last] = value.split(' ');
},
enumerable: true,
configurable: true
});
source.first = 'Ada';
source.last = 'Lovelace';
const target = {};
const descriptor = Object.getOwnPropertyDescriptor(source, 'fullName');
Object.defineProperty(target, 'fullName', descriptor);
target.first = 'Grace';
target.last = 'Hopper';
console.log(target.fullName); // "Grace Hopper"
这个例子没有递归深拷贝,但它说明了属性描述符的重要性。如果把访问器属性当作普通数据属性复制,目标对象上的fullName就会变成一个固定字符串,后续修改first或last也不会再触发计算。对于Symbol键,许多序列化和遍历方式会直接忽略它们,但Reflect.ownKeys能正确返回,因此深拷贝应当优先使用这个API。
五、实现深拷贝时的几个工程建议
生产环境中的深拷贝不是单一函数就能完美覆盖所有场景的。首先需要明确数据边界:如果你的数据只包含纯JSON结构,JSON.parse(JSON.stringify(obj))确实是最简单可靠的选择,但前提是数据一定不会出现Date、Map、Set、RegExp、undefined、Symbol和循环引用。如果数据来自表单、接口响应或状态管理,通常纯JSON方案就够了。
如果数据中会混入特殊类型,建议使用经过大量项目验证的库,例如lodash的cloneDeep、structuredClone,或者immer。浏览器原生提供的structuredClone已经能处理循环引用、Date、Map、Set、ArrayBuffer等,但不能复制函数和Symbol,而且某些旧版本浏览器需要polyfill。无论选择哪种工具,都要先写测试用例覆盖你关心的边界,而不是默认某个函数一定能正确处理所有数据。
最后,团队内部最好约定深拷贝的使用位置和次数。频繁对大型对象做深拷贝会带来明显的内存和CPU开销,必要时可以配合不可变数据、结构共享或只读访问来减少复制。理解深拷贝的陷阱,不只是为了写出正确的代码,也是在提醒我们:复杂的对象图往往意味着复杂的所有权关系,复制之前先想清楚是否真的需要完整克隆。
JavaScript深拷贝循环引用特殊对象修改时间:2026-10-05 08:17:49