JavaScript 的赋值、浅拷贝和深拷贝

  JavaScript 里的数据类型分为基本数据类型和引用数据类型,基本数据类型有 undefined、boolean、number、string、null、Symbol(ES6 中新增),而引用数据类型就是 Object。两种类型在赋值上面是有一些区别的。

赋值

基本数据类型

  基本数据类型的赋值操作是直接拷贝一份原数据的副本,赋值后改变原数据的值并不会影响到副本数据的值,反之也不会相互影响。

1
2
3
4
var a = 1
var b = a
a = 2
console.log(b) // 改变原数据a的值,并不会影响副本数据b的值,b输出1

引用数据类型

  而引用数据就不同了,赋值操作后改变原数据和副本数据任何一个数据的值都会影响另一个数据的值。这是因为在将一个引用数据的值赋值给副本数据后,副本数据保存的是和原引用数据相同(以下简称原数据)的指针,该指针和原数据指向同样的内存地址。其中任何一方修改这个指针的值,内存地址对应存储空间上的值发生改变后,另一方的指针指向的位置不变,所以对应指向的值也就发生了相同的改变。

1
2
3
4
5
6
var a = {
num: 10
}
var b = a
b.num = 20
console.log(a.num) // 改变副本数据b.num的值,会影响到原数据a.num的值,反之也是,a.num输出20

  因为引用数据的特性,这让我们在想进行拷贝引用数据操作时发生了问题。如果想拷贝引用数据,修改副本数据时不影响原数据的话,就要用到拷贝方法。拷贝方法又分为浅拷贝和深拷贝。

浅拷贝

遍历实现浅拷贝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 我们先来创建一个a对象
var a = {
arr: [1, 2, 3]
}

// 再封装一个浅拷贝函数,target是要复制的副本对象,source是原对象
function shallowCopy(target, source) {
for (let key in source) {
target[key] = source[key]
}
}

var b = {} // 创建一个空的b对象
shallowCopy(b, a) // 用封装好的浅拷贝函数把原对象a的值拷贝给b对象
a.arr = ['一', '二'] // 修改原对象a.arr的值
console.log(b) // 输出[1, 2, 3]。这时修改原数据a的值并不影响副本数据b的值

  但是浅拷贝方法也有一个问题,继续看:

1
2
3
4
5
6
7
8
9
// 重新创建一个c对象
var c = {
arr: [1, [2, 3]]
}

var d = {} // 重新创建一个空的d对象
shallowCopy(d, c) // 用刚刚封装好的浅拷贝函数把原对象c的值拷贝给d对象
c.arr[1] = ['二', '三'] // 修改原对象c.arr[1]的值,这个值是一个数组,也是一个引用数据类型的值
console.log(d) // 输出[1, ["二", "三"]]

  这时修改原数据 c 的值,副本数据 d 也一起被修改了。这是什么原因呢?原来是因为浅拷贝操作只能复制一层对象的属性,并不包括对象里的引用数据类型的值,所以当修改原数据 c 里的数组的值后,浅拷贝的副本数据 d 的数组值也就一起被改变了。如果要完全独立拷贝副本数据,就需要用到深拷贝的方法了。

ES6 浅拷贝数组方法

  ES6 新增了扩展运算符 ...,可以实现数组的浅拷贝。

1
2
3
4
5
// 创建原始数组
var a = [1, 2, [3, 4], 5]
var b = [...a]
a[1] = 100
console.log(b) // 输出[1, 2, [3, 4], 5]。副本数据b并没有随着a一起改变。(仅限于浅拷贝)

深拷贝

使用 JSON 序列化实现深拷贝

  深拷贝的一种方法是利用 JSON.stringify() 方法,先将原对象转化为 JSON 格式,再用 JSON.parse() 方法将转化后的 JSON 数据转回 JavaScript 对象的方式,进行深拷贝。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 还是重新创建一个a对象
var a = {
arr: [1, [2, 3]]
}

// 这时来封装一个深拷贝函数
function deepCopy(source) {
return JSON.parse(JSON.stringify(source))
}

var b = deepCopy(a) // 将深拷贝后的值赋给b对象(副本对象)
a.arr[1] = ['二', '三'] // 修改原数据内层引用数据类型的值
console.log(b) // 输出[1, [2, 3]]。并不会影响拷贝后副本对象b

  这样,我们就可以用深拷贝的方法拷贝引用数据类型的值,随意修改原数据或副本数据的其中一个,而不用担心另一个也会被随之修改了。

递归实现对象的深拷贝

  还有一种递归实现深拷贝的方法。解决思路是先用 for...in... 的方法遍历原对象,检测每次遍历的值是否为对象,是的话再次调用自身递归循环遍历,不是的话直接添加。

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
// 先创建原始a对象
var a = {
person: {
name: 'test_a',
age: 10
}
}

// 封装一个验证是否为对象的函数
function isObject(obj) {
if (typeof obj === 'object') {
return true
} else {
return false
}
}

// 封装递归对象深拷贝函数
function deepCopy(obj) {
let copyObj = {}
for (let key in obj) {
copyObj[key] = isObject(obj[key]) ? deepCopy(obj[key]) : obj[key] // 当遍历的数据为对象时,调用自身递归继续遍历子对象
}
return copyObj
}

var b = deepCopy(a)
a.person.age = 20 // 修改原对象a.person.age的值
console.log(b) //输出{ person: { name: "test_a"; age: 10 } }。拷贝后的子对象没有随之改变

  这种方法只针对内部全是对象的引用数据类型。如果还想加上数组,需要再加上一层验证。

  需要注意的是两种方法都不完美,都不支持值为 Function 或 正则 等格式的引用数据类型,需要寻找其它的解决方案。要想实现完美的深拷贝还需要考虑怎么处理原型等问题,一般在实际应用当中,基本上是没有全部需要深拷贝的情况的。上面说的两种方法在一般业务中基本上是足够使用的。