JavaScript 对象拷贝:深层与浅层解析及循环引用处理
在JavaScript编程中,数据的复制是一个基础而又关键的概念。尤其当我们处理对象和数组这类引用类型时,简单地使用赋值操作符往往无法达到我们期望的“复制”效果。这引出了深拷贝和浅拷贝这两个核心概念。本讲座将深入探讨这两种拷贝机制,分析它们的适用场景与局限性,并最终手写一个能够处理循环引用的健壮深拷贝函数。
1. JavaScript 中的值类型与引用类型:一切的根源
理解深拷贝与浅拷贝之前,我们必须先回顾JavaScript中数据类型的存储方式。
值类型 (Primitives): number, string, boolean, symbol, null, undefined, bigint。
这些类型的值直接存储在变量访问的位置。当我们对值类型进行赋值操作时,实际上是创建了一个全新的副本。
let a = 10;
let b = a; // b 获得了 a 的值的一个副本
b = 20;
console.log(a); // 输出: 10 (a 不受 b 改变的影响)
console.log(b); // 输出: 20
引用类型 (Objects): Object, Array, Function, Date, RegExp, Map, Set 等。
这些类型的值存储在堆内存中,而变量存储的只是指向这些值在堆内存中的内存地址,也就是“引用”。
let obj1 = { name: "Alice", age: 30 };
let obj2 = obj1; // obj2 获得了 obj1 的内存地址的副本,它们指向同一个对象
obj2.age = 31;
console.log(obj1.age); // 输出: 31 (obj1 受 obj2 改变的影响)
console.log(obj2.age); // 输出: 31
// 数组也是引用类型
let arr1 = [1, 2, { value: 3 }];
let arr2 = arr1; // arr2 和 arr1 指向同一个数组
arr2[0] = 100;
arr2[2].value = 300;
console.log(arr1[0]); // 输出: 100
console.log(arr1[2].value); // 输出: 300
从上述例子中可以看到,当我们将一个引用类型的变量赋值给另一个变量时,我们仅仅复制了它的引用地址。这意味着两个变量现在都指向堆内存中的同一个对象。任何通过其中一个变量对该对象的修改,都会反映在另一个变量上。这往往不是我们期望的“复制”行为。
2. 浅拷贝 (Shallow Copy):表层复制
浅拷贝是指创建一个新对象,这个新对象拥有原始对象的所有顶层属性的副本。但是,如果原始对象的某个属性值是引用类型(例如另一个对象或数组),那么新对象和原始对象会共享这个引用类型的属性。换句话说,浅拷贝只拷贝了对象的第一层,深层嵌套的对象仍然是共享引用。
2.1 常见的浅拷贝方法
2.1.1 Object.assign()
Object.assign() 方法用于将所有可枚举的自有属性的值从一个或多个源对象复制到目标对象。它会返回目标对象。
const originalObject = {
name: "Charlie",
details: {
age: 25,
city: "New York"
},
hobbies: ["reading", "coding"]
};
const shallowCopyAssign = Object.assign({}, originalObject);
console.log("--- Object.assign() 浅拷贝示例 ---");
console.log("Original:", originalObject);
console.log("Shallow Copy:", shallowCopyAssign);
// 修改顶层属性,互不影响
shallowCopyAssign.name = "David";
console.log("Original name after modification:", originalObject.name); // Charlie
console.log("Shallow Copy name after modification:", shallowCopyAssign.name); // David
// 修改嵌套对象属性,互相影响
shallowCopyAssign.details.age = 26;
console.log("Original details age after modification:", originalObject.details.age); // 26
console.log("Shallow Copy details age after modification:", shallowCopyAssign.details.age); // 26
// 修改嵌套数组元素,互相影响
shallowCopyAssign.hobbies.push("gaming");
console.log("Original hobbies after modification:", originalObject.hobbies); // ["reading", "coding", "gaming"]
console.log("Shallow Copy hobbies after modification:", shallowCopyAssign.hobbies); // ["reading", "coding", "gaming"]
特点:
- 会复制源对象的所有可枚举的自有属性到目标对象。
- 对于深层嵌套的对象,它只会复制引用。
2.1.2 扩展运算符 (...)
ES6引入的扩展运算符 (...) 提供了一种简洁的方式来浅拷贝对象和数组。
const originalObject = {
name: "Eve",
details: {
age: 30,
city: "London"
},
hobbies: ["painting", "hiking"]
};
const shallowCopySpread = { ...originalObject }; // 对象浅拷贝
const shallowCopyArraySpread = [...originalObject.hobbies]; // 数组浅拷贝
console.log("--- 扩展运算符 (...) 浅拷贝示例 ---");
console.log("Original:", originalObject);
console.log("Shallow Copy (Object):", shallowCopySpread);
console.log("Shallow Copy (Array):", shallowCopyArraySpread);
// 行为与 Object.assign() 类似,顶层属性独立,嵌套引用共享
shallowCopySpread.name = "Fiona";
shallowCopySpread.details.age = 31;
shallowCopySpread.hobbies.push("photography");
console.log("Original name:", originalObject.name); // Eve
console.log("Shallow Copy name:", shallowCopySpread.name); // Fiona
console.log("Original details age:", originalObject.details.age); // 31
console.log("Shallow Copy details age:", shallowCopySpread.details.age); // 31
console.log("Original hobbies:", originalObject.hobbies); // ["painting", "hiking", "photography"]
console.log("Shallow Copy hobbies:", shallowCopySpread.hobbies); // ["painting", "hiking", "photography"]
特点:
- 对于对象,扩展运算符会创建新对象,并复制源对象的所有可枚举的自有属性。
- 对于数组,扩展运算符会创建新数组,并复制源数组的所有元素。
- 同样,对于深层嵌套的引用类型,只复制引用。
2.1.3 Array.prototype.slice() 和 Array.from()
这两种方法主要用于数组的浅拷贝。
const originalArray = [1, { value: 2 }, [3, 4]];
const shallowCopySlice = originalArray.slice();
const shallowCopyFrom = Array.from(originalArray);
console.log("--- 数组浅拷贝示例 (slice, Array.from) ---");
console.log("Original:", originalArray);
console.log("Shallow Copy (slice):", shallowCopySlice);
console.log("Shallow Copy (Array.from):", shallowCopyFrom);
shallowCopySlice[0] = 10;
shallowCopySlice[1].value = 20;
shallowCopyFrom[2][0] = 30;
console.log("Original array after modification:", originalArray); // [1, { value: 20 }, [30, 4]]
console.log("Shallow Copy (slice) after modification:", shallowCopySlice); // [10, { value: 20 }, [30, 4]]
console.log("Shallow Copy (Array.from) after modification:", shallowCopyFrom); // [1, { value: 20 }, [30, 4]]
2.2 浅拷贝的适用场景
- 当对象只包含基本数据类型属性时。
- 当你知道并且希望拷贝对象和原始对象共享深层嵌套的引用时(例如,性能优化,避免不必要的深层复制)。
- 当你只关心顶层属性的独立性时。
2.3 浅拷贝的局限性
- 无法实现深层嵌套对象的完全独立。对深层嵌套对象的修改会同时影响原始对象和拷贝对象。
3. 深拷贝 (Deep Copy):完全独立复制
深拷贝是指创建一个全新的对象,并且递归地复制原始对象及其所有嵌套的引用类型属性。这意味着深拷贝后的对象与原始对象在内存上是完全独立的,对拷贝对象任何层级的修改都不会影响到原始对象。
3.1 挑战与复杂性
实现深拷贝比浅拷贝复杂得多,主要面临以下挑战:
- 递归复制: 需要遍历所有嵌套的对象和数组,直到所有属性都是基本数据类型。
- 类型处理: 需要正确处理各种JavaScript内置类型,如
Date、RegExp、Map、Set、Function等。 - 循环引用: 这是最棘手的问题。如果一个对象内部的某个属性引用了它自身,或者引用了其祖先链中的某个对象,那么简单的递归将导致无限循环,最终栈溢出。
3.2 简单但不完美的深拷贝方法:JSON.parse(JSON.stringify(obj))
这种方法利用JSON序列化和反序列化的特性,可以实现一个简单的深拷贝。
const originalObject = {
name: "Grace",
details: {
age: 40,
city: "Paris",
birthDate: new Date("1983-05-20T00:00:00.000Z")
},
hobbies: ["traveling", "cooking"],
greet: function() { console.log("Hello!"); },
undefinedProp: undefined,
symbolProp: Symbol('id')
};
const deepCopyJSON = JSON.parse(JSON.stringify(originalObject));
console.log("--- JSON.parse(JSON.stringify()) 深拷贝示例 ---");
console.log("Original:", originalObject);
console.log("Deep Copy (JSON):", deepCopyJSON);
// 修改深层属性,互不影响
deepCopyJSON.details.age = 41;
deepCopyJSON.hobbies.push("gardening");
console.log("Original details age:", originalObject.details.age); // 40
console.log("Deep Copy details age:", deepCopyJSON.details.age); // 41
console.log("Original hobbies:", originalObject.hobbies); // ["traveling", "cooking"]
console.log("Deep Copy hobbies:", deepCopyJSON.hobbies); // ["traveling", "cooking", "gardening"]
// 对比 Date 对象
console.log("Original birthDate type:", typeof originalObject.details.birthDate); // object
console.log("Deep Copy birthDate type:", typeof deepCopyJSON.details.birthDate); // string (Date对象被转换为ISO字符串)
// 丢失 Function, undefined, Symbol
console.log("Original greet:", originalObject.greet); // function
console.log("Deep Copy greet:", deepCopyJSON.greet); // undefined
console.log("Original undefinedProp:", originalObject.undefinedProp); // undefined
console.log("Deep Copy undefinedProp:", deepCopyJSON.undefinedProp); // undefined (属性直接消失)
console.log("Original symbolProp:", originalObject.symbolProp); // Symbol(id)
console.log("Deep Copy symbolProp:", deepCopyJSON.symbolProp); // undefined (属性直接消失)
// 循环引用会报错
const circularObj = {};
circularObj.a = circularObj;
try {
JSON.parse(JSON.stringify(circularObj));
} catch (e) {
console.error("JSON.stringify 遇到循环引用时会抛出错误:", e.message); // Converting circular structure to JSON
}
JSON.parse(JSON.stringify(obj)) 的局限性:
| 特性 | JSON.parse(JSON.stringify(obj)) 行为 |
理想深拷贝行为 |
|---|---|---|
| 函数 (Function) | 会被忽略(丢失) | 通常保留引用,或根据需求处理 |
undefined |
属性值是 undefined 的属性会被忽略(丢失) |
保留为 undefined |
Symbol |
属性值是 Symbol 的属性会被忽略(丢失) |
保留为 Symbol |
Date 对象 |
会被转换为 ISO 格式的字符串 | 复制为新的 Date 对象 |
RegExp 对象 |
会被转换为 {} (空对象) 或丢失 |
复制为新的 RegExp 对象 |
Map, Set |
会被转换为 {} 或 [] (空对象或空数组) |
复制为新的 Map, Set 对象 |
BigInt |
尝试序列化 BigInt 会抛出 TypeError |
保留为 BigInt |
| 循环引用 | 会抛出 TypeError: Converting circular structure to JSON |
能够优雅地处理,避免无限循环 |
| 原型链 | 无法复制原型链上的属性和方法 | 通常只复制自有属性 |
| 不可枚举属性 | 无法复制 | 通常只复制可枚举属性 |
尽管其方便,但由于上述诸多限制,JSON.parse(JSON.stringify(obj)) 并不适用于大多数需要健壮深拷贝的场景。
4. 手写递归深拷贝函数并处理循环引用
现在,我们将逐步构建一个能够处理各种类型(包括 Date, RegExp, Map, Set)并有效解决循环引用的深拷贝函数。
4.1 核心思路
- 确定拷贝目标类型: 首先判断要拷贝的值是基本类型还是引用类型。基本类型直接返回,
null也是。 - 创建目标容器: 如果是对象或数组,创建一个新的空对象或空数组作为拷贝结果。
- 处理特殊对象: 对于
Date、RegExp、Map、Set等特殊内置对象,需要有专门的复制逻辑。 - 处理循环引用: 在递归开始前,检查当前对象是否已经被拷贝过。如果已经被拷贝,则直接返回之前拷贝的副本,避免无限循环。
- 递归遍历: 遍历源对象的属性,对每个属性值再次调用深拷贝函数。
4.2 逐步实现
4.2.1 辅助函数:类型判断
为了准确地判断对象的类型,我们不能仅仅依靠 typeof 操作符(它会将 null 识别为 object,将数组识别为 object)。更可靠的方法是使用 Object.prototype.toString.call()。
function getType(obj) {
return Object.prototype.toString.call(obj).slice(8, -1);
}
obj 值 |
getType(obj) 结果 |
|---|---|
123 |
'Number' |
'abc' |
'String' |
true |
'Boolean' |
null |
'Null' |
undefined |
'Undefined' |
[] |
'Array' |
{} |
'Object' |
new Date() |
'Date' |
new RegExp() |
'RegExp' |
new Map() |
'Map' |
new Set() |
'Set' |
function() {} |
'Function' |
Symbol('test') |
'Symbol' |
10n |
'BigInt' |
4.2.2 循环引用处理机制
为了处理循环引用,我们需要一个机制来跟踪哪些对象已经被拷贝过。Map 是一个理想的选择,因为它允许我们将对象作为键存储。
在我们的 deepCopy 函数中,我们将维护一个 Map,其键是原始对象,值是对应的拷贝对象。
- 在每次递归进入一个对象或数组时,首先检查这个原始对象是否已经在
Map中。 - 如果存在,直接返回
Map中存储的拷贝对象,从而终止递归。 - 如果不存在,创建一个新的空拷贝对象(或数组),立即将其存入
Map中,然后再进行属性的递归拷贝。这一步至关重要,它确保了在处理到对象内部的属性时,如果这些属性又指向了该对象本身,能够正确地找到已创建的拷贝对象,而不是再次进入死循环。
// deepCopy 函数签名
function deepCopy(obj, map = new Map()) {
// ...
}
4.2.3 实现 deepCopy 函数
/**
* 深度拷贝 JavaScript 对象和数组,并处理循环引用。
*
* @param {*} obj 要拷贝的源对象。
* @param {Map} [map=new Map()] 用于存储已拷贝对象的映射,以处理循环引用。
* @returns {*} 深度拷贝后的新对象。
*/
function deepCopy(obj, map = new Map()) {
// 1. 处理基本类型、null、undefined、Function、Symbol、BigInt
// 基本类型 (number, string, boolean) 直接返回
// null 和 undefined 直接返回
// Function 和 Symbol、BigInt 通常不进行深拷贝,直接返回原始引用或值
if (obj === null || typeof obj !== 'object') {
// 对于 Function, Symbol, BigInt,它们是不可变的,或者其深拷贝没有意义,
// 因此直接返回原始引用/值是合理的行为。
return obj;
}
// 2. 处理已处理过的对象(循环引用检测)
// 如果当前对象在 map 中已经存在,说明之前已经拷贝过,直接返回其拷贝结果,避免无限循环。
if (map.has(obj)) {
return map.get(obj);
}
// 3. 根据对象类型创建新的容器
let copy;
const type = Object.prototype.toString.call(obj).slice(8, -1); // 获取精确类型
switch (type) {
case 'Array':
copy = [];
break;
case 'Date':
copy = new Date(obj.getTime()); // Date 对象需要特殊处理
break;
case 'RegExp':
// RegExp 对象需要特殊处理,复制其源字符串和标志
copy = new RegExp(obj.source, obj.flags);
break;
case 'Map':
copy = new Map(); // Map 对象需要特殊处理
break;
case 'Set':
copy = new Set(); // Set 对象需要特殊处理
break;
// 其他内置对象,如 Error, Promise, WeakMap, WeakSet, ArrayBuffer, TypedArray 等
// 通常不进行深拷贝或其深拷贝没有意义。
// 这里默认将它们作为普通对象处理,但更健壮的实现可能直接返回原始对象,或针对性处理。
// 对于本实现,我们仅处理最常见的可深度复制的类型。
default: // 'Object' 或其他未专门处理的类型
copy = {};
break;
}
// 4. 将新创建的拷贝对象存入 map,以便处理后续可能出现的循环引用。
// 这一步必须在递归遍历属性之前执行,否则如果对象内部有对自身的引用,
// 在递归到该引用时,map 中还找不到对应的拷贝对象,会再次创建,导致死循环。
map.set(obj, copy);
// 5. 递归拷贝属性
switch (type) {
case 'Array':
for (let i = 0; i < obj.length; i++) {
copy[i] = deepCopy(obj[i], map);
}
break;
case 'Map':
obj.forEach((value, key) => {
copy.set(deepCopy(key, map), deepCopy(value, map));
});
break;
case 'Set':
obj.forEach(value => {
copy.add(deepCopy(value, map));
});
break;
default: // 'Object' 或其他类型
// 遍历所有可枚举的自有属性
for (const key in obj) {
// 确保只拷贝对象自身的属性,而不拷贝原型链上的属性
if (Object.prototype.hasOwnProperty.call(obj, key)) {
copy[key] = deepCopy(obj[key], map);
}
}
break;
}
return copy;
}
4.3 综合测试与验证
现在我们来通过一系列测试用例验证我们的 deepCopy 函数。
// --- 测试用例 1: 基本类型和浅层对象 ---
const obj1 = {
a: 1,
b: 'hello',
c: true,
d: null,
e: undefined,
f: Symbol('test'),
g: 10n
};
const copiedObj1 = deepCopy(obj1);
console.log("--- Test Case 1: Basic Types ---");
console.log("Original:", obj1);
console.log("Copied:", copiedObj1);
console.log("obj1 === copiedObj1:", obj1 === copiedObj1); // false
console.log("obj1.a === copiedObj1.a:", obj1.a === copiedObj1.a); // true (基本类型值相等)
console.log("obj1.f === copiedObj1.f:", obj1.f === copiedObj1.f); // true (Symbol 引用相等)
console.log("obj1.g === copiedObj1.g:", obj1.g === copiedObj1.g); // true (BigInt 值相等)
// --- 测试用例 2: 嵌套对象和数组 ---
const obj2 = {
name: "Alice",
details: {
age: 30,
address: {
street: "123 Main St",
zip: "10001"
}
},
hobbies: ["reading", { type: "sport", name: "swimming" }],
scores: [10, 20, [30, 40]]
};
const copiedObj2 = deepCopy(obj2);
console.log("n--- Test Case 2: Nested Objects and Arrays ---");
console.log("Original:", obj2);
console.log("Copied:", copiedObj2);
copiedObj2.details.age = 31;
copiedObj2.details.address.street = "456 Oak Ave";
copiedObj2.hobbies[0] = "painting";
copiedObj2.hobbies[1].name = "running";
copiedObj2.scores[2][0] = 300;
console.log("Original obj2.details.age:", obj2.details.age); // 30
console.log("Copied obj2.details.age:", copiedObj2.details.age); // 31
console.log("Original obj2.details.address.street:", obj2.details.address.street); // 123 Main St
console.log("Copied obj2.details.address.street:", copiedObj2.details.address.street); // 456 Oak Ave
console.log("Original obj2.hobbies[0]:", obj2.hobbies[0]); // reading
console.log("Copied obj2.hobbies[0]:", copiedObj2.hobbies[0]); // painting
console.log("Original obj2.hobbies[1].name:", obj2.hobbies[1].name); // swimming
console.log("Copied obj2.hobbies[1].name:", copiedObj2.hobbies[1].name); // running
console.log("Original obj2.scores[2][0]:", obj2.scores[2][0]); // 30
console.log("Copied obj2.scores[2][0]:", copiedObj2.scores[2][0]); // 300
// --- 测试用例 3: Date 和 RegExp 对象 ---
const obj3 = {
eventDate: new Date("2023-10-26T10:00:00Z"),
pattern: /abc/gim
};
const copiedObj3 = deepCopy(obj3);
console.log("n--- Test Case 3: Date and RegExp ---");
console.log("Original:", obj3);
console.log("Copied:", copiedObj3);
console.log("obj3.eventDate === copiedObj3.eventDate:", obj3.eventDate === copiedObj3.eventDate); // false
console.log("obj3.eventDate.toISOString():", obj3.eventDate.toISOString());
console.log("copiedObj3.eventDate.toISOString():", copiedObj3.eventDate.toISOString());
console.log("obj3.pattern === copiedObj3.pattern:", obj3.pattern === copiedObj3.pattern); // false
console.log("obj3.pattern.source:", obj3.pattern.source, "flags:", obj3.pattern.flags);
console.log("copiedObj3.pattern.source:", copiedObj3.pattern.source, "flags:", copiedObj3.pattern.flags);
// --- 测试用例 4: Map 和 Set 对象 ---
const map = new Map();
map.set('key1', 'value1');
map.set('key2', { nested: 'mapValue' });
const set = new Set();
set.add('setVal1');
set.add({ nested: 'setVal2' });
const obj4 = {
myMap: map,
mySet: set
};
const copiedObj4 = deepCopy(obj4);
console.log("n--- Test Case 4: Map and Set ---");
console.log("Original:", obj4);
console.log("Copied:", copiedObj4);
console.log("obj4.myMap === copiedObj4.myMap:", obj4.myMap === copiedObj4.myMap); // false
console.log("obj4.mySet === copiedObj4.mySet:", obj4.mySet === copiedObj4.mySet); // false
// 修改拷贝后的Map和Set,不影响原始
copiedObj4.myMap.set('key3', 'value3');
copiedObj4.myMap.get('key2').nested = 'modifiedMapValue';
copiedObj4.mySet.add('setVal3');
Array.from(copiedObj4.mySet).find(item => item && item.nested).nested = 'modifiedSetVal2';
console.log("Original Map size:", obj4.myMap.size); // 2
console.log("Copied Map size:", copiedObj4.myMap.size); // 3
console.log("Original Map nested value:", obj4.myMap.get('key2').nested); // mapValue
console.log("Copied Map nested value:", copiedObj4.myMap.get('key2').nested); // modifiedMapValue
console.log("Original Set size:", obj4.mySet.size); // 2
console.log("Copied Set size:", copiedObj4.mySet.size); // 3
console.log("Original Set nested value:", Array.from(obj4.mySet).find(item => item && item.nested).nested); // setVal2
console.log("Copied Set nested value:", Array.from(copiedObj4.mySet).find(item => item && item.nested).nested); // modifiedSetVal2
// --- 测试用例 5: 函数 ---
const obj5 = {
name: "Function Test",
myFunc: function(val) { return val * 2; }
};
const copiedObj5 = deepCopy(obj5);
console.log("n--- Test Case 5: Functions ---");
console.log("Original:", obj5);
console.log("Copied:", copiedObj5);
console.log("obj5.myFunc === copiedObj5.myFunc:", obj5.myFunc === copiedObj5.myFunc); // true (函数被引用拷贝)
console.log("obj5.myFunc(5):", obj5.myFunc(5)); // 10
console.log("copiedObj5.myFunc(5):", copiedObj5.myFunc(5)); // 10
// --- 测试用例 6: 循环引用 ---
const circularObj1 = {};
circularObj1.self = circularObj1;
const copiedCircularObj1 = deepCopy(circularObj1);
console.log("n--- Test Case 6.1: Self-referencing Object ---");
console.log("Original:", circularObj1);
console.log("Copied:", copiedCircularObj1);
console.log("circularObj1 === copiedCircularObj1:", circularObj1 === copiedCircularObj1); // false
console.log("copiedCircularObj1.self === copiedCircularObj1:", copiedCircularObj1.self === copiedCircularObj1); // true
console.log("circularObj1.self === copiedCircularObj1.self:", circularObj1.self === copiedCircularObj1.self); // false
const circularObj2 = {
a: 1,
b: { c: 2 }
};
circularObj2.b.parent = circularObj2; // 嵌套对象引用父级
const copiedCircularObj2 = deepCopy(circularObj2);
console.log("n--- Test Case 6.2: Nested Object Referencing Parent ---");
console.log("Original:", circularObj2);
console.log("Copied:", copiedCircularObj2);
console.log("circularObj2 === copiedCircularObj2:", circularObj2 === copiedCircularObj2); // false
console.log("circularObj2.b === copiedCircularObj2.b:", circularObj2.b === copiedCircularObj2.b); // false
console.log("copiedCircularObj2.b.parent === copiedCircularObj2:", copiedCircularObj2.b.parent === copiedCircularObj2); // true
console.log("circularObj2.b.parent === copiedCircularObj2.b.parent:", circularObj2.b.parent === copiedCircularObj2.b.parent); // false
// --- 测试用例 7: 数组中的循环引用 ---
const circularArr = [];
circularArr.push(1, circularArr, { name: 'item' });
const copiedCircularArr = deepCopy(circularArr);
console.log("n--- Test Case 7: Circular Array ---");
console.log("Original:", circularArr);
console.log("Copied:", copiedCircularArr);
console.log("circularArr === copiedCircularArr:", circularArr === copiedCircularArr); // false
console.log("copiedCircularArr[1] === copiedCircularArr:", copiedCircularArr[1] === copiedCircularArr); // true
console.log("circularArr[1] === copiedCircularArr[1]:", circularArr[1] === copiedCircularArr[1]); // false
通过这些测试,我们可以看到我们的 deepCopy 函数成功地处理了各种数据类型,包括嵌套结构,并且最重要的是,它能够识别并正确处理循环引用,避免了无限递归。
5. 性能考量与替代方案
手写的递归深拷贝函数虽然强大,但并非总是最佳选择,尤其是在性能敏感或已经有成熟解决方案的场景下。
5.1 性能考量
- 递归开销: 对于非常深或非常大的对象,递归调用会占用大量的栈内存,可能导致栈溢出。
- 遍历开销: 逐属性遍历和类型判断会增加计算成本。
- Map 查找:
Map的查找操作虽然高效,但仍然有其开销。
5.2 替代方案
5.2.1 结构化克隆算法 (Structured Clone Algorithm)
现代浏览器提供了一个内置的结构化克隆算法,可以通过 window.structuredClone() 方法访问。这个算法是浏览器内部用于 postMessage、IndexedDB 存储等场景的,它能够处理许多复杂类型,包括循环引用。
const objToClone = {
a: 1,
b: new Date(),
c: /regex/g,
d: new Map([['x', 1]]),
e: new Set([2, 3])
};
objToClone.f = objToClone; // 循环引用
try {
const clonedObj = structuredClone(objToClone);
console.log("n--- structuredClone() Example ---");
console.log("Original:", objToClone);
console.log("Cloned:", clonedObj);
console.log("objToClone === clonedObj:", objToClone === clonedObj); // false
console.log("objToClone.f === clonedObj.f:", objToClone.f === clonedObj.f); // false (处理了循环引用)
console.log("objToClone.b.getTime() === clonedObj.b.getTime():", objToClone.b.getTime() === clonedObj.b.getTime()); // true
} catch (e) {
console.error("structuredClone error:", e.message);
}
// structuredClone 的局限性:
// 1. 不能克隆函数 (throws error if directly contained or nested)
// 2. 不能克隆 DOM 节点
// 3. 不能克隆 Error 对象(它会被克隆成一个普通对象,失去原型链)
// 4. 不能克隆 Promise, WeakMap, WeakSet
const objWithFunction = {
name: 'test',
func: () => console.log('hi')
};
try {
structuredClone(objWithFunction);
} catch (e) {
console.error("structuredClone cannot clone functions:", e.message); // DataCloneError: The object could not be cloned.
}
structuredClone() 是一个强大且通常更快的解决方案,因为它是由浏览器原生实现的。但它的局限性在于不能克隆函数、DOM节点等特定类型,并且是浏览器环境特有的(Node.js 可以使用 v8.deserialize(v8.serialize(obj)) 实现类似功能,但同样有局限性)。
5.2.2 第三方库
许多成熟的JavaScript工具库都提供了深拷贝功能,例如:
- Lodash:
_.cloneDeep(value)
Lodash 的cloneDeep是一个非常完善的深拷贝实现,它处理了几乎所有你能想到的复杂情况,包括各种内置对象、循环引用、原型链等。它通常是生产环境中深拷贝的首选。 - Immer: 虽然 Immer 主要用于创建不可变状态,它通过
produce函数和draft对象实现了“写时复制” (copy-on-write) 机制,在概念上与深拷贝相关。它不是直接提供一个深拷贝函数,但提供了一种更高效、更方便的方式来处理复杂对象的不可变更新。
// 示例 (需要安装 lodash)
// import _ from 'lodash';
// const original = { a: 1, b: { c: 2 } };
// const deepCopyLodash = _.cloneDeep(original);
// console.log(deepCopyLodash);
5.3 何时选择哪种方案?
- 简单对象/只需浅拷贝:
Object.assign(), 扩展运算符...。 - 简单深拷贝(无特殊类型,无循环引用):
JSON.parse(JSON.stringify(obj))(如果可以接受其局限性)。 - 需要健壮、全面的深拷贝,且在浏览器环境:
structuredClone()(如果其局un’x’ian性可以接受)。 - 需要健壮、全面的深拷贝,且有复杂类型或特定需求,或跨环境: 手写递归函数或使用
Lodash.cloneDeep()。 - 需要不可变更新,而不是简单复制: Immer。
6. 总结与展望
在JavaScript中,深拷贝和浅拷贝是处理对象和数组不可或缺的工具。理解值类型与引用类型的根本差异是掌握这些拷贝机制的前提。浅拷贝操作简单,效率高,适用于只关心顶层属性独立性的场景;而深拷贝则提供了完全独立的数据副本,是处理复杂嵌套结构和避免副作用的关键。
手写一个能够处理循环引用和多种内置类型的深拷贝函数,不仅能加深我们对JavaScript对象模型和递归算法的理解,也为我们应对特定业务需求提供了定制化的解决方案。然而,在实际项目中,我们应权衡性能、代码复杂度和功能完备性,优先考虑使用 structuredClone() 或成熟的第三方库如 Lodash,以获得更稳定、更高效的实现。