好的,我们来系统且深入地探讨一下 JavaScript 中的深拷贝。这不仅仅是“如何实现”的问题,更关键的是理解“为何如此实现”以及各种方法的优缺点和适用场景。
核心出发点:为什么需要深拷贝?
要理解深拷贝(Deep Copy),必须先理解 JavaScript 的数据类型和内存模型。
JavaScript 的数据类型分为两类:
-
原始类型 (Primitive Types): string, number, boolean, null, undefined, symbol, bigint。
这些值直接存储在**栈 (Stack)**内存中。当你把一个原始类型的值赋给另一个变量时,你是在创建一个全新的、独立的值。
JavaScript
let a = 100; let b = a; // b 得到了 a 的值的副本 b = 200; // 修改 b 不会影响 a console.log(a); // 输出 100 -
引用类型 (Reference Types): Object (包括普通对象、数组 Array、函数 Function、日期 Date、正则表达式 RegExp 等)。
这些值的内容(数据本身)存储在**堆 (Heap)内存中,而变量名和指向该内容的内存地址则存储在栈 (Stack)**内存中。当你把一个引用类型的值赋给另一个变量时,你只是复制了这个“内存地址的指针”,而不是数据本身。两个变量最终指向的是同一个堆内存中的对象。
JavaScript
let obj1 = { name: 'Alice', details: { age: 30 } }; let obj2 = obj1; // obj2 复制了 obj1 的内存地址,它们指向同一个对象 obj2.name = 'Bob'; // 通过 obj2 修改对象 console.log(obj1.name); // 输出 'Bob',因为 obj1 也被改变了
这个特性引出了浅拷贝 (Shallow Copy) 和 深拷贝 (Deep Copy) 的概念。
-
浅拷贝: 只复制对象的第一层属性。如果属性值是原始类型,就复制值;如果属性值是引用类型,就只复制其内存地址。这意味着拷贝后的对象和原对象的嵌套引用类型属性,仍然指向同一个对象。
Object.assign()和展开语法...都是浅拷贝。JavaScript
let obj1 = { name: 'Alice', details: { age: 30 } }; let obj2 = { ...obj1 }; // 使用展开语法进行浅拷贝 obj2.name = 'Bob'; // 修改第一层,不影响 obj1 console.log(obj1.name); // 'Alice' obj2.details.age = 35; // 修改嵌套对象 console.log(obj1.details.age); // 35,obj1 也被修改了! -
深拷贝: 完全创建一个新的、独立的对象,递归地复制原对象的所有层级的属性。拷贝后的对象与原对象没有任何关联,修改任何一方都不会影响另一方。
实现深拷贝的几种方式
方式一:JSON.stringify (简单但不完美的捷径)
这是最广为人知也最简单的“深拷贝”方法。
JavaScript
function jsonDeepCopy(obj) {
return JSON.parse(JSON.stringify(obj));
}
const obj1 = {
name: 'Alice',
details: { age: 30 },
hobbies: ['reading', 'coding'],
joinDate: new Date(),
sayHi: function() { console.log('hi'); },
id: Symbol('id'),
nothing: undefined
};
const obj2 = jsonDeepCopy(obj1);
console.log(obj2);
/*
输出:
{
"name": "Alice",
"details": {
"age": 30
},
"hobbies": [
"reading",
"coding"
],
"joinDate": "2025-07-26T11:30:00.000Z" // 日期变成了字符串
}
// function, symbol, undefined 都丢失了
*/
优点:
-
非常简单,一行代码搞定。
-
能处理常见的 JSON 安全数据(字符串、数字、布尔、数组、普通对象)。
致命缺点:
-
忽略
undefined和symbol: 如果对象的属性值是undefined或symbol,该属性会在序列化过程中丢失。 -
忽略函数: 属性值是函数时,该属性也会丢失。
-
转换
Date对象:Date对象会被转换为 ISO 格式的字符串,而不是一个新的Date对象。 -
无法处理
RegExp: 正则表达式对象会变为空对象{}。 -
无法处理循环引用: 如果对象之间存在循环引用(例如
a.b = b; b.a = a;),该方法会直接抛出TypeError错误。 -
无法处理
Map,Set等数据结构。
结论:此方法仅适用于数据结构简单、确定不包含上述特殊类型且没有循环引用的场景。在生产环境中需要慎用。
方式二:递归实现 (彻底理解原理)
要实现一个真正健壮的深拷贝,我们需要手动编写一个递归函数。下面我们从一个基础版本开始,逐步完善它,以处理各种边界情况。
V1.0: 基础递归版本 (仅处理普通对象和数组)
JavaScript
function basicDeepCopy(obj) {
// 如果是原始类型或者 null,直接返回
if (typeof obj !== 'object' || obj === null) {
return obj;
}
// 判断是数组还是对象
let newObj = Array.isArray(obj) ? [] : {};
// 遍历对象的属性(包括原型链上的)
for (let key in obj) {
// 只拷贝对象自身的属性
if (Object.prototype.hasOwnProperty.call(obj, key)) {
// 递归拷贝属性值
newObj[key] = basicDeepCopy(obj[key]);
}
}
return newObj;
}
这个版本已经能正确处理嵌套的对象和数组了,但它仍然有 V1.0 的问题:循环引用。
如果 obj.self = obj,那么 basicDeepCopy(obj) 将会无限递归下去,直到栈溢出。
V2.0: 解决循环引用问题
解决方案是使用一个 Map (或 WeakMap) 来存储已经拷贝过的对象。在拷贝一个新对象之前,先检查 Map 中是否已经存在,如果存在,则直接返回其对应的拷贝,避免重复拷贝和无限递归。
WeakMap 在这里是更好的选择,因为它对键是弱引用,当原对象没有其他引用时,垃圾回收机制可以正常回收它,避免内存泄漏。
JavaScript
function deepCopyWithCircularRef(obj, cache = new WeakMap()) {
// 原始类型或 null
if (typeof obj !== 'object' || obj === null) {
return obj;
}
// 如果已经拷贝过,直接返回缓存中的拷贝
if (cache.has(obj)) {
return cache.get(obj);
}
// 判断数据类型
let newObj = Array.isArray(obj) ? [] : {};
// 在递归之前,将新对象存入缓存
// 这样即使在递归中遇到循环引用,也能从缓存中获取
cache.set(obj, newObj);
// Reflect.ownKeys 可以获取所有类型的键名(包括 Symbol)
Reflect.ownKeys(obj).forEach(key => {
newObj[key] = deepCopyWithCircularRef(obj[key], cache);
});
return newObj;
}
这个版本已经相当不错了,解决了最大的循环引用问题。但它还不够“彻底”,因为它没有处理 Date, RegExp, Set, Map 等其他引用类型。
V3.0: 完整的深拷贝实现
让我们扩展 V2.0,使其能够处理更多的数据类型。
JavaScript
/**
* 一个相对完整的深拷贝实现
* @param {any} obj - 需要被拷贝的对象
* @param {WeakMap} cache - 用于缓存,解决循环引用问题
* @returns {any} 拷贝后的新对象
*/
function deepCopy(obj, cache = new WeakMap()) {
// 1. 原始类型、函数、null 直接返回
// 函数没有自己的状态,直接复用即可,无需拷贝
if (obj === null || typeof obj !== 'object') {
return obj;
}
// 2. 处理已拷贝过的对象(解决循环引用)
if (cache.has(obj)) {
return cache.get(obj);
}
// 3. 处理特定引用类型:Date, RegExp
if (obj instanceof Date) {
return new Date(obj);
}
if (obj instanceof RegExp) {
return new RegExp(obj);
}
// 4. 获取构造函数,创建新的实例
// 这样可以保留对象的原型,比如拷贝一个类的实例
const newObj = new obj.constructor();
// 5. 存入缓存
cache.set(obj, newObj);
// 6. 处理 Map 和 Set
if (obj instanceof Map) {
obj.forEach((value, key) => {
newObj.set(deepCopy(key, cache), deepCopy(value, cache));
});
return newObj;
}
if (obj instanceof Set) {
obj.forEach(value => {
newObj.add(deepCopy(value, cache));
});
return newObj;
}
// 7. 处理数组和普通对象 (遍历所有自有属性,包括 Symbol)
Reflect.ownKeys(obj).forEach(key => {
newObj[key] = deepCopy(obj[key], cache);
});
return newObj;
}
// --- 测试 ---
const obj1 = {
name: 'complex object',
age: 10,
isStudent: false,
nothing: null,
undef: undefined,
id: Symbol('id'),
hobbies: new Set(['coding', 'reading', 'gaming']),
friends: new Map([['Tom', { age: 12 }]]),
joinDate: new Date(),
pattern: /^\d+$/,
sayHi: function() { console.log('hi'); },
};
// 创建循环引用
obj1.self = obj1;
const obj2 = deepCopy(obj1);
// 验证拷贝的独立性
obj2.name = 'copied object';
obj2.hobbies.add('drawing');
obj2.friends.get('Tom').age = 15;
obj2.joinDate.setFullYear(2026);
obj1.sayHi(); // hi
console.log('Original Object:', obj1);
console.log('Copied Object:', obj2);
console.log(obj1.self === obj1); // true
console.log(obj2.self === obj2); // true
console.log(obj1.self === obj2.self); // false,循环引用也被正确处理
这个 V3.0 版本已经非常健壮,它:
-
正确处理了原始类型和函数。
-
使用
WeakMap优雅地解决了循环引用问题。 -
能够处理
Date,RegExp,Set,Map等多种内置对象。 -
通过
Reflect.ownKeys遍历所有类型的键。 -
通过
new obj.constructor()保留了对象的原型链。
方式三:structuredClone() (现代浏览器的内置武器)
现代浏览器和 Node.js (v17+) 提供了一个原生的、标准的深拷贝 API:structuredClone()。
JavaScript
const obj1 = {
name: 'Alice',
details: { age: 30 },
joinDate: new Date(),
regex: /abc/g,
map: new Map([['a', 1]])
};
obj1.circular = obj1; // 循环引用
const obj2 = structuredClone(obj1);
obj2.details.age = 35;
console.log(obj1.details.age); // 30
console.log(obj2.details.age); // 35
console.log(obj2.circular === obj2); // true
console.log(obj2.circular === obj1.circular); // false
structuredClone 使用了结构化克隆算法,这个算法本来是用于在 Web Workers 之间传递数据的。它非常强大:
-
优点:
-
原生标准:这是官方推荐的方式,无需引入任何库。
-
性能优秀:通常比手写的 JavaScript 实现更快。
-
功能强大:支持循环引用、支持大量内置类型(
Date,RegExp,Map,Set,Blob,File,ImageData等)。
-
-
缺点:
-
不支持函数: 和
JSON.stringify一样,如果尝试克隆包含函数的对象,会抛出DataCloneError错误。 -
不克隆原型链: 它不会复制对象的原型链。
-
兼容性: 在一些旧的浏览器或 Node.js 版本中不可用。
-
方式四:使用成熟的第三方库
比如 lodash 库的 _.cloneDeep() 方法。
JavaScript
// 需要先安装 lodash: npm install lodash
import _ from 'lodash';
const obj1 = { /* ... 复杂对象 ... */ };
const obj2 = _.cloneDeep(obj1);
优点:
-
极其健壮:经过了大量的测试,处理了无数开发者可能都想不到的边界情况。
-
兼容性好:可以在几乎所有的 JavaScript 环境中运行。
-
功能全面:通常比手写的实现或
structuredClone支持更多不常见的类型。
缺点:
- 增加依赖:需要为项目引入一个额外的库,增加了打包体积。
总结与最终推荐
理解了以上所有方法后,我们可以给出清晰的决策路径:
-
最佳实践与首选: structuredClone()
在你的目标环境(浏览器、Node.js)支持的情况下,这应该是你的第一选择。它是原生的、高效的,并且功能足够强大,可以满足绝大多数的深拷贝需求。不能拷贝函数通常不是问题,因为拷贝函数本身通常是一种不好的实践(函数是行为,不是数据)。
-
兼容性与终极方案: Lodash 的 _.cloneDeep()
如果你的项目需要兼容旧版浏览器,或者你需要拷贝一些 structuredClone 也不支持的极其复杂的对象,那么使用一个像 Lodash 这样经过实战检验的库是最安全、最省心的选择。
-
学习与面试: 手写递归实现
理解并能够写出 V3.0 版本的深拷贝函数,是衡量一个 JavaScript 开发者基础是否扎实的绝佳标尺。它能全面考察你对数据类型、内存模型、递归、循环引用、Map/WeakMap 以及各种内置对象特性的理解。
-
临时与特定场景: JSON.parse(JSON.stringify())
只有当你完全确定你的数据是 "JSON-safe" 的(不包含函数、undefined、Symbol、Date等),并且没有循环引用时,才可以使用这个方法图个方便。在不确定的情况下,避免使用它。