在准备面试的过程中,我发现对于深浅拷贝的细节还是需要捋一遍,写下本文记录思考过程。
- 什么是深/浅拷贝,跟赋值有何区别?
- 深/浅拷贝的实现方式有几种?
赋值与深浅拷贝
在了解这几个概念之前,我得先回顾下基本类型与引用类型的存储机制。
基本类型(Primitive Types)
包括:String
、Number
、Boolean
、Null
、Undefined
、Symbol
、BigInt
- 存储方式:直接存储在栈内存中,变量保存的是实际值
引用类型(Reference Types)
包括:Object
、Array
、Function
、Date
、RegExp
、Map
、Set
等
- 存储方式:实际数据存储在堆内存中,变量保存的是堆内存地址的引用
赋值
赋值操作是指将一个变量的值赋予另一个变量。对于原始类型来说,赋值意味着创建这个值的一个完全独立的副本。这意味着改变一个变量不会影响另一个变量 。
js
let a = 10;
let b = a; // 赋值操作
a = 100;
console.log(b); // 10
特点:
- 创建值的完全独立副本
- 修改新变量不影响原变量
当涉及到引用类型的赋值时,情况略有不同。当你将一个对象或数组赋值给另一个变量时,实际上是将引用(内存地址)复制了一份。两个变量指向同一个内存地址,因此修改其中一个变量会影响另一个变量
js
let a = [1,2,3]
let b = a
a[0] = 100
console.log(b) //[100,2,3]
特点:
- 只复制内存地址引用
- 新旧变量指向同一个对象
- 通过任一变量修改都会影响另一个
为了克服引用类型赋值的问题,可以使用拷贝。拷贝分为浅拷贝和深拷贝。
如果遇到面试官问你深浅拷贝,可以先说这几句话:
- 简单数据类型拷贝值
- 复杂数据类型拷贝地址 (浅拷贝)
- 复杂数据类型 开辟新的内存空间 拷贝值 (深拷贝) 再来详细说明这两个的区别。
浅拷贝(Shallow Copy)
-
定义 :创建一个新对象,并复制原对象的属性。如果属性是基本类型 (如
number
,string
),直接复制值;如果是引用类型 (如object
,array
),则复制其内存地址(即新旧对象共享同一引用)。 -
特点:
- 修改原对象或新对象的引用类型属性时,另一方会受影响。
- 仅复制一层,嵌套对象不独立。
深拷贝(Deep Copy)
-
定义: 不仅复制顶级属性 ,递归复制对象及其所有嵌套属性,新旧对象完全独立,不共享任何引用。
-
特点:
- 完全独立的对象结构,修改互不影响。
- 处理复杂对象时性能开销较大。
浅拷贝的实现
1. 使用 Object.assign()
Object.assign()
方法可以将一个或多个源对象的可枚举属性复制到目标对象中,从而实现浅拷贝。
js
const original = { a: 1, b: { c: 2 } };
const shallowCopy = Object.assign({}, original);
console.log(shallowCopy); // 输出: { a: 1, b: { c: 2 } }
// 修改第一层属性
shallowCopy.a = 10;
console.log(original.a); // 输出: 1 (不影响原对象)
// 修改嵌套对象
shallowCopy.b.c = 20;
console.log(original.b.c); // 输出: 20 (影响原对象)
2. 使用扩展运算符(Spread Operator)
扩展运算符 ...
可以用来展开对象或数组,从而实现浅拷贝。
js
const original = { a: 1, b: { c: 2 } };
const shallowCopy = { ...original };
console.log(shallowCopy); // 输出: { a: 1, b: { c: 2 } }
// 修改第一层属性
shallowCopy.a = 10;
console.log(original.a); // 输出: 1 (不影响原对象)
// 修改嵌套对象
shallowCopy.b.c = 20;
console.log(original.b.c); // 输出: 20 (影响原对象)
3. 使用数组的 slice()
方法
对于数组,可以使用 slice()
方法实现浅拷贝。
js
const originalArray = [1, 2, { value: 3 }];
const shallowCopyArray = originalArray.slice();
console.log(shallowCopyArray); // 输出: [1, 2, { value: 3 }]
// 修改第一层元素
shallowCopyArray[0] = 10;
console.log(originalArray[0]); // 输出: 1 (不影响原数组)
// 修改嵌套对象
shallowCopyArray[2].value = 30;
console.log(originalArray[2].value); // 输出: 30 (影响原数组)
自己实现一个
js
function clone(target) {
if(typeof target === 'object' && target !== null){
let cloneTarget = Array.isArray(target) ? []:{}; //兼容数组
for (const key in target) {
cloneTarget[key] = target[key];
}
return cloneTarget;
} else {
return target
}
};
// 测试
let obj = {a: 1, b: 2, c: {d: 3}};
let clonedObj = clone(obj);
clonedObj.c.d = 4;
console.log(obj.c.d);//4 也被修改了
clonedObj.a = 5;
console.log(obj.a);//1 没有被修改 互不影响
let a = clone('abc')
console.log(a) //abc
let b = clone(123)
console.log(b) //123
let c = clone(null)
console.log(c) //null
let d = clone(undefined)
console.log(d) //undefined
深拷贝的实现
最简单粗暴的做法
1. JSON.parse(JSON.stringify()):
js
let obj1 = { name: "Alice", details: { age: 25 } };
let obj2 = JSON.parse(JSON.stringify(obj1));
obj2.details.age = 30;
console.log(obj1.details.age); // 25 不受影响
局限性:
- 无法处理函数、
undefined
、Symbol
、Date
、RegExp
等特殊值。 - 无法处理循环引用的对象。
2.structuredClone
structuredClone(obj)
是一个 原生 JavaScript API ,用于进行深拷贝操作,旨在创建对象的完全独立副本,包括嵌套的对象、数组、Map
、Set
、Date
、RegExp
等数据类型。与传统的深拷贝方法(如 JSON.parse(JSON.stringify(obj))
)相比,structuredClone()
具有更强的功能和更高的效率。 局限性
-
函数(Function) :拷贝会丢失函数内容,结果为
undefined
。 -
Symbol
:会丢失Symbol
类型的属性。
3.递归实现深拷贝
把之前浅拷贝的代码稍微改下就能实现一个基础的递归深拷贝了
js
function clone(target){
if(typeof target === 'object' && target !== null){
let cloneTar = Array.isArray(target)? []:{}
for(let key in target){
cloneTar[key] = clone(target[key])
}
return cloneTar
}else {
return target
}
}
测试:
js
const target = {
field1: 1,
field2: undefined,
field3: {
child: 'child'
},
field4: [2, 4, 8]
};
let clonedObj = clone(target)
clonedObj.field4.push(10)
clonedObj.field3.child = 'hello'
clonedObj.field1 = 10
console.log(target,clonedObj)
目前来看没啥问题,但如果target的属性直接或间接引用自身呢?
js
const target = {
field1: 1,
field2: undefined,
field3: {
child: 'child'
},
field4: [2, 4, 8],
};
target.self = target
console.log(clone(target))
第一次调用clone(target),之后遍历到self属性时,cloneTar[self] = clone(target[self]) 不就变成了cloneTar[self] = clone(target),再重复之前的操作,会不停的递归下去,不会命中停止递归的条件。
为了避免这种情况,可以采用一些高级的深拷贝策略来处理循环引用,比如:
记录已经拷贝过的对象: 在开始深拷贝之前,维护一个Map
或WeakMap
来存储已经拷贝过的对象和它们的副本之间的映射。在递归过程中,如果发现当前需要拷贝的对象已经在映射中,则直接使用已有的副本,而不是再次尝试去拷贝。
WeakMap
对其键(对象)的引用是弱引用。这意味着如果一个对象仅被 WeakMap
作为键持有,并且没有其他强引用指向它,垃圾回收器可以在任何时间点回收该对象。相比之下,Map
持有的是强引用,只要对象存在于 Map
中,它就不会被垃圾回收,即使在代码的其他地方不再需要这个对象。因此,在深拷贝过程中使用 WeakMap
可以减少内存泄漏的风险,因为它允许未使用的对象被及时回收。
WeakMap
的API限制了键必须是对象,不能是原始类型值,这也恰好符合在深拷贝过程中使用对象作为键的需求。所以我们更倾向于用weakMap
来记录已经拷贝过的对象。
具体步骤:
- 检查
weakmap
中有无克隆过的对象 - 有就直接返回
- 没有就将当前对象作为
key
,克隆对象作为value
进行存储 - 继续克隆
js
function clone(target) {
const map = new WeakMap()
function _clone(target) {
if (typeof target === 'object' && target !== null) {
let cloneTar = Array.isArray(target) ? [] : {}
if (map.has(target)) {
return map.get(target)
}
map.set(target, cloneTar)
for (let key in target) {
cloneTar[key] = clone(target[key])
}
return cloneTar
} else {
return target
}
}
return _clone(target)
}
还可以使用es6的默认参数,这样更简洁。
js
function clone(target,map = new WeakMap()){
if(typeof target === 'object' && target !== null){
let cloneTar = Array.isArray(target)? []:{}
if(map.has(target)){
return map.get(target)
}
map.set(target,cloneTar)
for(let key in target){
cloneTar[key] = clone(target[key],map)
}
return cloneTar
}else {
return target
}
}
本以为到了这一步,深拷贝就已经完成的很好了,考虑到了数组,会使用递归解决问题,还解决了循环引用,甚至考虑到了内存泄露,但是看了这篇文章才发现我还是太天真了。面试官可能还想在这道题目上看到你更多的品质。
for...in
会遍历对象自身和继承的可枚举属性,这意味着它需要检查原型链上的属性,因此性能开销较大。我们可以自己实现一个forEach方法来进行性能优化。
js
/**
* 自定义的数组遍历函数,对数组的每个元素执行指定的回调函数。
* @param {Array} array - 需要遍历的数组。
* @param {Function} iteratee - 对数组每个元素执行的回调函数,接收元素和索引作为参数。
* @returns {Array} 返回原数组。
*/
function forEach(array, iteratee) {
// 初始化索引为 -1
let index = -1;
// 获取数组的长度
const length = array.length;
// 循环遍历数组,对每个元素执行 iteratee 函数
while (++index < length) {
iteratee(array[index], index);
}
// 返回原数组
return array;
}
再来改造一下上个版本的:
js
function clone(target,map = new WeakMap()){
if(typeof target === 'object' && target !== null){
const isArray = Array.isArray(target);
let cloneTar = isArray? []:{}
if(map.has(target)){
return map.get(target)
}
map.set(target,cloneTar)
const keys = isArray? undefined : Object.keys(target)
//如果target是数组,forEach直接遍历target即可,如果是对象,forEach就是遍历由target的可枚举属性组成的数组
forEach(keys || target, (value,key) => {
if(keys){
key = value
}
cloneTar[key] = clone(target[key],map)
})
return cloneTar
}else {
return target
}
}
if (keys) { key = value; }
的作用
- 当
target
是普通对象时,keys
为Object.keys(target)
返回的键名数组。此时forEach
遍历的是keys
数组,回调函数中的value
代表当前遍历到的键名,key
是遍历的索引。为了能正确使用键名访问对象属性,就需要把key
赋值为value
。 - 当
target
是数组时,keys
为undefined
,forEach
直接遍历数组元素,value
是数组元素,key
是数组索引,这种情况下key
就保持原本的索引值。
是为了能统一使用 cloneTar[key] = clone(target[key],map)
主要完成了对基本数据类型,对象,数组就差不多够了吧,其他的引用类型无非是多加些条件判断,跟面试官表达自己的想法即可。 比如:
js
if (target instanceof Set) {
const clonedSet = new Set();
target.forEach(value => clonedSet.add(clone(value, map)));
return clonedSet;
}
总结:
对比项 | 深拷贝 (Deep Copy) | 浅拷贝 (Shallow Copy) | 赋值 (Assignment) |
---|---|---|---|
拷贝方式 | 递归复制所有嵌套数据 | 仅复制第一层,内部引用仍指向原对象 | 直接复制引用,两个变量指向同一对象 |
是否独立 | ✅ 新对象完全独立 | ❌ 共享引用,修改嵌套对象会影响原对象 | ❌ 直接共享同一对象 |
影响范围 | 深层嵌套对象也被复制 | 只复制一层,内部对象仍然共享 | 变量名不同,但本质是同一个对象 |
修改后互不影响 | ✅ 互不影响 | ❌ 修改嵌套对象会影响原对象 | ❌ 修改任意一方都会影响另一方 |
常见方法 | JSON.parse(JSON.stringify(obj)) (丢失方法 & undefined ) structuredClone(obj) (现代浏览器) 手写递归或 lodash.cloneDeep() |
Object.assign({}, obj) [...arr] (数组) { ...obj } (对象) |
let newVar = oldVar; |