js 浅拷贝和深拷贝

参考《JavaScript高级程序设计》以及网上内容总结
https://github.com/wengjq/Blog/issues/3
https://stackoverflow.com/questions/728360/how-do-i-correctly-clone-a-javascript-object
https://stackoverflow.com/questions/4459928/how-to-deep-clone-in-javascript
https://github.com/mqyqingfeng/Blog/issues/32
http://jerryzou.com/posts/dive-into-deep-clone-in-javascript/

更新于2018-01-17号
随着最近比较闲,有时间静下心来把深浅拷贝好好补充学习下,在网上看了不少前辈们的讲解才发现以前自己写的多low!

首先,浅拷贝和深拷贝只是针对复杂数据类型的复制。

这里,不得不说js的数据类型了。

ECMAScript中有5种简单数据类型(也叫基本数据类型)和一种复杂数据类型(引用类型)。

  • 5种基本数据类型
    • Null
    • Undefined
    • Boolean
    • String
    • Number
  • 复杂数据类型:Object

为什么说浅拷贝和深拷贝只针对复杂数据类型(Object)呢(Array其实也是Object的一种)?
这时候需要了解js的堆栈相关知识了。
高程书上p81页有讲到:

  • 基本类型值在内存中占据固定大小的空间,因此被保存在栈内存
  • 引用类型的值是对象,保存在堆内存
  • 从一个变量向另一个变量复制基本类型的值,会创建这个值的一个副本
  • 从一个变量向另一个变量复制引用类型的值,复制的其实是指针,因此两个变量最终都指向同一个对象。
  • 包含引用类型值的变量实际上包含的并不是对象本身,而是一个指向该对象的指针

这句话:从一个变量向另一个变量复制引用类型的值,复制的其实是指针,因此两个变量最终都指向同一个对象。
也就是说,当我们以常规方法去复制引用类型的值(其实就是复杂数据类型)时,复制的是指针,它们共享了同一个内存空间,这种复制就是浅拷贝

浅拷贝是拷贝引用,拷贝后的引用都是指向同一个对象的实例,彼此之间的操作会互相影响

浅拷贝

源对象拷贝实例,其属性值若是对象则拷贝引用。

1.通用的浅拷贝—-直接赋值型
例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 普通对象
var obj1 = {
value: '1'
}
var obj2 = obj1
obj1 === obj2 // true
obj2.value = '110'
obj1.value // '110'
// 数组
var arr1 = [1,2,3]
var arr2 = arr1
arr2[0] = 9
arr1 // [9,2,3]
arr1 === arr2 // true

以上就是最简单的浅拷贝,不论数组还是对象,直接赋值即可。
因为不论obj1还是obj2其实都是指向{value: '1'}对象的指针,它们引用的是同一个对象,这个对象在堆内存空间里只有唯一的位置,它们两指针都指向同一个堆内存空间。所以改变任何一个指针的属性值其实是在改变它所指向的那个对象的属性值。
同样的,arr1arr2也都是指向[1,2,3]数组的指针。

2.普通对象的浅拷贝—-Object.assign()

具体看MDN或者我的博客:https://lizhongzhen11.github.io/2017/10/31/Object.assign/

例如:

1
2
3
4
5
6
7
var obj1 = {
value: '1'
}
var obj2 = Object.assign({},obj1)
obj2.value = '110'
obj1.value // '1'
obj1 === obj2 // false

新手看到这里或者自己试验可能会混淆,可能会觉得你看,你改变了obj2value属性值,obj1value属性值没有改变啊,两个也不全等,这难道不是深拷贝吗?怎么会是浅拷贝呢?

不得不说,这个例子没有选好,但是很多新手刚开始并不懂的确会搞错。
从这个例子来看,obj1只有一个value属性且它的值为1,该值是字符串不是对象。而使用Object.assign({},obj1)时我们应该注意到,它的第一个参数也就是目标对象是{},这意味着在堆内存里的确重新开辟了新的内存空间了。由于被拷贝的obj1指向的对象里面的属性值是基本数据类型,所以这里很多人会把这个拷贝看做深拷贝。
但这是不正确的或以偏概全的。

改写一下:

1
2
3
4
5
6
7
8
9
10
var obj1 = {
value: {
val: '1'
}
}
var obj2 = Object.assign({}, obj1)
obj2.value.val = '110'
obj1.value.val // '110'
obj2 === obj1 // false
obj2.value === obj1.value // true

是不是很神奇?是不是很amazing?
why呢?

仔细观察obj1所指向的对象的结构,可以发现它有一个value属性,但是该属性的值却是一个{val: '1'}对象(也可以认为这是对象里嵌套着另一个对象吧)。这个对象内部还有一个val属性,值为'1'(string类型)。
value属性的值是一个对象,是不是可以认为这个value其实只是一个指向{val: '1'}的指针呢?或者说obj1指向的对象通过value属性引用{val: '1'}
再分析分析obj2,它是由Object.assign({}, obj1)得到。第一个参数{}其实已经在堆内存里开辟了新的内存空间了,所以直接obj2 === obj1打印的是false,但是obj1.valueobj2.value其实都是指向{val: '1'}的指针,所以obj2.value === obj1.value返回true

从这里可以看出,Object.assign()方法拷贝的并不完整,当被拷贝的对象,它的内部属性也是一个指针时(即内部属性值是对象),他只能是浅拷贝。


3.数组的浅拷贝—-slice和concat
https://github.com/wengjq/Blog/issues/3 这里说的其实比较详细了,我这边就做个学习笔记。
这里我做个补充:

1
2
3
4
5
6
7
8
9
10
11
var arr1 = [1, [1,2], {value: '1'}]
var arr2 = [].slice.call(arr1)
arr2 // [1, [1,2], {value: '1'}]
arr1 === arr2 // false
arr1[1][0] = 9
arr2 // [1, [9,2], {value: '1'}]
arr1[1] === arr2[1] // true
arr1[2] === arr2[2] // true
arr1[2].value = '110'
arr2[2].value // '110'
arr1[2].value === arr2[2].value // true

其实这里[].slice.call(arr1)arr1调用了数组的slice方法,一种变形罢了。原理跟链接里面的差不多,只是提醒。

4.es6语法数组的扩展
新知识:

1
2
3
4
5
6
7
var arr1 = [1, [2]]
var arr2 = [...arr1]
arr1 === arr2 // false
arr2[1].push(9)
arr2 // [1, [2, 9]]
arr1 // [1, [2, 9]]
arr1[1] === arr2[1] // true

可以看到,数组的扩展也是浅拷贝哦,记住了!

浅拷贝目前只能找到这么多,以后如果还有继续补充。

注意!初学者可能会搞混以下代码

1
2
3
4
5
6
7
var a = {id: 1};
var b = a;
a === b; // true
b.id = 2;
a; // {id: 2}
a = {};
b; // {id: 2}

其实仔细看看代码,基础牢固是不会搞混的,但有些初学者可能一时脑回路堵塞,想不通。
为什么a={},后,b却不等于{}呢?
很简单,a={},其实是把变量a强行指向{}a一开始是指向{id:1}这个对象的,但是a={}a指向了{}对象,取消了对{id:2}的指向,内存空间改变了,此时ab两个变量就不是共享同一个内存空间了。

深拷贝

那么深拷贝是什么呢?

堆中重新分配内存,并且把源对象所有属性都进行新建拷贝,以保证深拷贝的对象的引用与原来的对象的引用没有关联,拷贝后的对象与原来的对象是完全隔离,互不影响
注意完全隔离互不影响!!!即使拷贝的对象内部有嵌套的对象或数组,不论嵌套多少层,改变被拷贝的对象(源对象)或者拷贝得到的对象(目标对象)中的任何一属性值都应该完全互不影响。

深拷贝常见方法:JSON.parse(),JSON.stringify(),jQury的$.extend(true,{},obj),lodash的.cloneDeep和.clone(value, true)。


1.最简单粗暴的JSON.parse()和JSON.stringify(),但有弊端
看例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var obj1 = {
v1: '1',
v2: {
value: 886
},
v3: [9,9,9]
}
var obj2 = JSON.parse(JSON.stringify(obj1))
obj2.v2.value = 996
obj1.v2.value // 886
obj1 === obj2 // false
obj1.v2.value === obj2.v2.value // false
obj1.v3.push(4)
obj1.v3 // [9,9,9,4]
obj2.v3 // [9,9,9]
obj1.v3 === obj2.v3 // false

如上例所示,真的是简单粗暴啊,是不是开发时直接用就百无一害呢?
不是的,它还是有弊端的。因为这种方法依托于JSON序列化,而序列化本身就有限制。
具体可以查看:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify

MDN上明确告知有五个注意点,其中:undefined、任意的函数以及 symbol 值,在序列化过程中会被忽略(出现在非数组对象的属性值中时)或者被转换成 null(出现在数组中时)

什么意思呢?看例子吧:

1
2
3
4
5
6
7
8
var obj1 = {
v1: 521,
v2: function() {
return 125
}
}
var obj2 = JSON.parse(JSON.stringify(obj1))
obj2 // {v1: 521}

看到没有?内部属性值如果是函数的话,序列化过程中直接忽略掉了,这怎么行呢?
所以该方法在使用时一定要先了解JSON.stringify方法的注意事项,如果开发过程中我们需要深拷贝的对象中没有符合JSON.stringify注意事项的的,那么直接用也OK的,如果有,请不要用。

注意,该方法还有一个很重大的弊端:循环引用。
例:

1
2
3
4
5
var obj1 = {value: 1}
var obj2 = {value: 2}
obj1.children = obj2
obj2.parent = obj1
var a = JSON.stringify(obj1) // Uncaught TypeError: Converting circular structure to JSON

可自行打印尝试下,如果存在循环引用请不要用该方法。

2.jQury的$.extend(true,{},obj)
https://github.com/wengjq/Blog/issues/3 ,感觉我的水平写得不会比他好,还不如直接看他的,然后记录一下学习的困惑吧。

主要需要记住该方法也无法处理源对象内部循环引用。

除此之外还可以看:https://github.com/mqyqingfeng/Blog/issues/33
把extend方法实现一遍,加深理解。

如果想自己实现一个较完美的深拷贝库,看这个:http://jerryzou.com/posts/dive-into-deep-clone-in-javascript/

自己实现,当然还是参照别人的,尴尬

具体看:https://github.com/lizhongzhen11/myStudy/tree/master/jq#extendjs
这上面照着前辈的讲解实践的同时加上了自己的理解,若有不完善的可以在github上提issue!

Newer Post

js 作用域及作用域链

参考《JavaScript高级程序设计》 执行环境及作用域全局执行环境是最外围的执行环境。在Web浏览器中,全局执行环境被认为是window对象,因此所有全局变量和函数都是作为window对象的属性和方法创建的。每个函数都有自己的执行环境。ECMAScript的执行流机制,当执行流进入一个函数时,函 …

继续阅读
Older Post

闭包

参考了《JavaScript高级程序设计》和阮一峰先生的博客http://www.ruanyifeng.com/blog/2009/08/learning_javascript_closures.html 概念 闭包是指有权访问另一个函数作用域中的变量的函数。 理解:1.闭包首先肯定是一个函数。2 …

继续阅读