在实际项目开发中,我们有时需要对某些对象进行操作,但又不希望直接操作原对象,以防对其他逻辑产生影响,此时一般会复制一个副本进行操作。
而所谓的“复制”,在编程领域又可分为浅拷贝和深拷贝两种方式。
本文将从理解深浅拷贝的含义出发,逐步尝试封装浅拷贝函数和深拷贝函数。
栈内存与堆内存
在聊深浅拷贝之前,先梳理一下 JS 中数据存储的方式。
在 JS 中,存储空间可分为栈内存与堆内存,可以简单理解为:原始值存放在栈内存中,引用值存放在堆内存中。
原始值与栈内存
当我们声明一个变量,赋值为原始值的时候,JS 引擎会开辟一个栈内存空间,把原始值存放在栈内存空间中,并且使变量指向这个栈内存空间的地址。
当我们读取这个变量的时候,其实就是读取这个变量所对应的栈内存中的值。示意图如下所示:
引用值与堆内存
而对于引用值,当我们声明一个变量为引用值的时候,同样会开辟一个栈内存空间,并且这个变量同样指向栈内存的地址。但是引用值实际上是存放在堆内存中的,而栈内存空间存放的是堆内存的地址。
当我们使用变量读取某个引用值的时候,会首先通过栈内存地址找到栈内存空间,然后通过里面存放的堆内存地址,找到真正存放引用值的堆内存空间,从而读取引用值具体的值,示意图如下所示:
浅拷贝
所谓的浅拷贝,就是把栈内存空间的值直接复制一份,如果存放的是原始值,则毫无疑问地复制它的值。如果存放的是引用值(即堆内存地址),那么也是直接将堆内存地址复制下来。
所以浅拷贝有个显而易见的问题,就是拷贝引用值的时候,只抄下了它的门牌号,并没有对家里的东西进行复制,所以如果在原对象中对引用值进行修改,那么浅拷贝后的对象中所对应的引用值也会受影响(因为浅拷贝只拷贝了门牌号,最终访问的还是同一个房屋)。
理解了浅拷贝的含义后,将尝试手写一个浅拷贝函数,并作出如下约定:
- 只对数组和对象进行处理
- 只对自身且可枚举的属性进行拷贝
- 不处理 symbol 属性
function clone(origin) {
if (origin instanceof Array) {
// 处理数组的情况
// 展示两种简易的方法
// return origin.slice();
// return [...origin];
// 创建一个与原数组同样长度的数组,且全部元素填充为0。
const result = new Array(origin.length).fill(0);
origin.forEach((item, index) => {
result[index] = item;
});
return result;
} else if (Object.prototype.toString.call(origin) === "[object Object]") {
// 处理对象的情况
// 展示两种简易的浅拷贝方法
// return { ...origin }
// return Object.assign({}, origin)
const result = {};
// 遍历自身及原型链上的可枚举属性
for (let key in origin) {
// 是自身属性才进行克隆
if (Object.hasOwn(origin, key)) {
result[key] = origin[key];
}
}
// 注:以下是对symbol属性进行处理,一般无需使用到,可根据业务进行调整
// 获取自身所有symbol属性(包括不可枚举的)
// const symbolKeys = Object.getOwnPropertySymbols(origin);
// // 判断属性的可枚举性
// for (let key of symbolKeys) {
// const descriptor = Reflect.getOwnPropertyDescriptor(origin, key)
// // 是可枚举属性才进行克隆
// if (descriptor.enumerable) {
// result[key] = origin[key]
// }
// }
return result;
}
// 其他情况如原始值,或Function等内置对象直接返回。
return origin;
}
// ===================测试用例======================
const obj1 = {
a: 'hello',
b: [1, 2, 3]
}
const obj2 = clone(obj1)
console.log(obj2) // { a: 'hello', b: [ 1, 2, 3 ] }
obj1.a = 'hi'
obj1.b[0] = 99
console.log(obj1) // { a: 'hi', b: [ 99, 2, 3 ] }
console.log(obj2) // { a: 'hello', b: [ 99, 2, 3 ] }
从上面的测试用例可以看到,当对象中的属性是引用值时,那么此引用值的修改,会影响到拷贝后的对象,这是浅拷贝的缺点。
深拷贝
为了解决上面提到的浅拷贝对引用值的拷贝的缺陷,就有了深拷贝这一概念。
深拷贝与浅拷贝的差别主要体现在对引用值的处理上。
浅拷贝对引用值进行拷贝的时候,是直接复制栈内存中存储的堆内存的地址(即把引用值的门牌号给抄下来)。
而深拷贝则是会通过门牌号找到对应的房屋,并且进入房屋内,将里面的所有物品一一复制到另一间新的房屋中(当然,如果里面有对其他引用值的引用,那么也是需要对这些引用值进行递归处理)。
所以深拷贝之后,原对象中的引用值的修改,是不会影响到拷贝后的对象,反之亦然。
基础实现
基础逻辑及实现
函数实现逻辑为:
- 函数可以接收任何类型的值,并返回其深拷贝的值(传入原始值则直接返回)。
- 函数不做拷贝操作,直接返回。
- 因 JS 中有非常多的内置对象,所以只对 Array,Object,Date 等常见对象进行拷贝,其他直接返回。
function deepClone(origin) {
// 原始值直接返回, 函数不作处理,直接返回
if (origin === null || typeof origin !== "object") return origin;
// 某些内置对象,这里只举几个常见的进行处理
if (origin instanceof Date) return new Date(origin);
if (origin instanceof RegExp) return new RegExp(origin);
if (origin instanceof Map) return new Map(origin);
if (origin instanceof Set) return new Set(origin);
// 处理数组
if (origin instanceof Array) {
const result = [];
origin.forEach((item, index) => {
// 递归处理:如item是原始值,则直接得到此值。如是引用值,则对此引用值进行递归深拷贝
result[index] = deepClone(item);
});
return result;
}
// 处理对象
if (Object.prototype.toString.call(origin) === "[object Object]") {
const result = {};
// 获取对象自身及原型链上的可枚举属性
for (let key in origin) {
// 只处理自身属性(除symbol属性以外)
if (Object.hasOwn(origin, key)) {
// 递归处理:如item是原始值,则直接得到此值。如是引用值,则对此引用值进行递归深拷贝
result[key] = deepClone(origin[key]);
}
}
return result;
}
// 更多未处理的内置对象,暂不处理,直接返回
return origin;
}
// ===================测试用例======================
const obj1 = {
a: 'hello',
b: {
b1: [1, 2, 3]
}
}
const obj2 = deepClone(obj1)
console.log(obj2) // { a: 'hello', b: { b1: [ 1, 2, 3 ] } }
obj1.b.b1[0] = 99
console.log(obj1) // { a: 'hello', b: { b1: [ 99, 2, 3 ] } }
console.log(obj2) // { a: 'hello', b: { b1: [ 1, 2, 3 ] } }
从上面测试用例可知,深拷贝可以解决浅拷贝对引用值拷贝的局限性。
隐患
如果使用下面的测试用例对上述深拷贝函数进行测试,那么程序将会报错
const obj1 = {
name: 'obj1'
}
const obj2 = {
name: 'obj2',
// obj2 引用 obj1
ref: obj1
}
// obj1循环引用obj2
obj1.ref = obj2
const obj3 = deepClone(obj1) // Uncaught RangeError: Maximum call stack size exceeded
上面代码中,obj1 和 obj2 两个对象,都互相引用了对方,即出现循环引用现象。
如果使用上面的深拷贝函数拷贝循环引用的对象时,就会无限地进行递归拷贝,出现调用栈溢出的报错。
进阶实现
为了解决上述深拷贝函数无法处理循环引用的问题,我们需要对上述函数进行改进。
上面基础函数存在的问题是,不会判断传递进来的引用值,在此之前是否已经处理过,而是当成一个新对象去进行拷贝,这样的话就会有无数个“新对象”需要进行拷贝,也就会出现调用栈溢出的报错。
所以我们的核心思路是:
- 创建一个“表”,用于记录每次进行深拷贝的引用值,以及拷贝之后生成的值(注意:记录引用值实际上是记录引用值的堆内存地址,而不是里面的具体内容,判断两个引用值是否是同一个,使用的也是堆内存地址作比较)。
- 在对引用值进行拷贝之前,在表中查找此引用值是否已经处理过,是的话直接返回拷贝后的值,否则才进行拷贝处理,并将其记录在表中。
上述所说的表,本文将使用 WeakMap 实现。WeakMap 与 Map 相比,对垃圾回收机制更加友好,用一句话概括它们之间的最大差异就是:
WeakMap 的键名所对应的引用值,它的被引用数不会 +1,当其他地方没有对其进行引用时,垃圾回收机制会将其回收。
具体代码如下所示:
function deepClone(origin, visited = new WeakMap()) {
// 原始值直接返回, 函数不作处理,直接返回
if (origin === null || typeof origin !== "object") return origin;
// 判断此引用值之前是否进行过拷贝,是的话直接返回拷贝后的引用,不再继续递归
if (visited.has(origin)) return visited.get(origin);
// 某些内置对象,这里只举几个常见的进行处理
if (origin instanceof Date) return new Date(origin);
if (origin instanceof RegExp) return new RegExp(origin);
if (origin instanceof Map) return new Map(origin);
if (origin instanceof Set) return new Set(origin);
// 处理数组
if (origin instanceof Array) {
const result = [];
// 将“原引用:拷贝后的引用”记录下来
visited.set(origin, result);
origin.forEach((item, index) => {
// 递归处理:如item是原始值,则直接得到此值。如是引用值,则对此引用值进行递归深拷贝
result[index] = deepClone(item, visited);
});
return result;
}
// 处理对象
if (Object.prototype.toString.call(origin) === "[object Object]") {
const result = {};
// 将“原引用:拷贝后的引用”记录下来
visited.set(origin, result);
// 获取对象自身及原型链上的可枚举属性
for (let key in origin) {
// 只处理自身属性(除symbol属性以外)
if (Object.hasOwn(origin, key)) {
// 递归处理:如item是原始值,则直接得到此值。如是引用值,则对此引用值进行递归深拷贝
result[key] = deepClone(origin[key], visited);
}
}
return result;
}
// 更多未处理的内置对象,暂不处理,直接返回
return origin;
}
// ===========测试用例============
const obj1 = {
name: 'obj1'
}
const obj2 = {
name: 'obj2',
// obj2 引用 obj1
ref: obj1
}
// obj1循环引用obj2
obj1.ref = obj2
const obj3 = deepClone(obj1)
console.log(obj3) // <ref *1> { name: 'obj1', ref: { name: 'obj2', ref: [Circular *1] } }
总结
简而言之,浅拷贝和深拷贝的差别就是:是否会对引用值进行递归复制。浅拷贝性能开销小,深拷贝对数据而言更安全,可根据实际需求在二者中进行选择。
而在实现浅拷贝函数和深拷贝函数时,需要注意以下几点:
- 判断属性值的类型,采取不同的处理策略。
- 注意过滤原型链上的属性。
- 实现深拷贝函数时,需要注意循环引用的问题。