揭秘JS深浅拷贝:面试官爱问,你真懂了吗?
前言
在前端开发的日常工作中,我们经常会遇到需要复制对象或数组的场景。然而,JavaScript中对象的赋值操作并非简单的值拷贝,而是引用拷贝。这常常导致一些意想不到的问题,尤其是在处理复杂数据结构时。因此,深入理解JavaScript的深拷贝和浅拷贝机制,不仅是编写健壮代码的关键,更是前端面试中绕不开的高频考点。
本文将从V8引擎的数据存储原理出发,层层深入,为你揭示JavaScript深浅拷贝的奥秘。我们将详细探讨浅拷贝和深拷贝的定义、常见实现方式、各自的优缺点以及适用场景,并通过丰富的代码示例和图示,帮助你彻底掌握这一核心概念,让你在面试中游刃有余,在实际开发中避免踩坑。
准备好了吗?让我们一起踏上这场“拷贝”之旅!
V8引擎的数据存储之道:理解深浅拷贝的基石
要理解JavaScript中的深浅拷贝,我们首先需要了解V8引擎是如何在内存中存储数据的。V8引擎,作为Chrome浏览器和Node.js的JavaScript执行引擎,其高效的数据存储机制是JavaScript高性能运行的保障。在V8中,数据主要存储在两种类型的内存区域:栈内存(Stack)和堆内存(Heap)。
栈内存(Stack)
栈内存是一种线性存储区域,其特点是自动分配和释放。它主要用于存储以下类型的数据:
- 基本数据类型(Primitive Types) :包括
Number、String、Boolean、Undefined、Null、`Symbol和BigInt。这些类型的值在内存中占据固定大小的空间,并且直接存储在栈中。当我们将一个基本数据类型的值赋给另一个变量时,实际上是创建了一个全新的副本,两者之间互不影响。 - 引用数据类型的地址(Reference Types' Addresses) :对于
Object(包括普通对象、数组、函数等)这类引用数据类型,它们的实际内容是存储在堆内存中的。而在栈内存中,存储的仅仅是这些引用数据在堆内存中的内存地址(或称引用)。当我们将一个引用数据类型赋给另一个变量时,拷贝的并非实际数据,而是这个内存地址。这意味着两个变量将指向堆内存中的同一个数据块。
栈内存的存取速度非常快,因为它遵循“后进先出”(LIFO)的原则,操作简单。但是,栈内存的空间相对较小,主要用于存储程序执行过程中的局部变量和函数调用帧。
堆内存(Heap)
堆内存是一个非线性的存储区域,其特点是动态分配和释放。它主要用于存储所有引用数据类型的实际内容,例如:
- 对象(Objects) :包括普通对象
{}、数组[]、函数function() {}等。 - 复杂数据结构:如
Map、Set等。
堆内存的空间相对较大,可以存储任意大小的数据。由于其非线性的存储方式,访问堆内存中的数据需要通过栈内存中存储的地址来查找,因此存取速度相对较慢。JavaScript的垃圾回收机制主要负责管理堆内存,自动回收不再被引用的对象,从而释放内存空间。
内存存储与拷贝的关系
理解栈内存和堆内存的区别,是理解深浅拷贝的关键。当进行变量赋值时:
- 基本数据类型:直接在栈内存中进行值拷贝,互不影响。
- 引用数据类型:在栈内存中拷贝的是引用地址。这意味着两个变量指向堆内存中的同一个对象。此时,如果通过其中一个变量修改了堆内存中的对象,另一个变量也会“看到”这些修改,这就是浅拷贝问题的根源。
深拷贝的目的,就是为了彻底解决引用数据类型在拷贝时共享内存的问题,确保新对象与原对象在内存上完全独立,互不影响。接下来,我们将详细探讨浅拷贝和深拷贝的具体实现和应用场景。
浅拷贝:只拷贝一层,共享深层引用
浅拷贝,顾名思义,就是只拷贝对象或数组的最外层。当原始对象中的属性值是基本数据类型时,浅拷贝会直接复制这些值;但当属性值是引用数据类型(如对象、数组)时,浅拷贝只会复制其在堆内存中的引用地址,而不会复制引用指向的实际对象。这意味着,新旧对象会共享这些深层引用,如果修改了新对象或旧对象中这些共享的引用类型属性,那么另一个对象也会受到影响。
浅拷贝的常见实现方式
在JavaScript中,有多种方式可以实现浅拷贝,它们各有特点,但都遵循“只拷贝一层”的原则。
1. Object.assign()
Object.assign()方法用于将所有可枚举属性的值从一个或多个源对象复制到目标对象。它会返回目标对象。当目标对象和源对象有同名属性时,源对象的属性会覆盖目标对象的属性。如果源对象属性的值是引用类型,那么拷贝的是引用。
const originalObj = { a: 1, b: { c: 2 } };
const shallowCopyObj = Object.assign({}, originalObj);
console.log(shallowCopyObj); // { a: 1, b: { c: 2 } }
shallowCopyObj.a = 10; // 修改基本数据类型,不影响原对象
console.log(originalObj.a); // 1
shallowCopyObj.b.c = 20; // 修改引用数据类型,影响原对象
console.log(originalObj.b.c); // 20
2. 扩展运算符(...)
扩展运算符(Spread syntax)在数组和对象中都可以用于实现浅拷贝。对于数组,它会展开数组的元素;对于对象,它会展开对象的属性。其行为与Object.assign()类似,对于深层引用类型,同样是拷贝引用。
数组的扩展运算符:
const originalArr = [1, { a: 2 }, 3];
const shallowCopyArr = [...originalArr];
console.log(shallowCopyArr); // [1, { a: 2 }, 3]
shallowCopyArr[0] = 10; // 修改基本数据类型,不影响原数组
console.log(originalArr[0]); // 1
shallowCopyArr[1].a = 20; // 修改引用数据类型,影响原数组
console.log(originalArr[1].a); // 20
对象的扩展运算符:
const originalObj = { a: 1, b: { c: 2 } };
const shallowCopyObj = { ...originalObj };
console.log(shallowCopyObj); // { a: 1, b: { c: 2 } }
shallowCopyObj.a = 10; // 修改基本数据类型,不影响原对象
console.log(originalObj.a); // 1
shallowCopyObj.b.c = 20; // 修改引用数据类型,影响原对象
console.log(originalObj.b.c); // 20
3. Array.prototype.slice()
slice()方法可以从已有的数组中返回选定的元素。它返回一个新数组,包含从 start 到 end(不包括 end)的 originalArr 元素。如果省略 start 和 end,则 slice() 会创建一个原数组的浅拷贝。
const originalArr = [1, { a: 2 }, 3];
const shallowCopyArr = originalArr.slice();
console.log(shallowCopyArr); // [1, { a: 2 }, 3]
shallowCopyArr[0] = 10; // 修改基本数据类型,不影响原数组
console.log(originalArr[0]); // 1
shallowCopyArr[1].a = 20; // 修改引用数据类型,影响原数组
console.log(originalArr[1].a); // 20
4. Array.prototype.concat()
concat()方法用于合并两个或多个数组。此方法不会更改现有数组,而是返回一个新数组。当不传入任何参数时,concat()会返回当前数组的一个浅拷贝。
const originalArr = [1, { a: 2 }, 3];
const shallowCopyArr = [].concat(originalArr);
console.log(shallowCopyArr); // [1, { a: 2 }, 3]
shallowCopyArr[0] = 10; // 修改基本数据类型,不影响原数组
console.log(originalArr[0]); // 1
shallowCopyArr[1].a = 20; // 修改引用数据类型,影响原数组
console.log(originalArr[1].a); // 20
5. Array.prototype.toReversed().reverse() (ES2023)
这是一个比较新的方法组合,toReversed()是ES2023中新增的非破坏性方法,它返回一个新数组,其中元素顺序颠倒。然后对其调用reverse()(这是一个破坏性方法,但在这里是对toReversed()返回的新数组操作,所以不会影响原数组)。这种组合方式也可以实现数组的浅拷贝。
const originalArr = [1, { a: 2 }, 3];
const shallowCopyArr = originalArr.toReversed().reverse();
console.log(shallowCopyArr); // [1, { a: 2 }, 3]
shallowCopyArr[0] = 10; // 修改基本数据类型,不影响原数组
console.log(originalArr[0]); // 1
shallowCopyArr[1].a = 20; // 修改引用数据类型,影响原数组
console.log(originalArr[1].a); // 20
6. Object.create()
Object.create()方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__。它实现了原型链上的继承,而不是直接的属性拷贝。如果用于浅拷贝,通常需要结合Object.assign()或其他方式来复制属性。
const originalObj = { a: 1, b: { c: 2 } };
const shallowCopyObj = Object.create(Object.getPrototypeOf(originalObj), Object.getOwnPropertyDescriptors(originalObj));
console.log(shallowCopyObj); // { a: 1, b: { c: 2 } }
shallowCopyObj.a = 10; // 修改基本数据类型,不影响原对象
console.log(originalObj.a); // 1
shallowCopyObj.b.c = 20; // 修改引用数据类型,影响原对象
console.log(originalObj.b.c); // 20
浅拷贝的适用场景
浅拷贝适用于以下场景:
- 对象或数组只包含基本数据类型:在这种情况下,浅拷贝足以满足需求,因为所有值都是独立的副本。
- 只需要拷贝对象的第一层属性:如果深层属性的修改不会对程序逻辑造成影响,或者深层属性本身就是基本数据类型,那么浅拷贝是简单高效的选择。
- 希望新旧对象共享部分深层数据:在某些特定场景下,我们可能希望新旧对象共享某些引用,以便一个对象的修改能同步反映到另一个对象上,此时浅拷贝正是我们需要的。
然而,当对象或数组中包含多层嵌套的引用类型数据,并且我们希望新旧对象完全独立,互不影响时,浅拷贝就显得力不从心了。这时,我们就需要引入深拷贝的概念。
深拷贝:层层复制,彻底独立
深拷贝,与浅拷贝相对,它会递归地复制对象或数组的所有层级。这意味着,无论属性是基本数据类型还是引用数据类型,深拷贝都会创建一个全新的副本,确保新对象与原对象在内存上完全独立,互不影响。修改新对象中的任何属性,都不会对原对象产生副作用,反之亦然。
深拷贝的常见实现方式
实现深拷贝的方法相对复杂,因为需要处理嵌套的对象和数组,以及一些特殊的数据类型和循环引用问题。
1. JSON.parse(JSON.stringify(obj))
这是最常见也是最简单的深拷贝方法之一。它的原理是先将JavaScript对象转换为JSON字符串,然后再将JSON字符串解析回JavaScript对象。由于JSON字符串中不包含引用,因此通过这种方式可以实现深拷贝。
const originalObj = {
a: 1,
b: {
c: 2,
d: [3, 4]
},
e: new Date(),
f: /abc/g,
g: undefined,
h: null,
i: Symbol('foo'),
j: function() {},
k: BigInt(10)
};
const deepCopyObj = JSON.parse(JSON.stringify(originalObj));
console.log(deepCopyObj);
// { a: 1, b: { c: 2, d: [ 3, 4 ] }, e: '2025-06-20T00:00:00.000Z', f: {} }
// 注意:Date对象被转换为字符串,RegExp对象被转换为空对象,undefined、Symbol、函数、BigInt丢失
deepCopyObj.b.c = 20; // 修改深层属性,不影响原对象
console.log(originalObj.b.c); // 2
deepCopyObj.b.d[0] = 30; // 修改数组元素,不影响原对象
console.log(originalObj.b.d[0]); // 3
优点:
- 实现简单,代码量少。
- 对于只包含基本数据类型和普通对象的简单数据结构,效果良好。
缺点:
- 无法处理特殊数据类型:
undefined、Symbol、function、BigInt在转换过程中会丢失。Date对象会转换为字符串,RegExp对象会转换为{}空对象。 - 无法处理循环引用:如果对象中存在循环引用(即对象中的某个属性引用了对象本身或其祖先),
JSON.stringify()会报错。 - 无法拷贝原型链:
JSON.parse(JSON.stringify(obj))会丢失对象的原型链。
因此,JSON.parse(JSON.stringify(obj))适用于对数据结构相对简单,不包含特殊类型和循环引用的对象进行深拷贝。
2. structuredClone() (ES2022)
structuredClone()是一个相对较新的全局函数,它提供了一种安全、高效地深拷贝结构化数据的方法。它能够处理许多JSON.parse(JSON.stringify())无法处理的类型,包括Date、RegExp、Map、Set、ArrayBuffer、Blob、File、ImageData等,并且能够正确处理循环引用。
const originalObj = {
a: 1,
b: {
c: 2,
d: [3, 4]
},
e: new Date(),
f: /abc/g,
g: undefined,
h: null,
i: Symbol('foo'),
j: function() {},
k: BigInt(10)
};
// 模拟循环引用
originalObj.self = originalObj;
const deepCopyObj = structuredClone(originalObj);
console.log(deepCopyObj);
// 能够正确拷贝Date、RegExp,并且处理循环引用
// 注意:函数和Symbol仍然无法拷贝,BigInt会报错
deepCopyObj.b.c = 20;
console.log(originalObj.b.c); // 2
deepCopyObj.b.d[0] = 30;
console.log(originalObj.b.d[0]); // 3
// 验证循环引用是否正确处理
console.log(deepCopyObj.self === deepCopyObj); // true
console.log(deepCopyObj.self === originalObj); // false
优点:
- 安全高效:浏览器原生实现,性能优异。
- 处理多种数据类型:支持
Date、RegExp、Map、Set、ArrayBuffer、Blob、File、ImageData等。 - 处理循环引用:能够正确处理对象中的循环引用。
缺点:
- 无法拷贝函数和Symbol:函数和Symbol类型的值在拷贝过程中会被忽略。
- 无法拷贝BigInt:
structuredClone()在处理BigInt时会抛出错误。 - 无法拷贝DOM节点:DOM节点也无法通过
structuredClone()进行拷贝。 - 无法拷贝原型链:与
JSON.parse(JSON.stringify())类似,它也会丢失对象的原型链。
structuredClone()是目前深拷贝结构化数据最推荐的方法,尤其是在浏览器环境中。但对于包含函数、Symbol、BigInt或DOM节点的复杂对象,仍需自定义深拷贝函数。
3. 手写递归深拷贝函数
为了实现一个更完善的深拷贝函数,我们需要手动编写递归逻辑,处理各种数据类型和循环引用。这是一个相对复杂的任务,但能提供最大的灵活性和控制力。
基本思路:
- 判断数据类型:根据不同的数据类型采取不同的拷贝策略。基本数据类型直接返回,引用数据类型创建新对象/数组。
- 处理循环引用:使用
Map或WeakMap来存储已经拷贝过的对象,避免无限递归。 - 递归拷贝:遍历对象的属性或数组的元素,对引用类型的值进行递归拷贝。
- 处理特殊对象:如
Date、RegExp等,需要特殊处理以保留其类型和值。
function deepClone(obj, hash = new WeakMap()) {
// 处理基本数据类型和null
if (obj === null || typeof obj !== 'object') {
return obj;
}
// 处理日期对象
if (obj instanceof Date) {
return new Date(obj);
}
// 处理正则对象
if (obj instanceof RegExp) {
return new RegExp(obj);
}
// 处理循环引用
if (hash.has(obj)) {
return hash.get(obj);
}
// 处理Map对象
if (obj instanceof Map) {
const newMap = new Map();
hash.set(obj, newMap);
obj.forEach((value, key) => {
newMap.set(deepClone(key, hash), deepClone(value, hash));
});
return newMap;
}
// 处理Set对象
if (obj instanceof Set) {
const newSet = new Set();
hash.set(obj, newSet);
obj.forEach(value => {
newSet.add(deepClone(value, hash));
});
return newSet;
}
// 处理数组或普通对象
const cloneObj = Array.isArray(obj) ? [] : {};
hash.set(obj, cloneObj); // 存储已拷贝的对象,用于处理循环引用
for (let key in obj) {
// 确保只拷贝对象自身的属性,不拷贝原型链上的属性
if (Object.prototype.hasOwnProperty.call(obj, key)) {
cloneObj[key] = deepClone(obj[key], hash);
}
}
return cloneObj;
}
// 测试
const originalObj = {
a: 1,
b: {
c: 2,
d: [3, 4]
},
e: new Date(),
f: /abc/g,
g: undefined,
h: null,
i: Symbol('foo'),
j: function() { console.log('hello'); },
k: BigInt(10),
m: new Map([['key1', 'value1'], ['key2', { nested: true }]]),
n: new Set([1, 2, { nested: true }])
};
// 模拟循环引用
originalObj.self = originalObj;
const deepCopyObj = deepClone(originalObj);
console.log(deepCopyObj);
deepCopyObj.b.c = 20;
console.log(originalObj.b.c); // 2
deepCopyObj.b.d[0] = 30;
console.log(originalObj.b.d[0]); // 3
console.log(deepCopyObj.e instanceof Date); // true
console.log(deepCopyObj.f instanceof RegExp); // true
console.log(deepCopyObj.j === originalObj.j); // 函数引用相同,因为函数通常不需要深拷贝
console.log(deepCopyObj.i === originalObj.i); // Symbol引用相同,因为Symbol是独一无二的
console.log(deepCopyObj.k === originalObj.k); // BigInt引用相同,因为BigInt是基本类型
console.log(deepCopyObj.m instanceof Map); // true
console.log(deepCopyObj.n instanceof Set); // true
// 验证循环引用是否正确处理
console.log(deepCopyObj.self === deepCopyObj); // true
console.log(deepCopyObj.self === originalObj); // false
优点:
- 高度可控:可以根据需求处理各种复杂数据类型和边缘情况。
- 处理循环引用:通过
WeakMap有效避免循环引用导致的栈溢出。 - 保留原型链:可以根据需要选择是否保留原型链(上述实现未保留,但可以通过
Object.getPrototypeOf和Object.setPrototypeOf实现)。
缺点:
- 实现复杂:需要考虑的细节较多,代码量较大。
- 性能开销:递归操作可能带来一定的性能开销,尤其是在处理非常大的对象时。
手写深拷贝函数是理解深拷贝原理的最佳方式,也是面试中考察开发者对JavaScript底层机制理解的重要题目。
4. 使用第三方库(如Lodash的_.cloneDeep)
在实际开发中,为了避免重复造轮子,并且确保深拷贝的健壮性和性能,通常会选择使用成熟的第三方库,例如Lodash提供的_.cloneDeep方法。
// 首先需要安装lodash:npm install lodash
import _ from 'lodash';
const originalObj = {
a: 1,
b: {
c: 2,
d: [3, 4]
},
e: new Date(),
f: /abc/g,
g: undefined,
h: null,
i: Symbol('foo'),
j: function() {},
k: BigInt(10)
};
// 模拟循环引用
originalObj.self = originalObj;
const deepCopyObj = _.cloneDeep(originalObj);
console.log(deepCopyObj);
deepCopyObj.b.c = 20;
console.log(originalObj.b.c); // 2
deepCopyObj.b.d[0] = 30;
console.log(originalObj.b.d[0]); // 3
console.log(deepCopyObj.e instanceof Date); // true
console.log(deepCopyObj.f instanceof RegExp); // true
console.log(deepCopyObj.j === originalObj.j); // 函数引用相同
console.log(deepCopyObj.i === originalObj.i); // Symbol引用相同
console.log(deepCopyObj.k === originalObj.k); // BigInt引用相同
// 验证循环引用是否正确处理
console.log(deepCopyObj.self === deepCopyObj); // true
console.log(deepCopyObj.self === originalObj); // false
优点:
- 功能完善:能够处理各种复杂数据类型、循环引用、特殊对象等。
- 性能优化:经过高度优化,性能通常优于手写实现。
- 易于使用:API简单,一行代码即可实现深拷贝。
- 健壮性高:经过大量测试和社区验证,稳定性强。
缺点:
- 引入额外依赖:需要安装和引入第三方库,增加了项目体积。
在实际项目中,如果对深拷贝的健壮性和性能有较高要求,并且不介意引入第三方依赖,那么使用Lodash等库是最佳选择。
深拷贝的适用场景
深拷贝适用于以下场景:
- 需要完全独立的数据副本:当修改副本不希望影响原始数据时,例如在状态管理(Redux、Vuex)中,为了保持数据的不可变性,通常需要对状态进行深拷贝。
- 处理复杂嵌套的数据结构:当对象或数组中包含多层嵌套的引用类型数据时,深拷贝能够确保所有层级的数据都被完全复制。
- 避免副作用:在函数式编程中,为了避免函数对外部数据产生副作用,通常会对输入数据进行深拷贝。
总结:深浅拷贝的选择与权衡
理解JavaScript的深浅拷贝机制,是前端开发者必备的核心技能。它们之间的主要区别在于拷贝的深度和对引用类型的处理方式。
| 特性 | 浅拷贝 (Shallow Copy) | 深拷贝 (Deep Copy) |
|---|---|---|
| 拷贝深度 | 只拷贝对象或数组的最外层 | 递归拷贝对象或数组的所有层级 |
| 引用类型处理 | 拷贝引用地址,新旧对象共享深层引用 | 创建全新的引用类型副本,新旧对象完全独立 |
| 性能 | 通常较快,开销较小 | 通常较慢,开销较大,尤其对于复杂对象 |
| 实现复杂度 | 简单,多种内置方法支持 | 相对复杂,需要处理循环引用、特殊类型等,或依赖第三方库 |
| 适用场景 | 对象简单,或希望共享深层引用时 | 需要完全独立的数据副本,避免副作用,处理复杂嵌套数据结构时 |
如何选择?
- 优先考虑
structuredClone():如果你的项目环境支持(现代浏览器或Node.js v17+),并且不需要拷贝函数、Symbol或BigInt,structuredClone()是目前最佳的内置深拷贝方案。 - 简单场景使用浅拷贝:如果数据结构简单,或者明确知道不需要深层独立,浅拷贝方法(如扩展运算符、
Object.assign())是更高效的选择。 - 复杂场景或兼容性要求高时使用第三方库:对于需要处理各种复杂数据类型、循环引用,并且对性能和健壮性有较高要求的场景,推荐使用Lodash的
_.cloneDeep等成熟的第三方库。 - 面试或深入理解时手写深拷贝:手写递归深拷贝函数是理解其原理和处理各种边界情况的最佳方式,也是面试中常考的题目。
JSON.parse(JSON.stringify())谨慎使用:虽然简单,但其局限性较大,只适用于特定场景。
掌握深浅拷贝,不仅能让你在面试中脱颖而出,更能帮助你在实际开发中编写出更健壮、更可维护的代码。希望本文能为你提供清晰的指引,让你在“拷贝”的世界里不再迷茫!