Javascript的深浅拷贝

基本数据类型与引用数据类型

在讨论深浅拷贝之前,我们需要先说一下基本数据类型引用数据类型

  • 基本数据类型:直接存储在栈中的数据
  • 引用数据类型:存储的是该对象在栈中的引用,真实数据存放在堆内存中。

因为基本数据类型每次复制都会在栈中新开辟一块内存存放值,所以基本数据类型并不会涉及到深浅拷贝,接下来关于深浅拷贝到讨论仅针对于像ObjectArray这样的引用数据类型。

浅拷贝与深拷贝

首先说一下什么是深浅拷贝:

  • 浅拷贝只复制指向某个对象的指针,而不复制对象本身,新旧对象还是共享同一块内存
  • 深拷贝会另外创造一个一模一样的对象,新对象跟原对象不共享内存,对新对象进行操作不会影响到原对象。

简单说就是有一个对象A,还有一个对象B是通过复制A来实现的,如果修改了对象A,对象B也跟着一起发生了变化,这就是浅拷贝;当修改了对象A,但是对象B不受影响,这就是深拷贝。

浅拷贝与赋值

赋值:

  • 把一个对象赋值给一个新的变量时,赋的是对象在栈中的地址,而不是堆中的数据,所以两个对象是指向同一个存储空间的。因此无论是哪个对象发生了改变,都会改变存储空间中的数据,另一个对象也会随之发生改变,即两个对象是联动的。
  • 赋值的对象发生任何改动都会对原数据产生影响。

浅拷贝:

  • 浅拷贝是按位拷贝对象,他会创建一个有着原始对象属性值精确拷贝的新对象。如果原始对象是基本数据类型,就会拷贝栈中的值,如果原始对象是引用数据类型,就会拷贝栈中存储的内存地址。
  • 对浅拷贝的对象进行改动,如果原始对象是基本数据类型则不受影响,如果原始对象是引用数据类型则会随着进行变化。

下面说一些常用的深浅拷贝的方法(有些方法来自前辈的文章,如有侵权请联系删除)。

浅拷贝的实现方法

Object.assign()方法

关于Object.assign()方法,引用MDN的解释:

Object.assign()方法用于将所有可枚举属性的值从一个或多个源对象分配到目标对象。该方法将返回目标对象。

语法:Object.assign(target, ...sources)

该方法拷贝的是可枚举属性值,所以如果源值是一个对象的引用,它仅仅会复制其引用值

1
2
3
let obj = {a: 1, b: 2}
let copyObj = Object.assign({}, obj1)
console.log(copyObj) // {a: 1, b: 2}

该方法也可用于实现对象的合并,不过要注意属性会被后续参数中具有相同属性的其他对象覆盖。

关于该方法的更多使用及细节,这里不展开说,有兴趣可以参阅MDN

Array.prototype.concat()方法

concat()方法用于合并两个或多个数组,此方法不会更改现有的数组,而是返回一个新数组。

不难看出,这个方法仅使用于数组

1
2
3
4
5
6
7
let arr = [1, 3, [5]]
let copy = arr.concat()
console.log(copy) // [ 1, 3, [ 5 ] ]
arr[0] = 10
console.log(copy) // [ 1, 3, [ 5 ] ] 基本数据类型更改不受影响
arr[2][0] = 10
console.log(copy) // [ 1, 3, [ 10 ] ] 引用数据类型更改受影响

看到这里大家应该能更加清楚浅拷贝与赋值的区别了吧,咱们在看一下赋值的操作会怎样:

1
2
3
4
5
6
7
let arr = [1,2,[5]]
let copy = arr
console.log(copy) // [ 1, 2, [ 5 ] ]
arr[0] = 10
console.log(copy) // [ 10, 2, [ 5 ] ]
arr[2][0] = 1
console.log(copy) // [ 10, 2, [ 1 ] ]

从这里应该不难看出了吧,回想一下赋值与浅拷贝的定义,我们也能清楚的知道,赋值的引用数据类型,进行任何改动都会互相影响,其存储的是整个源对象的内存地址;而浅拷贝的对象,是对源对象的每个属性值进行精确拷贝,所以对源对象中的基本数据类型进行更改,不会影响到拷贝对象,只有更改引用数据类型才会因为存储了相同的内存地址而互相影响,看一下上面的例子应该比较容易理解了,这也是我当初犯糊涂的一个地方。

Array.prototype.slice()方法

slice()方法返回一个新的数组对象,这一对象是一个由 beginend 决定的原数组的浅拷贝(包括 begin,不包括end)。原始数组不会被改变。

beginend均为可选值,省略begin则从头开始,省略end则一直提取到数组末尾

MDN到定义中已经清楚的写出了slice()方法返回的是原数组的浅拷贝,这里不过多赘述。

1
2
3
let arr = [1, 2, 3]
let copy = arr.slice()
console.log(copy) // [1, 2, 3]

再次强调concat()slice()方法

  1. slice()concat()方法并不会修改原数组,只是返回原数组中的浅拷贝。
  2. 原数组中的元素按照下述规则进行拷贝:
    • 如果元素是基本数据类型,会将类型值直接拷贝到新数组,两个数组对于这些元素的改动不会互相影响。
    • 如果元素是引用数据类型,会将该数据的内存地址拷贝到新数组,两个数组对于这些元素的操作会产生互相影响。

深拷贝的实现方法

JSON.parse(JSON.stringify())

先将源对象转换为json字符串在解析为js对象

1
2
let arr = [1, 2, [5]]
let copy = JSON.parse(JSON.stringfy(arr))

注意:该方法不能用来处理函数

手写递归方法

原理是递归遍历源对象的所有引用类型,知道其是基本数据类型再去进行复制。

在进行实现之前先介绍一下toString方法,我们可以通过toString.call()来判断数据类型:

仅限于Object.prototype.toString方法,其他类型的toString方法或多或少进行了重写,所以只有Object原型上的toString方法有这个效果,下边的toString指的是Object.prototype.toString。所以需要使用call方法来获取指定数据的原型。

1
2
3
4
5
6
7
8
9
10
toString.call(3) // '[object Number]'
toString.call(NaN) // '[object Number]'
toString.call('sss') // '[object String]'
toString.call(true) // '[object Boolean]'
toString.call() // '[object Undefined]'
toString.call(null) // '[object Null]'
toString.call(function(){}) // '[object Function]'
toString.call([2]) // '[object Array]'
toString.call({a:1}) // '[object Object]'
toString.call(Symbol(1)) // '[object Symbol]'

可以发现,不管是什么类型得到的都是以[object开头,所以我们只需要下标8到-1的部分就可以得到数据的完整类型

下面是深拷贝的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 深拷贝的函数实现
function deepClone(data){
// 最终克隆的数据
let result;
// 要进行拷贝的数据的类型
let dataType = checkDataType(data)
if(dataType === 'Object'){
result = {}
}else if(dataType === 'Array'){
result = []
}else {
// 基本数据类型直接return
return data
}
// 引用类型则遍历数据
for(let i in data){
// 获取每一项的值
let val = data[i]
// 判断是否为引用类型
if(checkDataType(val) === 'Object' || checkDataType(val) === 'Array'){
// 是引用类型则对该值进行深拷贝,递归
result[i] = deepClone(val)
}else{
// 不是引用类型则直接加入到结果中
result[i] = val
}
}
return result
}
// 用于检测数据类型的功能函数
function checkDataType(currentData){
return Object.prototype.toString.call(currentData).slice(8, -1)
}
作者

胡兆磊

发布于

2021-10-21

更新于

2022-10-23

许可协议