JS拷贝:关于浅拷贝与深拷贝的区别

450 阅读17分钟

在实际开发过程中,我们通常会有拷贝这个需求。我们需要得到与原对象一样的一个对象去进行操作。今天,我们就来详细解析一下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。

image.png

所以当我们传一个null进去时,就能得到一个没有隐式原型的对象,所以我们说不是所有对象都有隐式原型。

当然这是个题外话哈。我们接下来就可以将这个方法用作拷贝。

let obj = {
    a: 1
}
let newObj = Object.create(obj);
console.log(newObj.a);

我们定义一个对象obj,里面key为a值为1。然后将它作为参数放到create方法中去,它会返回一个新对象newObj。我们再去用newObj去调用a,应该能输出1。

image.png

这样我们就完成了一个对象的拷贝。我们再去验证一下它是浅拷贝还是深拷贝。

let obj = {
    a: 1
}
let newObj = Object.create(obj);
obj.a = 2
console.log(newObj.a);

我们将原对象中的a的值改为2,看看新对象中是否更改了。

image.png

更改了。这是因为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身上,然后返回一个新对象。

image.png

确实拼接成功了。此时obj改变了吗?我们来输出obj看一下:

屏幕截图 2024-11-27 231550.png

我们发现它会影响obj。因为它是将obj2拼接到obj当中去。

那我们怎么用它实现对一个对象的拷贝呢?我们这样使用:

let obj = {
    a: 1
}

let obj2 = Object.assign({}, obj)
console.log(obj2);

我们将obj2拼接到一个空对象上返回,不就能得到和obj一样的一个对象了吗。我们输出obj2来看看。

image.png

确实和obj一样,实现了拷贝效果。那么它是浅拷贝还是深拷贝呢?我们来验证一下。

let obj = {
    a: 1
}

let obj2 = Object.assign({}, obj)
obj.a = 2
console.log(obj2);

我们在拷贝完后将obj中的a改为了2,再输出obj2看看obj2中的a是否更改了。

image.png

我们发现并没有更改,那是不是说明它是深拷贝了。是吗?我们再来看:

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,我们来看看输出结果:

image.png

我们发现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现在长什么样子。

屏幕截图 2024-11-28 092736.png

成功拼接到了一起。那被拼接的arr会受影响吗?我们输出arr看一下。

屏幕截图 2024-11-28 093029.png

我们发现并不会,它就和对象的assign方法有所区别。

现在我们也把它用到拷贝上去。我们用一个空数组去调用concat方法,然后将arr作为参数传进去,应该就能得到一个和arr一样的数组了。

let arr = [1, 2]

let newArr = [].concat(arr)

console.log(newArr)

image.png

确实拷贝成功了。我们同样也来验证一下它是浅拷贝还是深拷贝。我们在拷贝结束后将arr[1]的值改为2,看看新数组newArr是否受原数组的影响。

let arr = [1, 2,]

let newArr = [].concat(arr)

arr[0] = 2

console.log(newArr)

屏幕截图 2024-11-28 093448.png

我们发现新数组中的值并没有改变,但这能说明它就是深拷贝了吗?当然不能,我们还得验证一下数组里的对象。

let arr = [1, 2, { n: 3 }]

let newArr = [].concat(arr)

arr[2].n = 4

console.log(newArr)

我们在数组里添加了一个对象,然后在拷贝结束后更改原数组对象中的值,看看新数组是否更改了。

屏幕截图 2024-11-28 093933.png

我们发现还是更改了,说明新数组还是受原数组的影响的。这其实和对象的assign方法差不多,都是碰到原始类型时直接将值复制过去,碰到引用类型时直接将引用地址复制过去。

所以 [].concat(arr) 也能实现拷贝效果,而且是浅拷贝。

2.4 数组解构

我们还可以用数组解构实现一个拷贝效果。

let arr = [1, 2, { n: 3 }]

let newArr = [...arr]

arr[2].n = 4

console.log(newArr)

它当然也是浅拷贝效果。

屏幕截图 2024-11-28 093933.png

所以数组解构也能实现拷贝效果,而且也是浅拷贝。

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不要,而且切完之后原数组不受影响。

image.png

splice方法也可以接收两个参数,第一个参数表示从哪个下标开始切,第二个参数表示要切几个。但是切完之后原数组是会受影响的。

let arr = ['a', 'b', 'c', 'd', 'e']

let newArr = arr.splice(1, 4)

console.log(newArr);
console.log(arr);

image.png

所以请不要搞混了这两个方法。

那既然我们要实现拷贝效果,自然不能对原数组产生影响,所以在这里我们用slice,它可以不接受参数,不接受参数就可以将原数组拷贝下来。

let arr = [1, 2, { n: 3 }]

let newArr = arr.slice()

console.log(newArr)

image.png

成功将原数组拷贝了下来,它自然也是浅拷贝,各位可以自行去验证。

2.6 arr.toReversed().reverse()

在数组中还存在这样一个方法:reverse。它可以将数组中的值反转。

let arr = ['a', 'b', 'c', 'd', 'e']

console.log(arr.reverse());

image.png

那我们这样想一想,我们调用这个方法两次,是不是得到的那个数组就和原数组一样,是不是就实现了拷贝效果。但是有个小问题,这个方法是在原数组本身上进行改动,会影响原数组,所以它没有空间复杂度,和排序的那个方法sort一样。

let arr = ['a', 'b', 'c', 'd', 'e']

console.log(arr.reverse());
console.log(arr);

image.png

我们输出arr看一下确实arr改变了。那改变了原数组效果就不能叫拷贝了,所以后来es6新增了一个方法:toReverse。它不会在原数组身上进行改动,而是生成一个新数组。所以我们这样使用是不是就能实现拷贝效果了:arr.toReversed().reverse()。先调用toReversed反转一下得到新数组,再在新数组身上调用reverse进行反转,就不会影响到原数组了。

image.png

原数组没有被更改。所以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.keyres[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'

image.png

这样写是不是将c作为字符串添加到对象上去了。我们只能这样写:obj[c] = 'world'

let obj = {
    a: 1
}

let c = 'hello'
obj[c] = 'world'

console.log(obj);

这样就将c作为变量去使用了。

image.png

所以在这里,我们遍历原对象的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));

这样我们就完成了浅拷贝的代码。我们输出看看:

image.png

拷贝得到的对象确实和原对象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 去遍历原对象。

image.png

我们发现b也被遍历到了。但在拷贝时我们并不需要将原对象上的隐式原型也拷贝下来。有没有说什么方法能规避它呢?

有的,在对象上有这样一个方法:hasOwnProperty。它能判断一个对象是否显示拥有一个属性,返回一个布尔值。

Object.prototype.b = 2

let obj = {
    a: 1
}

console.log(obj.hasOwnProperty('a'));
console.log(obj.hasOwnProperty('b'));

直接用obj调用这个方法,去判断a和b是否在对象上显示拥有。

image.png

在判断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看看:

image.png

确实变成了字符串。而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看看:

image.png

我们发现丢失了很多数据类型,并且很多值都变成了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);

屏幕截图 2024-11-28 114631.png

我们发现并没有改变。新对象并没有受到原对象的影响。说明它实现的效果是深拷贝。

但是他有缺点,它不能不能识别 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。它是会报错的。

image.png

它显示无法处理循环语句。

所以对于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);

image.png

确实实现了拷贝效果。我们再来验证一下它是不是深拷贝。将user中的like中的n更改,看看newUser是否会受user的影响。

const user = {
    name: '朱总',
    like: {
        n: '泡脚',
        m: '吃鸡'
    }
}

const newUser = structuredClone(user)

user.like.n = '喝茶'

console.log(newUser);

屏幕截图 2024-11-28 120739.png

我们发现并不会,它确实货真价实的实现了深拷贝。但我们在实际工作中很少去使用它,因为它的兼容性不好,它是最近几年才打造出来的方法,浏览器读不懂它。

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()