嗨嗨嗨~我唱着歌出现了!唱的什么歌——孙燕姿的《开始懂了》。好叭,我就是搞个抽象,毕竟是个搞笑女哈哈哈哈哈
有人说JS中99%都是浅拷贝,真的假的呢?今天就让我们来一探究竟,看看拷贝到底是什么?深拷贝和浅拷贝又有什么区别呢?它们是使用什么方法实现的呢?
学习之前,如果JS的内存机制还有不懂的喔,请看上一篇:了解JavaScript的底层——内存机制—掘金
拷贝的类型
我们先复习一段代码:
let a = 1
let b = a
a = 2
console.log(b); // 输出 1 不受a的影响
// 引用地址赋值,引用地址在栈中,值在堆里;但是不是拷贝,需要成为两个一样的对象
let obj = {
a:1
}
let obj2 = obj;
obj.a = 2;
console.log(obj2); // 输出 { a:2 },受原对象的影响
之前我们在js的内存机制中,我们已经知道原始类型(基本类型)在栈里面,原始类型的赋值是值的复制,引用类型的赋值是引用地址的复制。所以上面代码的结果,我们很容易得出结果b还是输出1,obj2中的a属性的值也发生改变了。
其实深拷贝和浅拷贝的定义很容易理解,就像代码中展示的对象obj2一样,新对象受原对象影响,这就是浅拷贝。而像代码中的变量b一样,新对象不受原对象的影响,这就是深拷贝。
简单理解的话,浅拷贝就像是阳光下产生的影子,我们干什么,影子也干什么,会受我们的影响;而深拷贝就像是把我们拉进了实验室克隆出来了一个新的人,深刻的复刻一份,但是他是独立的,完全不受我们的影响了。
但是,上面的代码其实不是标准意义上的拷贝,我们今天所聊的拷贝只针对引用类型的对象,是基于原对象拷贝得到一个新对象的过程。在原始类型的拷贝只有一个可以聊:原始类型的赋值一定是深拷贝。而在上述代码中obj赋值给obj2的过程,没有创建一个新的对象,所以也不能称之为拷贝。
实现浅拷贝的方法
1. Object.create(x) ——创建对象
在之前聊原型的文章中,我们提到了,Object.create()是以一个现有对象作为原型,创建一个新对象的静态方法。所以我们是可以利用它以obj为原型直接创建一个新对象,新对象会是一个空对象。代码如下:
let obj = {
a:1
}
// 创一个新对象
let newObj = Object.create(obj)
obj.a = 2
console.log(newObj.a); // 输出 2
此刻,obj已经是一个空对象newObj的原型,所以newObj是可以访问到obj身上的a属性的,当原对象的a属性改变,新对象的a属性同样改变。这样,我们就实现了一个浅拷贝。
2. Object.assign({}, obj) ——对象拼接
第二种实现浅拷贝的方法是:对象的合并。Object.assign()可以将一个或者多个原对象中所有可枚举的自有属性复制到目标对象,并返回修改后的目标对象。其实就是将参数中的第二个对象的属性拼接到第一个对象里,我们举个例子:
let obj = {
a:1,
b:{
n:2
}
}
// 将多个对象进行合并,拼到第一个里面
let obj2 = Object.assign({}, obj)
obj.b.n = 20;
console.log(obj2); // 输出{a:1, b:{n:20}}
上述代码中第九行,我们创建了一个变量obj2用来存放新的对象,使用Object.assign将一个空对象和原对象一起拼接,会将原对象obj的全部属性复制到空对象中,并返回拼接后的对象。这个过程我们使用图示来看看更容易理解:
得到的对象obj2中,a属性的值是原始类型是相等的,b属性的值是引用类型,又会在堆空间开辟一个地址存放b的值,所以b的内部存的还是值的引用地址。所以拼接后的对象开辟一个新的地址,地址赋值给obj2。
代码的第十行,我们在修改原始对象b属性的值,在原对象和新对象中b的存放的引用地址还是不变的,所以新对象中的b的值会受到原对象的值改变的影响,即不够深彻的拷贝还是受原对象影响,也可以说是复用了子对象的指针,是浅拷贝。
3. [].concat(arr)——数组拼接
我们知道数组也是一种特殊的对象,而数组自带了一个concat()方法——用于连接两个或多个数组。concat()方法不会更改现有数组,而是返回一个新数组,其中包含已连接数组的值。举个例子:
let arr = [1,2, {n: 3}]
let arr2 = [3,4]
// concat 凭空开辟了一个空间复杂度
let arr3 = arr.concat(arr2)
console.log(arr3); // 输出 [1,2,{n:3},3,4]
拷贝需要创建一个新对象和原对象一样,那我们可以使用空数组和原数组实现拼接,返回的拼接后的数组就实现了对原对象的拷贝。
let arr = [1,2, {n: 3}]
let newArr = [].concat(arr); // newArr = [1,2,{n:3}]
// 试验是否为浅拷贝
arr[0] = 10
arr[2].n = 30
console.log(arr); // 输出[10,2,{n:30}]
console.log(newArr); // 输出 [1,2,{n:30}]
我们从结果可以发现,当数组的值存在对象时,拷贝的其实还是对象的地址。两个数组中的对象是同一个引用地址,所以原数组中的对象的值改变,新数组中的对象访问的也是改变后的值。即[].concat(arr)实现的还是不够深刻的拷贝,仍然受到原对象的影响,仍然是复用子对象的指针,是浅拷贝。
4. [...arr]——数组解构
解构赋值语法是ES6引入的一种强大且灵活的语法,可以将数组中的值或对象的属性取出,赋值给其他变量。它在编程中可以有多种使用方式,具体分为以下几种:
批量声明——同时声明多个变量并赋值时,我们可以使用数组去解构赋值。代码如下:用来赋值的数组中的值就是原数组对应下标的变量的值。
// let a = 1
// let b = 2
// let c = 3
// 批量化声明变量 解构赋值
let [a,b,c] = [1,2,3]
省略元素——不需要定义变量来获取的元素我们可以跳过,直接声明一个或几个变量来想要获取的元素,代码如下:
// 只定义了d,对应下标的值赋值:d = 3
let [, , d] = [1, 2,3] // d = 3
剩余元素——使用(...rest)来获取剩下的所有元素,rest默认接受剩余的值,是一个数组的形式。代码如下:
let [x,y,z, ...w] = [1,2,3,4,5,6,7]
// w = [4,5,6,7]
// 接受函数的多个形参
function add(...args){
const res = 0
for(let i = 0; i < args.length; i++){
res = res + args[i]
}
}
add(1, 2, 3, 4)
相同结构承接值——在使用过程使用相同的结构方式去声明变量承接各个值。代码如下:
// 可以传arr,arr是一整个数组,function(arr){ } => arr = [1,2]
// 但是参数上做解构 每个值可以直接用,x = 1,y = 2
function f([x,y]){
}
f([1,2])
// 定义一样的结构方式去承接值
let [foo, [bar, [baz]]] = [1, [2, [3]]]
在对象解构中尤其特别:当我们定义来承接value的变量和对象的key是同一个单词,我们可以简写为 let {key1,key2} = obj来承接各个属性的值(注意:这里的key1和key2是变量名,只不过与obj的各个属性名相同)。代码如下:
// 对象中的解构——举例1
let obj = {
name:'mei',
age:18
}
// let {name: name, age: age} = obj 也是定义一样的结构方式去承接对象的value值
// 但是当我们定义的对象的key和value同一个单词,我们可以简写
let {name,age} = obj
console.log(name,age); // 输出mei, 18
//对象中的解构——举例2
let obj2 = {
name:'Tom',
age:18,
like:[
'coding',
{x: 'eat'}
]
}
// obj2.name obj2.like[1].x
let {name,like:[ , {x}]} = obj
console.log(name,x); // 输出Tom, eat
默认值——数组解构时没有被赋新值时,可以使用默认值。代码如下:
let [a, b = 1] = [10] // 数组[10] 没给b赋值,所以a被赋值10,b使用默认值1
let [S = 'hello',R,T,...O] = []
console.log(S) // S = hello,默认值
let [s = 'hello',r,t,...o] = [1]
console.log(s) // s = 1,被赋值了
所以,在解构语法中,[...arr]是将arr数组中的元素获取到再赋值到一个新数组,所以是可以获得一个新的数组对象,且和原来的arr数组对象一模一样的。也和上一个方法一样,新数组也会复用子对象的指针,[...arr]也能实现浅拷贝。
5. arr.slice()——数组切片
数组自带的slice() 方法,可从已有的数组中返回选定的元素。它可提取字符串的某个部分,并以新的字符串返回被提取的部分。slice() 方法不会改变原始数组。
slice()包括两个参数start(起始的下标)和end(结束的下标),切除下来的部分不包括end的下标的元素。如果省略了start,则默认使用0;省略了 end,则使用 array.length,提取所有元素直到末尾。
所以我们可以使用arr.slice()返回一个新数组,直接从arr的第一位提取到最后一位,同样实现一个浅拷贝。
6. arr.toReversed().reverse()——数组反转
在JavaScript中,数组自带两个反转方法toReversed() 和reverse() 。它们的区别在于前者是不会影响原数组返回一个新数组,后者是直接使原数组反转。代码如下:
const arr = [1, 2, 3];
const newArr = arr.toReversed();
console.log(arr); // 输出: [1, 2, 3]
console.log(newArr); // 输出: [3, 2, 1]
const arr2 = [1,2,3]
const newArr2 = arr2.reverse();
console.log(arr2); // 输出: [3, 2, 1]
console.log(newArr2); // 输出: [3, 2, 1]
所以,我们想着可以在不改变原数组的情况下,将原数组进行了两次反转的结果返回,也能得到一个和原数组一样的数组。代码如下:
let arr = ['a','b','c','d','e']
// 数组的反转方法reverse,创建一个新对象是原对象的反转(toReversed)后再反转
console.log(arr.toReversed().reverse()); // 输出 ['a','b','c','d','e']
console.log(arr);
这两个方法组合使用,也是创建了一个新数组对象,同样是可以复用子对象的指针的(请自行尝试了,我相信你懂了!),实现了一个浅拷贝。
7. 手写一个浅拷贝shallow()
首先,我们要知道浅拷贝只需要拷贝第一层,内部还存在子对象时,我们也是照搬子对象的指针的。
所以,浅拷贝的实现原理是:
- 创建一个新对象
for in语句进行原对象的属性和值的遍历- 判断是否是原对象的
显示属性后,往新对象中引入新的键值对与原对象的一样
在手写时,值得注意的细节是有两点:
- for in遍历对象时,如果在对象的对象原型上也有属性,则它身上的
隐式属性也会全部遍历出来。所以我们需要使用obj.hasOwnProperty(key) 判断一个属性是不是该对象显示拥有的属性,如果是,才在新对象中引入相同的属性。 - 在引入原对象的属性时,点key(
.key) 是将‘key’字符串识别成属性名了,所以我们要使用方括号 ([key]),让新对象识别一个key变量的值作为属性名。
手写浅拷贝的代码如下:
let person = {
name:'美美',
age:18,
like:{
n:'running'
}
}
// obj[c] 读到c是一个对象 obj['c']
// 对象的赋值是浅拷贝
function shallow(obj){
let res = {}
for(let key in obj){
// key 是不是 obj 显示拥有的属性
if(obj.hasOwnProperty(key)){
res[key] = obj[key]
}
}
return res
}
console.log(shallow(person))
实现深拷贝的方法
1. JSON.parse(JSON.stringify(obj))——JSON序列化和反序列化
在js这么多年以来其实本质实现不了深拷贝,但是有一个很凑巧的方法,可以实现深拷贝的效果。我们先来看个对象的示例:
let obj = {
name: '钟总',
age: 18,
like: {
n: '金铲铲'
},
a: true,
b: null,
c: undefined,
d: Infinity, // Infinity 属于Number,无穷大
e: -Infinity,
f: NaN, // NaN 属于Number
g: Symbol(1),
// h:123n // 报错,不能处理大整型数据
i: function() {}
}
let str = JSON.stringify(obj)
console.log(str) // 输出 {"name":"钟总","age":18,"like":{"n":"金铲铲"},"a":true,"b":null,"d":null,"e":null,"f":null}
let res = JSON.parse(str)
console.log(res); // 输出 {name: '钟总', age: 18, like: { n: '金铲铲' }, a: true, b: null, d: null, e: null, f: null}
在第18行代码执行时,我们其实会发现对象转换为JSON字符串——JSON.stringify()时,如果第14行代码存在,即对象中包含大整型数据,19行代码输出时会报错显示不能处理大整型类数据的;
我们把大整型数据删掉后,再进行了第20和21行,再把刚刚对象转化成的字符串转换成对象——JSON.parse(),让原对象obj和19和21行的输出进行对比,我们发现很多属性没有了,还有很多属性变成了null:其中包括undefined值消失、Symbol(1)唯一值消失、函数对象消失以及Infinity值变成null、NaN值变成null。
我们试着对比经过转换成字符串后,又转换成对象的新对象res会受原对象的影响吗?
let obj = {
name: '钟总',
age: 18,
like: {
n: '金铲铲'
},
a: true,
b: null,
c: undefined,
d: Infinity, // Infinity 属于Number,无穷大
e: -Infinity,
f: NaN, // NaN 属于Number
g: Symbol(1),
i: function() {}
}
let res = JSON.parse(JSON.stringify(obj))
obj.name = '美美'
obj.like.n = "打王者"
console.log(obj);
console.log(res);
输出结果如下:
我们直接对比输出结果就发现,在原对象改变内部属性的值时,新对象是不受影响的。在忽略一些不能被拷贝的值的情况下,JSON.parse(JSON.stringify(obj))实现了一个深拷贝的效果。
但是我们知道这个方法,存在几个缺点:
- 不能识别 bigInt(数据类型)
- 不能拷贝 undefined Symbol function NaN Infinity(值)
- 无法处理循环引用
第三个缺点中的概念是有点懵,没事的,这里我们先做了解一下,如下代码添加在上述对象声明的下方:
// 循环引用
obj.c = obj.like
obj.like.n = obj.c
运行结果如下:报错内容就是无法处理循环引用。
所以,如果我们能确保被拷贝的对象是不存在bigInt、undefined、Symbol、function、NaN、Infinity等数据类型或者值,那我们就可以使用这个方法实现一个对象的深拷贝。
2. cturedClone()——官方打造的深拷贝方法
structuredClone 是一个全局函数,用于创建对象的深拷贝,用法很简单,你只需要往函数中传入需要拷贝的对象就可。举个例子:
const user = {
name:'美美',
like:{
n:'追剧',
m:'哆啦a梦'
}
}
const newUser = structuredClone(user);
user.like.m = '看书' //原对象修改,新对象不受影响
console.log(newUser); // 输出{ name: '美美', like: { n: '追剧', m: '哆啦a梦' } }
这个方法可以直接实现一个深拷贝,很简单但是可能有些浏览器的版本会跟不上,所以还是很少用的。
好了,本期的内容就到这里啦~相信你们肯定懂了,大家都是最棒的!
喜欢的话,记得给我点点赞喔~我们下期再见