手写高质量深拷贝:攻克循环引用、Symbol、WeakMap等核心难点
引言
在JavaScript开发中,拷贝数据是日常高频操作。浅拷贝仅能复制数据的表层结构,对于引用类型(Object、Array、Map、Set等),拷贝后仍会共享底层数据,修改新数据会同步影响原数据。而深拷贝的核心目标是创建一个与原数据完全独立的新数据副本,所有层级的引用类型都被重新创建,修改副本不会对原数据产生任何影响。
原生提供的拷贝方案(如JSON.parse(JSON.stringify()))存在诸多局限性:无法处理循环引用、会丢失Symbol类型属性、无法拷贝特殊引用类型(Map、Set、WeakMap)、会忽略undefined/函数属性等。要实现一个健壮、全面的深拷贝,必须手动攻克这些核心难点。本文将从浅拷贝与深拷贝的区别入手,逐步拆解深拷贝的实现逻辑,重点解决循环引用、Symbol、WeakMap等关键问题,最终实现一个工业级可用的深拷贝函数。
一、前置认知:浅拷贝 vs 深拷贝 vs 赋值
在动手实现深拷贝前,首先要明确赋值、浅拷贝、深拷贝三者的核心差异,这是理解深拷贝本质的基础。
1.1 赋值(赋值引用)
赋值操作仅传递引用类型的内存地址,原变量与新变量指向同一个数据对象,任何一方修改都会同步影响另一方。
ini
const obj = { name: "张三", hobbies: ["篮球", "游泳"] };
const obj2 = obj; // 仅赋值引用
obj2.name = "李四";
obj2.hobbies.push("跑步");
console.log(obj.name); // 李四(被修改)
console.log(obj.hobbies); // ["篮球", "游泳", "跑步"](被修改)
1.2 浅拷贝(表层拷贝)
浅拷贝仅复制数据的表层结构,对于基本类型(string、number、boolean等)会复制具体值,对于引用类型仅复制内存地址,深层引用类型仍会共享。
常见浅拷贝方法:Object.assign()、扩展运算符{...}、Array.prototype.slice()。
ini
const obj = { name: "张三", hobbies: ["篮球", "游泳"] };
const obj2 = { ...obj }; // 浅拷贝
obj2.name = "李四"; // 修改表层基本类型,不影响原数据
obj2.hobbies.push("跑步"); // 修改深层引用类型,影响原数据
console.log(obj.name); // 张三(不受影响)
console.log(obj.hobbies); // ["篮球", "游泳", "跑步"](被修改)
1.3 深拷贝(全层级拷贝)
深拷贝会递归遍历原数据的所有层级,为每一层的引用类型创建新的实例,最终生成一个与原数据完全独立的副本,两者无任何内存共享。
javascript
const obj = { name: "张三", hobbies: ["篮球", "游泳"] };
const obj2 = deepClone(obj); // 手写深拷贝
obj2.name = "李四";
obj2.hobbies.push("跑步");
console.log(obj.name); // 张三(不受影响)
console.log(obj.hobbies); // ["篮球", "游泳"](不受影响)
二、深拷贝的核心难点剖析
手写深拷贝的核心挑战不在于表层数据的复制,而在于处理各种特殊场景和数据类型,其中最关键的三个难点如下:
- 循环引用问题 :原数据中存在自身引用或相互引用(如
obj.self = obj),递归拷贝时会陷入无限循环,最终导致栈溢出。 - Symbol类型处理:Symbol是ES6新增的基本数据类型,可作为对象的属性名,原生浅拷贝方案无法正确复制Symbol属性,深拷贝需要主动识别并拷贝Symbol类型的键与值。
- 特殊引用类型处理:除了普通Object和Array,JavaScript还有Map、Set、WeakMap、WeakSet等内置引用类型,这些类型有专属的构造函数和数据结构,需要针对性处理才能完成正确拷贝。
- 额外难点:处理undefined、null、函数、日期、正则等特殊数据,保证拷贝后的数据类型和功能与原数据一致。
三、分步实现:从基础版本到工业级版本
我们将采用"循序渐进"的方式,从最基础的递归拷贝开始,逐步加入对各难点的解决方案,最终实现一个健壮的深拷贝函数。
3.1 版本1:基础递归拷贝(仅支持普通Object/Array)
该版本实现核心递归逻辑,能够处理普通对象和数组的深拷贝,但无法解决循环引用、Symbol、特殊引用类型等问题。
实现思路
- 先判断数据类型,对于基本类型(除Symbol外),直接返回原值(基本类型赋值即拷贝)。
- 对于Array,创建新数组,递归遍历原数组的每一项,拷贝后放入新数组。
- 对于Object,创建新对象,遍历原对象的可枚举属性,递归拷贝属性值后赋值给新对象。
- 对于其他未处理的类型,暂时直接返回原值。
javascript
/**
* 版本1:基础递归深拷贝(仅支持普通Object/Array)
* @param {*} target 要拷贝的目标数据
* @returns 拷贝后的新数据
*/
function deepCloneV1(target) {
// 1. 处理基本类型(null 单独判断,因为 typeof null === 'object')
if (target === null || typeof target !== 'object') {
return target;
}
// 2. 处理 Array
let result;
if (target instanceof Array) {
result = [];
for (let i = 0; i < target.length; i++) {
result[i] = deepCloneV1(target[i]);
}
}
// 3. 处理 普通 Object
else if (target instanceof Object) {
result = {};
for (const key in target) {
// 仅拷贝自身可枚举属性(排除原型链属性)
if (target.hasOwnProperty(key)) {
result[key] = deepCloneV1(target[key]);
}
}
}
// 4. 其他未处理的类型(Map、Set、Symbol 等)
return result;
}
测试验证
javascript
// 测试普通对象
const obj1 = { name: "张三", age: 20, hobbies: ["篮球", "游泳"] };
const obj1Clone = deepCloneV1(obj1);
obj1Clone.hobbies.push("跑步");
console.log(obj1.hobbies); // ["篮球", "游泳"](拷贝成功)
console.log(obj1Clone.hobbies); // ["篮球", "游泳", "跑步"]
// 测试循环引用(会报错:Maximum call stack size exceeded 栈溢出)
const obj2 = { name: "李四" };
obj2.self = obj2; // 循环引用
// const obj2Clone = deepCloneV1(obj2); // 执行会栈溢出
// 测试 Symbol 属性(无法拷贝)
const symKey = Symbol("id");
const obj3 = { [symKey]: 123, name: "王五" };
const obj3Clone = deepCloneV1(obj3);
console.log(obj3Clone[symKey]); // undefined(Symbol 属性丢失)
存在问题
- 无法处理循环引用,会导致栈溢出。
- 无法拷贝Symbol类型的属性名和属性值。
- 无法处理Map、Set、WeakMap等特殊引用类型。
- 无法处理日期、正则等特殊对象。
3.2 版本2:解决Symbol类型拷贝问题
Symbol类型有两种使用场景:作为对象的属性名、作为属性值。要正确拷贝Symbol,需要:
- 遍历对象时,不仅要遍历普通字符串键,还要遍历Symbol键(使用
Object.getOwnPropertySymbols())。 - 对于属性值为Symbol类型的,直接返回新的Symbol(或原值,Symbol是基本类型,赋值即拷贝)。
实现思路
- 增加对Symbol类型的判断,若目标是Symbol类型,直接返回
Symbol(target.description)(保持描述一致,创建新的Symbol实例)。 - 处理普通Object时,先遍历普通字符串键,再遍历Symbol键,将所有自身属性拷贝到新对象。
javascript
/**
* 版本2:解决 Symbol 类型拷贝问题
* @param {*} target 要拷贝的目标数据
* @returns 拷贝后的新数据
*/
function deepCloneV2(target) {
// 1. 处理基本类型(包含 null)
if (target === null || typeof target !== 'object') {
// 单独处理 Symbol 基本类型
if (typeof target === 'symbol') {
return Symbol(target.description);
}
return target;
}
// 2. 处理 Array
let result;
if (target instanceof Array) {
result = [];
for (let i = 0; i < target.length; i++) {
result[i] = deepCloneV2(target[i]);
}
}
// 3. 处理 普通 Object(包含 Symbol 键)
else if (target instanceof Object) {
result = {};
// 3.1 遍历普通字符串键
for (const key in target) {
if (target.hasOwnProperty(key)) {
result[key] = deepCloneV2(target[key]);
}
}
// 3.2 遍历 Symbol 键
const symbolKeys = Object.getOwnPropertySymbols(target);
for (const symKey of symbolKeys) {
if (target.hasOwnProperty(symKey)) {
result[symKey] = deepCloneV2(target[symKey]);
}
}
}
// 4. 其他未处理的类型
return result;
}
测试验证
javascript
// 测试 Symbol 属性名和属性值
const symKey = Symbol("id");
const symValue = Symbol("userName");
const obj4 = {
[symKey]: 123,
name: symValue,
info: { age: 20 }
};
const obj4Clone = deepCloneV2(obj4);
console.log(obj4[symKey]); // 123
console.log(obj4Clone[symKey]); // 123(Symbol 键拷贝成功)
console.log(obj4.name === obj4Clone.name); // false(Symbol 值创建了新实例,拷贝成功)
console.log(obj4.name.description); // userName
console.log(obj4Clone.name.description); // userName(描述保持一致)
obj4Clone.info.age = 30;
console.log(obj4.info.age); // 20(深层拷贝成功)
存在问题
- 仍无法处理循环引用,会栈溢出。
- 无法处理Map、Set、WeakMap等特殊引用类型。
3.3 版本3:解决循环引用问题(核心:WeakMap 缓存)
循环引用的本质是"递归过程中,再次遇到已经拷贝过的对象",要解决这个问题,需要通过一个缓存容器记录已经拷贝过的对象,当再次遇到该对象时,直接返回缓存中的副本,而不是继续递归拷贝。
为什么选择 WeakMap 作为缓存容器?
- 键可以是对象类型:我们需要以"原对象"作为键,"拷贝后的新对象"作为值,Map和WeakMap都支持对象作为键,而普通Object仅支持字符串/Symbol作为键。
- 弱引用特性,避免内存泄漏:WeakMap的键是对对象的弱引用,当原对象不再被其他变量引用时,垃圾回收机制(GC)可以直接回收该对象,不会因为缓存容器的强引用而导致内存无法释放。如果使用Map作为缓存,会形成强引用,即使原对象被销毁,缓存中的对象也无法被GC回收,长期运行会导致内存泄漏。
- 自动清理无效引用:WeakMap不会阻止垃圾回收,当缓存的对象被回收后,对应的键值对会自动从WeakMap中移除,无需手动清理,更适合作为深拷贝的缓存容器。
实现思路
-
新增一个
cache参数(WeakMap类型),用于缓存已拷贝的对象,默认值为new WeakMap()。 -
递归拷贝前,先判断
cache中是否存在当前目标对象:- 若存在,直接返回缓存中的新对象,终止递归。
- 若不存在,创建新对象(数组/普通对象),将"原对象-新对象"的映射存入
cache,再进行递归拷贝。
-
为了方便调用,对外暴露的函数无需传入
cache,内部递归时传递cache。
javascript
/**
* 版本3:解决循环引用问题(使用 WeakMap 缓存)
* @param {*} target 要拷贝的目标数据
* @param {WeakMap} cache 缓存容器,记录已拷贝的对象
* @returns 拷贝后的新数据
*/
function deepCloneV3(target, cache = new WeakMap()) {
// 1. 处理基本类型(包含 null、Symbol)
if (target === null || typeof target !== 'object') {
if (typeof target === 'symbol') {
return Symbol(target.description);
}
return target;
}
// 2. 处理循环引用:若缓存中存在,直接返回缓存的新对象
if (cache.has(target)) {
return cache.get(target);
}
// 3. 处理 Array
let result;
if (target instanceof Array) {
result = [];
// 3.1 存入缓存(原对象 -> 新对象)
cache.set(target, result);
// 3.2 递归拷贝数组项
for (let i = 0; i < target.length; i++) {
result[i] = deepCloneV3(target[i], cache);
}
}
// 4. 处理 普通 Object(包含 Symbol 键)
else if (target instanceof Object) {
result = {};
// 4.1 存入缓存
cache.set(target, result);
// 4.2 遍历普通字符串键
for (const key in target) {
if (target.hasOwnProperty(key)) {
result[key] = deepCloneV3(target[key], cache);
}
}
// 4.3 遍历 Symbol 键
const symbolKeys = Object.getOwnPropertySymbols(target);
for (const symKey of symbolKeys) {
if (target.hasOwnProperty(symKey)) {
result[symKey] = deepCloneV3(target[symKey], cache);
}
}
}
// 5. 其他未处理的类型
return result;
}
测试验证
javascript
// 测试循环引用(不再栈溢出,拷贝成功)
const obj5 = { name: "李四", age: 25 };
obj5.self = obj5; // 自身循环引用
obj5.friend = { name: "王五", ref: obj5 }; // 相互循环引用
const obj5Clone = deepCloneV3(obj5);
console.log(obj5Clone.self === obj5Clone); // true(循环引用处理成功,指向新对象自身)
console.log(obj5Clone.friend.ref === obj5Clone); // true(相互引用处理成功)
console.log(obj5Clone.name); // 李四
console.log(obj5Clone.age); // 25
// 修改副本,不影响原数据
obj5Clone.age = 30;
console.log(obj5.age); // 25
console.log(obj5Clone.age); // 30
存在问题
- 仍无法处理Map、Set、WeakMap等特殊引用类型。
- 无法处理日期、正则等特殊对象。
3.4 版本4:支持 Map、Set、WeakMap 等特殊引用类型
JavaScript中的内置引用类型(Map、Set、WeakMap、WeakSet、Date、RegExp)都有专属的构造函数和数据结构,拷贝时需要:
- 先识别数据类型,通过
Object.prototype.toString.call()获取准确的类型标识。 - 调用对应类型的构造函数创建新实例。
- 针对性遍历数据内容,递归拷贝后存入新实例。
关键说明
- Map :可通过
entries()遍历键值对,键和值都需要深拷贝,新实例通过new Map()创建。 - Set :可通过
values()遍历成员,成员需要深拷贝,新实例通过new Set()创建。 - WeakMap :由于WeakMap的键是弱引用,且不支持遍历(没有
entries()、keys()等方法),无法直接拷贝其键值对,本文采用"创建空WeakMap实例"的方案(若需完整拷贝,需借助额外API,且不符合WeakMap的设计初衷)。 - Date/RegExp :Date直接通过
new Date(target)拷贝,RegExp通过new RegExp(target.source, target.flags)拷贝。
javascript
/**
* 版本4:支持 Map、Set、WeakMap 等特殊引用类型
* @param {*} target 要拷贝的目标数据
* @param {WeakMap} cache 缓存容器,记录已拷贝的对象
* @returns 拷贝后的新数据
*/
function deepCloneV4(target, cache = new WeakMap()) {
// 1. 处理基本类型(包含 null、Symbol)
if (target === null || typeof target !== 'object') {
if (typeof target === 'symbol') {
return Symbol(target.description);
}
return target;
}
// 2. 处理循环引用:若缓存中存在,直接返回缓存的新对象
if (cache.has(target)) {
return cache.get(target);
}
// 3. 获取准确的数据类型标识
const type = Object.prototype.toString.call(target);
let result;
// 4. 处理 Array
if (type === '[object Array]') {
result = [];
cache.set(target, result);
for (let i = 0; i < target.length; i++) {
result[i] = deepCloneV4(target[i], cache);
}
}
// 5. 处理 Object(普通对象)
else if (type === '[object Object]') {
result = {};
cache.set(target, result);
// 5.1 遍历普通字符串键
for (const key in target) {
if (target.hasOwnProperty(key)) {
result[key] = deepCloneV4(target[key], cache);
}
}
// 5.2 遍历 Symbol 键
const symbolKeys = Object.getOwnPropertySymbols(target);
for (const symKey of symbolKeys) {
if (target.hasOwnProperty(symKey)) {
result[symKey] = deepCloneV4(target[symKey], cache);
}
}
}
// 6. 处理 Map
else if (type === '[object Map]') {
result = new Map();
cache.set(target, result);
for (const [key, value] of target.entries()) {
// Map 的键可以是任意类型,需要深拷贝键和值
result.set(deepCloneV4(key, cache), deepCloneV4(value, cache));
}
}
// 7. 处理 Set
else if (type === '[object Set]') {
result = new Set();
cache.set(target, result);
for (const value of target.values()) {
result.add(deepCloneV4(value, cache));
}
}
// 8. 处理 WeakMap(无法遍历,创建空实例)
else if (type === '[object WeakMap]') {
result = new WeakMap();
cache.set(target, result);
// 注意:WeakMap 不支持遍历,无法拷贝具体键值对,仅创建空实例
}
// 9. 处理 Date
else if (type === '[object Date]') {
result = new Date(target);
cache.set(target, result);
}
// 10. 处理 RegExp
else if (type === '[object RegExp]') {
result = new RegExp(target.source, target.flags);
cache.set(target, result);
}
// 11. 其他未支持的类型(如 Function、WeakSet)
else {
result = target;
cache.set(target, result);
}
return result;
}
测试验证
javascript
// 测试 Map
const map1 = new Map();
map1.set(Symbol("key1"), { name: "张三" });
map1.set(123, ["篮球", "游泳"]);
const map1Clone = deepCloneV4(map1);
map1Clone.get(Symbol("key1")).name = "李四";
map1Clone.get(123).push("跑步");
console.log(map1.get(Symbol("key1")).name); // 张三(拷贝成功)
console.log(map1.get(123)); // ["篮球", "游泳"](拷贝成功)
// 测试 Set
const set1 = new Set([1, 2, { age: 20 }]);
const set1Clone = deepCloneV4(set1);
set1Clone.forEach(item => {
if (typeof item === 'object' && item !== null) {
item.age = 30;
}
});
console.log([...set1][2].age); // 20(拷贝成功)
console.log([...set1Clone][2].age); // 30(拷贝成功)
// 测试 WeakMap
const weakMap1 = new WeakMap();
const obj6 = { name: "王五" };
weakMap1.set(obj6, "test");
const weakMap1Clone = deepCloneV4(weakMap1);
console.log(weakMap1Clone instanceof WeakMap); // true(创建空实例成功)
// 测试 Date
const date1 = new Date("2024-01-01");
const date1Clone = deepCloneV4(date1);
date1Clone.setFullYear(2025);
console.log(date1.getFullYear()); // 2024(拷贝成功)
console.log(date1Clone.getFullYear()); // 2025(拷贝成功)
3.5 版本5:最终优化(工业级可用)
在版本4的基础上,进行细节优化,提升函数的健壮性和可用性:
- 处理函数类型(虽然函数一般无需深拷贝,直接返回原函数或新函数包装)。
- 优化类型判断逻辑,提取公共工具函数。
- 增加对
undefined、NaN等特殊值的处理。 - 严格遵循"深拷贝"原则,确保所有引用类型都完全独立。
javascript
/**
* 公共工具函数:获取准确的数据类型
* @param {*} target 目标数据
* @returns 数据类型标识(如 [object Array])
*/
function getType(target) {
return Object.prototype.toString.call(target);
}
/**
* 版本5:工业级可用的深拷贝(最终版)
* 支持:循环引用、Symbol、Map、Set、WeakMap、Date、RegExp 等
* @param {*} target 要拷贝的目标数据
* @param {WeakMap} cache 缓存容器,记录已拷贝的对象
* @returns 拷贝后的新数据
*/
function deepClone(target, cache = new WeakMap()) {
// 1. 处理基本类型
if (target === null || typeof target !== 'object') {
// 处理 Symbol 基本类型
if (typeof target === 'symbol') {
return Symbol(target.description);
}
// 处理 undefined、NaN、Infinity 等特殊值
return target;
}
// 2. 处理循环引用
if (cache.has(target)) {
return cache.get(target);
}
const type = getType(target);
let result;
// 3. 处理各类引用类型
switch (type) {
// 3.1 数组
case '[object Array]':
result = [];
cache.set(target, result);
target.forEach((item, index) => {
result[index] = deepClone(item, cache);
});
break;
// 3.2 普通对象
case '[object Object]':
result = {};
cache.set(target, result);
// 遍历普通键
Object.keys(target).forEach(key => {
result[key] = deepClone(target[key], cache);
});
// 遍历 Symbol 键
Object.getOwnPropertySymbols(target).forEach(symKey => {
result[symKey] = deepClone(target[symKey], cache);
});
break;
// 3.3 Map
case '[object Map]':
result = new Map();
cache.set(target, result);
target.forEach((value, key) => {
result.set(deepClone(key, cache), deepClone(value, cache));
});
break;
// 3.4 Set
case '[object Set]':
result = new Set();
cache.set(target, result);
target.forEach(value => {
result.add(deepClone(value, cache));
});
break;
// 3.5 WeakMap
case '[object WeakMap]':
result = new WeakMap();
cache.set(target, result);
// 无法遍历,仅创建空实例
break;
// 3.6 Date
case '[object Date]':
result = new Date(target);
cache.set(target, result);
break;
// 3.7 RegExp
case '[object RegExp]':
result = new RegExp(target.source, target.flags);
cache.set(target, result);
break;
// 3.8 函数(直接返回原函数,函数一般无需深拷贝)
case '[object Function]':
result = target;
cache.set(target, result);
break;
// 3.9 其他未支持的类型
default:
result = target;
cache.set(target, result);
break;
}
return result;
}
四、核心知识点总结与避坑指南
4.1 核心知识点回顾
- 深拷贝的本质:递归遍历所有数据层级,为每个引用类型创建新实例,实现数据完全独立。
- 循环引用的解决:使用WeakMap作为缓存容器,记录已拷贝的对象,避免无限递归。
- WeakMap的优势:弱引用特性,避免内存泄漏,自动清理无效引用,适合作为深拷贝缓存。
- Symbol的处理 :遍历
Object.getOwnPropertySymbols()获取Symbol键,创建新Symbol实例保持描述一致。 - 特殊引用类型的处理:通过准确的类型判断,调用对应构造函数创建新实例,针对性遍历拷贝数据。
4.2 常见避坑点
- 忽略
null的判断 :typeof null === 'object',若不单独判断,会将null当作对象处理,导致错误。 - 使用Map作为缓存:Map会形成强引用,导致原对象无法被垃圾回收,引发内存泄漏,优先使用WeakMap。
- 直接修改原对象属性:深拷贝过程中,避免修改原数据,确保拷贝的纯洁性。
- 忽略原型链属性 :使用
Object.keys()或hasOwnProperty()过滤原型链属性,仅拷贝自身可枚举属性。 - 函数的深拷贝:函数一般无需深拷贝,因为函数的执行依赖作用域,深拷贝函数可能导致作用域丢失,直接返回原函数即可。
五、总结
手写一个健壮的深拷贝函数,不仅需要掌握递归的核心逻辑,还需要深入理解JavaScript的数据类型、内存模型、垃圾回收等底层知识。本文从基础版本到工业级版本,逐步攻克了循环引用、Symbol、WeakMap等核心难点,最终实现的深拷贝函数能够满足大部分业务场景的需求。
需要注意的是,深拷贝是一个相对昂贵的操作(递归遍历、创建大量新实例),在性能敏感的场景中,应谨慎使用,可考虑:
- 按需拷贝:仅拷贝需要使用的数据,而非整个对象。
- 浅拷贝替代:若数据仅有表层引用类型,可使用浅拷贝提升性能。
- 第三方库:如
lodash.cloneDeep(),经过严格测试,支持更多边缘场景,工业项目中可优先使用。