在实际开发过程中,我们通常会有拷贝这个需求。我们需要得到与原对象一样的一个对象去进行操作。今天,我们就来详细解析一下JS中关于拷贝的概念。
1. 拷贝的概念
拷贝分为两种:浅拷贝与深拷贝。我们先通过两个例子简单认识一下浅拷贝与深拷贝。
let a = 1
let b = a
a = 2
console.log(b);
我们定义一个a的值为1,将a赋值给b,再将a的值改为2,然后输出b。我们在JS的内存机制中已经见过这段代码了。b的值应该为1。因为原始类型的赋值是值的复制。 所以b的值不会随着a的值改变而改变。
我们再来看这段代码:
let obj = {
a: 1
}
let obj2 = obj
obj.a = 2
console.log(obj2);
我们定义一个对象obj,里面放一个key为a值为1。然后将obj赋值给obj2。再将obj中的a的值改为2,再输出obj2。我们也知道这段代码的输出结果。obj2中的a的值也会被更改为2。因为引用类型的赋值是引用地址的复制。 此时,obj和obj2中存放的是同一个引用地址,所以调用的是同一个对象。
这就是浅拷贝与深拷贝。原始类型的赋值可以说是深拷贝,引用类型的赋值可以说是浅拷贝。
对于浅拷贝:新对象会受原对象的影响。
对于深拷贝:新对象不受原对象的影响。
但其实用原始类型的赋值来聊拷贝的概念是很不贴切的,因为它百分之百是深拷贝。我们一般只在引用类型身上探讨浅拷贝与深拷贝的概念。
用对象的赋值来形容深拷贝其实也差点意思。因为我们所说的拷贝,指的应该是你一份我一份,得到了一个新的对象。但对象的赋值只是引用地址的复制,并没有得到一个新的对象。
所以对于拷贝,我们有以下两个概念:
- 只针对引用类型
- 基于原对象,拷贝得到一个新对象
2. 浅拷贝
我们先来着重讲解一下浅拷贝。看一下在JS中,有哪些方法可以实现浅拷贝的效果。
2.1 Object.create(x)
我们先来看对象上的这样一个方法:create。我们在原型那篇文章中提到过这个方法。它可以接收一个对象x作为参数然后凭空产生一个新的空对象,并且让新对象的隐式原型等于x。
所以当我们传一个null进去时,就能得到一个没有隐式原型的对象,所以我们说不是所有对象都有隐式原型。
当然这是个题外话哈。我们接下来就可以将这个方法用作拷贝。
let obj = {
a: 1
}
let newObj = Object.create(obj);
console.log(newObj.a);
我们定义一个对象obj,里面key为a值为1。然后将它作为参数放到create方法中去,它会返回一个新对象newObj。我们再去用newObj去调用a,应该能输出1。
这样我们就完成了一个对象的拷贝。我们再去验证一下它是浅拷贝还是深拷贝。
let obj = {
a: 1
}
let newObj = Object.create(obj);
obj.a = 2
console.log(newObj.a);
我们将原对象中的a的值改为2,看看新对象中是否更改了。
更改了。这是因为newObj的隐式原型等于对象obj,说明隐式原型存的是obj的引用地址。当我们去更改obj时,隐式原型也会更改,因为它们是同一个引用地址。
因为创建的这个新对象受原对象的影响,所以我们说这是浅拷贝。但这里有一个小问题,你说用这个方法得到的新对象算拷贝吗。得到的这个新对象它是一个空对象啊,而原对象不是空对象。但它确实又符合浅拷贝的特征。所以这个问题就仁者见仁智者见智了。
2.2 Object.assign({},obj)
我们再来看能实现浅拷贝的第二种方法。
在对象上还有这样一个方法:assign。它能实现对象的拼接。它可以接收多个参数,它先将最后一个对象拼接到倒数第二个对象上,再将倒数第二个对象拼接到倒数第三个对象上。最后返回一个新对象。我们来看看它的效果。
let obj = {
a: 1
}
let obj2 = {
b: 2
}
let obj3 = Object.assign(obj, obj2)
console.log(obj3);
我们定义两个对象obj和obj2,里面都放了一个属性。然后将两个对象作为参数去调用create方法。它会先将obj2拼接到obj身上,然后返回一个新对象。
确实拼接成功了。此时obj改变了吗?我们来输出obj看一下:
我们发现它会影响obj。因为它是将obj2拼接到obj当中去。
那我们怎么用它实现对一个对象的拷贝呢?我们这样使用:
let obj = {
a: 1
}
let obj2 = Object.assign({}, obj)
console.log(obj2);
我们将obj2拼接到一个空对象上返回,不就能得到和obj一样的一个对象了吗。我们输出obj2来看看。
确实和obj一样,实现了拷贝效果。那么它是浅拷贝还是深拷贝呢?我们来验证一下。
let obj = {
a: 1
}
let obj2 = Object.assign({}, obj)
obj.a = 2
console.log(obj2);
我们在拷贝完后将obj中的a改为了2,再输出obj2看看obj2中的a是否更改了。
我们发现并没有更改,那是不是说明它是深拷贝了。是吗?我们再来看:
let obj = {
a: 1,
b: {
n: 2
}
}
let obj2 = Object.assign({}, obj)
obj.a = 2
obj.b.n = 3
console.log(obj2);
我们再在obj中新添加一个key为b值为一个对象,里面放一个n为2。在拷贝之后将obj中对象b的n值改为3,我们来看看输出结果:
我们发现n值被改变了。所以这是浅拷贝,只要有值受原数组的影响,我们就说这是浅拷贝。
那为什么a的值没有改变而n的值改变了呢?
这是因为我们再将obj拼接到空对象中去的时候,读到a时,发现值为原始类型,就在新对象中将这个值复制过去;而在读到b时,发现它是一个对象,是一个引用类型,就会把引用地址复制过去。所以我们去更改obj中的a时,obj2中的a不会受影响,因为原始类型是值的赋值,这已经是两个不同的值了。而去更改obj中对象b中的值时,obj2中的对象b就会受影响,因为此时obj中的对象b和obj2中的对象b是同一个引用地址,你更改了我当然也受影响。
所以Object.assign({},obj) 实现的效果就是浅拷贝。
2.3 [].concat(arr)
对象中有能将对象拼接的方法,那数组中有没有能将数组拼接的方法呢?
当然有,那就是:concat。它是怎么使用的呢?它可以直接让实例对象调用,接收一个数组作为参数,将这个数组拼接到自己身上然后返回一个新数组。
let arr = [1, 2]
let arr2 = [3, 4]
let arr3 = arr.concat(arr2)
console.log(arr3);
我们定义两个数组arr和arr2,然后让arr调用concat,返回得到一个新数组arr3。我们来看看arr3现在长什么样子。
成功拼接到了一起。那被拼接的arr会受影响吗?我们输出arr看一下。
我们发现并不会,它就和对象的assign方法有所区别。
现在我们也把它用到拷贝上去。我们用一个空数组去调用concat方法,然后将arr作为参数传进去,应该就能得到一个和arr一样的数组了。
let arr = [1, 2]
let newArr = [].concat(arr)
console.log(newArr)
确实拷贝成功了。我们同样也来验证一下它是浅拷贝还是深拷贝。我们在拷贝结束后将arr[1]的值改为2,看看新数组newArr是否受原数组的影响。
let arr = [1, 2,]
let newArr = [].concat(arr)
arr[0] = 2
console.log(newArr)
我们发现新数组中的值并没有改变,但这能说明它就是深拷贝了吗?当然不能,我们还得验证一下数组里的对象。
let arr = [1, 2, { n: 3 }]
let newArr = [].concat(arr)
arr[2].n = 4
console.log(newArr)
我们在数组里添加了一个对象,然后在拷贝结束后更改原数组对象中的值,看看新数组是否更改了。
我们发现还是更改了,说明新数组还是受原数组的影响的。这其实和对象的assign方法差不多,都是碰到原始类型时直接将值复制过去,碰到引用类型时直接将引用地址复制过去。
所以 [].concat(arr) 也能实现拷贝效果,而且是浅拷贝。
2.4 数组解构
我们还可以用数组解构实现一个拷贝效果。
let arr = [1, 2, { n: 3 }]
let newArr = [...arr]
arr[2].n = 4
console.log(newArr)
它当然也是浅拷贝效果。
所以数组解构也能实现拷贝效果,而且也是浅拷贝。
2.5 arr.slice()
在数组中还存在一个方法slice,它可以将数组中的值切下来,但它很容易和数组中另一个方法splice搞混。我们先来说一说它们之间的区别。
let arr = ['a', 'b', 'c', 'd', 'e']
let newArr = arr.slice(1, 4)
console.log(newArr);
console.log(arr);
slice方法可以接收两个参数,第一个表示从哪个下标开始切,第二个表示切到哪个位置为止,是左闭右开的,也就是下标1要下标4不要,而且切完之后原数组不受影响。
而splice方法也可以接收两个参数,第一个参数表示从哪个下标开始切,第二个参数表示要切几个。但是切完之后原数组是会受影响的。
let arr = ['a', 'b', 'c', 'd', 'e']
let newArr = arr.splice(1, 4)
console.log(newArr);
console.log(arr);
所以请不要搞混了这两个方法。
那既然我们要实现拷贝效果,自然不能对原数组产生影响,所以在这里我们用slice,它可以不接受参数,不接受参数就可以将原数组拷贝下来。
let arr = [1, 2, { n: 3 }]
let newArr = arr.slice()
console.log(newArr)
成功将原数组拷贝了下来,它自然也是浅拷贝,各位可以自行去验证。
2.6 arr.toReversed().reverse()
在数组中还存在这样一个方法:reverse。它可以将数组中的值反转。
let arr = ['a', 'b', 'c', 'd', 'e']
console.log(arr.reverse());
那我们这样想一想,我们调用这个方法两次,是不是得到的那个数组就和原数组一样,是不是就实现了拷贝效果。但是有个小问题,这个方法是在原数组本身上进行改动,会影响原数组,所以它没有空间复杂度,和排序的那个方法sort一样。
let arr = ['a', 'b', 'c', 'd', 'e']
console.log(arr.reverse());
console.log(arr);
我们输出arr看一下确实arr改变了。那改变了原数组效果就不能叫拷贝了,所以后来es6新增了一个方法:toReverse。它不会在原数组身上进行改动,而是生成一个新数组。所以我们这样使用是不是就能实现拷贝效果了:arr.toReversed().reverse()。先调用toReversed反转一下得到新数组,再在新数组身上调用reverse进行反转,就不会影响到原数组了。
原数组没有被更改。所以arr.toReversed().reverse() 也可以实现拷贝效果,而且也是浅拷贝,当我们在原数组添加一个对象,在拷贝结束后更改对象中的值,新数组是会受影响的。
2.7 手搓一个浅拷贝代码
至此,我们差不多学完了JS中能实现浅拷贝效果的一些方法。现在,我们可以自己来写一个能实现浅拷贝效果的代码。
let person = {
name: '阿炜',
age: 18,
like: {
n: 'running'
}
}
function shallow(obj) {
let res = {}
return res
}
console.log(shallow(person));
我们有一个对象person,我们来写一个方法shallow,当我们将person作为参数去调用shallow时,它能给我们返回一个和原对象一样的对象。
应该怎么写呢?首先要实现拷贝效果,应该得到一个新对象。所以我们先定义一个空对象res,最后再返回这个对象res。
然后怎么写呢?浅拷贝实现的效果,是不是碰到原始类型直接将值复制过去,碰到引用类型直接将引用地址复制过去。那我们直接想个办法将原对象中的每一个key遍历一遍然后添加到新对象上去。有没有一个方法能遍历对象呢?当然有,那就是 for (let key in obj)。它可以遍历对象,key可以随便写,它用来做变量代替对象中的key。
let person = {
name: '阿炜',
age: 18,
like: {
n: 'running'
}
}
function shallow(obj) {
let res = {}
for (let key in obj) {
}
return res
}
console.log(shallow(person));
此时我们遍历了原对象obj,然后将obj中的值添加到新对象中去。我们这样写:res[key] = obj[key]。请问能不能这样写呢:res.key。res[key] 和 res.key有什么区别呢?
我们知道对象中的key是字符串类型,当我们写res.key时,它就会将key作为字符串添加到对象中去,也就是说对象中多了一个key为key的属性。但这里我们不是想往新对象上添加一个key为key,而是将key作为变量来使用,所以这里我们就得用res[key],它可以将key作为变量来使用,将它代表的值作为key添加到对象上去。
比如:
let obj = {
a: 1
}
let c = 'hello'
obj.c = 'world'
console.log(obj);
我们有一个对象obj,我们定义了一个c为hello。然后我们想在对象中添加一个key为hello值为world的属性。能这样写吗:obj.c = 'world'。
这样写是不是将c作为字符串添加到对象上去了。我们只能这样写:obj[c] = 'world'。
let obj = {
a: 1
}
let c = 'hello'
obj[c] = 'world'
console.log(obj);
这样就将c作为变量去使用了。
所以在这里,我们遍历原对象的key,我们let了一个key作为变量去接收原对象中的key。所以我们不能res.key只能用res[key]。
let person = {
name: '阿炜',
age: 18,
like: {
n: 'running'
}
}
function shallow(obj) {
let res = {}
for (let key in obj) {
res[key] = obj[key]
}
}
return res
}
console.log(shallow(person));
这样我们就完成了浅拷贝的代码。我们输出看看:
拷贝得到的对象确实和原对象person一模一样。
其实这里还有一个小细节:对于 for (let key in obj)这条语句。因为这条语句过于强大,它还能将对象obj上的隐式原型也遍历到。比如:
Object.prototype.b = 2
let obj = {
a: 1
}
for (let key in obj) {
console.log(key);
}
我们定义一个对象obj,然后在Object的原型上添加一个b为2,所以obj隐式原型上也会有一个b为2。然后我们用for in 去遍历原对象。
我们发现b也被遍历到了。但在拷贝时我们并不需要将原对象上的隐式原型也拷贝下来。有没有说什么方法能规避它呢?
有的,在对象上有这样一个方法:hasOwnProperty。它能判断一个对象是否显示拥有一个属性,返回一个布尔值。
Object.prototype.b = 2
let obj = {
a: 1
}
console.log(obj.hasOwnProperty('a'));
console.log(obj.hasOwnProperty('b'));
直接用obj调用这个方法,去判断a和b是否在对象上显示拥有。
在判断a时返回true,判断b时返回false。
所以我们可以将它用在往新对象赋值前,判断一下原对象此时的key是否是显示拥有的。
let person = {
name: '阿炜',
age: 18,
like: {
n: 'running'
}
}
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));
如果是显示拥有我们才拷贝到新对象上。这就是浅拷贝的完整代码了。
3. 深拷贝
了解完了浅拷贝,是时候聊聊JS中能实现深拷贝效果的方法了。
3.1 JSON.parse(JSON.stringify(obj))
在JS中有一个方法凑巧能实现深拷贝的效果。我们来看一下。
let obj = {
name: '钟总',
age: 18,
like: {
n: '金铲铲'
},
a: true,
b: null,
c: undefined,
d: Infinity,
e: -Infinity,
f: NaN,
g: Symbol(1),
h: function () { }
}
我们定义了一个对象obj,并且将一些常见的数据类型都添加到这个对象上。
而JS中有一个方法能将对象转成字符串:JSON.stringify(obj)。JSON是一种数据格式。
let obj = {
name: '钟总',
age: 18,
like: {
n: '金铲铲'
},
a: true,
b: null,
c: undefined,
d: Infinity,
e: -Infinity,
f: NaN,
g: Symbol(1),
h: function () { }
}
let str = JSON.stringify(obj)
console.log(str);
我们输出str看看:
确实变成了字符串。而JSON身上还有种方法能将字符串变成对象:JSON.parse(str)。我们再将我们得到的字符串str转成对象。
let obj = {
name: '钟总',
age: 18,
like: {
n: '金铲铲'
},
a: true,
b: null,
c: undefined,
d: Infinity,
e: -Infinity,
f: NaN,
g: Symbol(1),
h: function () { }
}
let str = JSON.stringify(obj)
let res = JSON.parse(str)
console.log(res);
我们输出res看看:
我们发现丢失了很多数据类型,并且很多值都变成了null。
但我们先别管这些,我们将obj中的对象like中的n改为其他值,看看我们得到的新对象res是否改变了。
let obj = {
name: '钟总',
age: 18,
like: {
n: '金铲铲'
},
a: true,
b: null,
c: undefined,
d: Infinity,
e: -Infinity,
f: NaN,
g: Symbol(1),
h: function () { }
}
let str = JSON.stringify(obj)
let res = JSON.parse(str)
obj.like.n = '王者'
console.log(res);
我们发现并没有改变。新对象并没有受到原对象的影响。说明它实现的效果是深拷贝。
但是他有缺点,它不能不能识别 bigint,不能拷贝 undefined Symbol function NaN Infinity,还不能处理循环引用。
比如我们这样写:
let obj = {
name: '钟总',
age: 18,
like: {
n: '金铲铲'
},
a: true,
b: null,
c: undefined,
d: Infinity,
e: -Infinity,
f: NaN,
g: Symbol(1),
h: function () { }
}
obj.c = obj.like
obj.like.n = obj.c
let str = JSON.stringify(obj)
let res = JSON.parse(str)
console.log(res);
我们加了这样两条语句:obj.c = obj.like、obj.like.n = obj.c。它是会报错的。
它显示无法处理循环语句。
所以对于JSON.parse(JSON.stringify(obj)),我们有:
1. 不能识别 bigint
2. 不能拷贝 undefined Symbol function NaN Infinity
3. 无法处理循环引用
但它能实现深拷贝的效果。
3.2 structuredClone()
在JS中,还有一种官方打造的真正能实现深拷贝效果的方法:structuredClone()。它可以返回一个新对象。
我们来看看:
const user = {
name: '朱总',
like: {
n: '泡脚',
m: '吃鸡'
}
}
const newUser = structuredClone(user)
console.log(newUser);
确实实现了拷贝效果。我们再来验证一下它是不是深拷贝。将user中的like中的n更改,看看newUser是否会受user的影响。
const user = {
name: '朱总',
like: {
n: '泡脚',
m: '吃鸡'
}
}
const newUser = structuredClone(user)
user.like.n = '喝茶'
console.log(newUser);
我们发现并不会,它确实货真价实的实现了深拷贝。但我们在实际工作中很少去使用它,因为它的兼容性不好,它是最近几年才打造出来的方法,浏览器读不懂它。
4.总结
至此我们学完了能实现浅拷贝与深拷贝的方法:
浅拷贝:新对象会受原对象的影响
1. Object.create(x)
2. Object.assign({},obj)
3. [].concat(arr)
4. 数组解构
5. arr.slice()
6. arr.toReversed().reverse()
深拷贝:新对象不受原对象的影响
1. JSON.parse(JSON.stringify(obj))
-不能识别 bigint
-不能拷贝 undefined Symbol function NaN Infinity
-无法处理循环引用
2. structuredClone()