本文首发于个人博客:www.wyb.plus
0. 前言
JS中的深浅拷贝是一个十分难以理清的概念 , 很多人其实都对这个概念都没有真正理解。
- 作者:王雨波
- 时间:2020年5月4日
- qq:760478684
- 博客:www.wyb.plus
1. 数据类型
首先要了解
数据类型
,在JS中数据类型分为两大类型:分别是原始数据类型
和引用数据类型
。原始数据类型又叫基本数据类型。
- 原始数据类型包括:
Number
、String
、Boolean
、Null
、undefined
、Symbol
(ES6引入)- 引入数据类型包括:
Object
、Array
、Function
两种数据类型的不同:
- 基本数据类型的特点:直接存储在
栈(stack)
中的数据- 引用数据类型的特点:栈中存储的是该对象的
引用
,真实的数据存放在堆内存
里。栈与堆还有队列这里不深入展开。但是要知道的是,引用数据类型的查找,是先从栈内存中取得该数据的引用(也就是取得该数据的地址,可以理解为指针,但是JS中没有指针的概念),然后根据该地址去堆内存空间中取得该值;而原始数据类型的查找是直接从栈内存中读取值。
2. 深拷贝与浅拷贝的不同
过程的不同:
深拷贝
是在堆内存中开辟一块新的内存空间,将原数据完整地复制一份,放到新的堆内存中。
浅拷贝
将原数据的 “指针” 拷贝一份,新“指针”也指向原来的指针所指向的空间,但是堆内存的空间只有一个。结果的不同:
- 深拷贝在拷贝后是有
两个
堆内存空间,原数据与拷贝后的数据互不影响
,两个数据独立不关联- 浅拷贝在拷贝后只有
一个
堆内存空间,指向这个空间的两个 “ 指针” 都能够操作这个数据
3. 赋值与深浅拷贝的不同
条件 >>>
从
基本类型
,简单对象
和复杂对象
这三个条件出发,比较赋值与拷贝的不同。基本数据类型的赋值 >>>
let a = 10; let b = a; console.log("a=" + a, "b=" + b); b = 20; console.log("a=" + a, "b=" + b);
基本数据类型的赋值是在栈空间完成的,不存在引用,所以a就是a,b就是b,完全独立。
简单对象的赋值 >>>
// 定义一个简单对象a let a = { name: "wyb", age: 18, sex: null, handsome: undefined, student: true } // 把a赋值给b let b = a; // 此时第一次打印a和b console.log(a, b); // 修改b的属性 b.name = "wangyubo"; b.age = 23; b.sex = null; b.handsome = true; b.student = undefined // 第二次打印a和b console.log(a, b);
第一次打印a和b的结果:
发现a和b完全一样,但是也不能说明他们指向同一个对象。
第二次打印a和b的结果:
第二次打印是在b被修改之后,发现a和b一模一样,所以修改b时,a也被修改了,所以可以得出结论:简单对象的赋值,是复制对象的指针,并不生成一个新对象,跟“浅拷贝”是一样的效果。
复杂对象的赋值>>>
// 定义一个复杂对象a let a = { name: "wyb", hobby: ["唱", "跳", "rap", "篮球"], girlfriends: { first: "桥本环奈", second: "斋藤飞鸟" } }; // 把a赋值给b let b = a; //第一次打印a和b console.log(a, b); // 修改b的属性 b.name = "wangyubo"; b.hobby[0] = "多人运动打篮球"; b.girlfriends.first = "呆头鹅"; //第二次打印a和b console.log(a, b);
两次打印的结果:
可以看出:
修改b后,a变的和b一样了,所以可以判断出a和b指向同一个对象。也就是说,复杂对象的赋值,也是复制一份指针,两个变量同时指向同一个对象。相当于“浅拷贝”的效果。
并且可以发现,复杂对象的赋值是完全引用,也就是复杂对象里面的属性的类型为引用类型时,也是复制指针。
小结:当赋值的类型是基本数据类型时,被赋值的对象与赋值对象是两个独立的存在。当赋值的类型为引用数据类型时(无论是简单对象还是复杂对象),被赋值的对象与赋值对象指向同一个对象,两者相互关联,一方的改变会引起另一方的相应改变。
4. 补充
不知道细心的你有没有发现:
谷歌控制台在第一次打印的时候,
name
这个属性,展开前和展开后不一样。这是什么原因呢?一开始我是真没搞明白,问了很多人,他们也都说不清楚原理,有同学建议我用其他浏览器试试,于是我又用了火狐,IE11,edge,最后在vscode的终端,以及Cmder上,通过node环境去运行,结果让我更加懵逼了。主流浏览器,IE系,以及node环境中,给了我三个不同的结果。让我们关注两点:第一点是第一次打印的ab
没展开
的name和展开
的name,第二点是,两次打印的属性值的不同。
首先是主流浏览器,chrome和Firefox >>>
chrome
firefox
这两个第一次打印的时候,没展开的name都是
wyb
,展开的name变成了wangyubo
,并且修改前和修改后的两次打印的结果完全一致
。也就是说在修改前打印a和b,输出的却是修改后的值。
IE系 >>>
IE11
edge:
IE11和edge里面可以看到展开的name和没展开的name
都是wyb
, 而更具体地属性,ie11是undefined看不出来有没有被修改,从edge里面可看到,第一次打印的时候,也就是修改前输出,除了
name是被修改前
的值外,girlfriends
和hobby
的属性都是修改后
的值。在node环境中 >>>
vs code终端:
Cmder:
从这两张图中我们可以看出,在node环境下,第一次输出的结果为
完全的没修改的值
,第二次输出的结果是被修改后的值
。以上就是我发现的同一段代码,在这三种环境下给我返回的三个结果。
经过一番查找和求证以后,得出以下结论 >>>
Webkit的console.log
很多时候不能真实的反应js时序,因为是异步的内存快照
;也就是说它在输出一个引用类型的值时,并不会立即拍摄对象快照,相反,它只存储了一个指向对象的引用,然后再代码返回时间队列时才去拍照。在谷歌控制台中,点击我们输出的对象旁边的蓝色i , 提示我们
value below was evaluated just now
译为:刚才评估了下面的值。这也说明了展开的属性是评估后的值,也就是被修改了的值。
node的console.log
是另一回事,它是严格同步的。根据这三个结论对三个结果进行分析:
- 首先是主流浏览器,
没展开
的里面存储的是同步的快照
,也就是修改前的值,展开后
显示的是评估后的值
,这就是因为此时的console.log是异步的。这种情况下,效果相当于浅拷贝,并且是完全的浅拷贝。- 在edge中,第一次打印展开前和展开后都是一样的,但是,它的复杂对象中的值为原始数据类型时,是同步的,因为输出修改前的name时就是输出的修改前的name,没有被修改,而复杂对象中的值为引用类型时,它又是异步的,因为修改前的
hobby
和girlfriends
里面的属性输出的是修改后的属性。- 在node环境中,那就很简单了,因为他是严格同步的,修改前输出的就是修改前的值,修改后输出的就是修改后的值。
最后,无论是在这三种环境的哪一种中,修改后的a和b都是保持一致的。所以无论哪种环境,对我们上一节中的复杂对象的赋值是完全引用的结论都没有影响的。
5.深浅拷贝
回到正题深浅拷贝上来,当我们在谈论深前拷贝时,一定要清楚深浅拷贝的使用前----复杂对象。只有复杂对象才会有深拷贝与浅拷贝的说法。
一些能够实现拷贝的API(包括但不限于)
5.1 Object.assign()
Object.assign()
方法可以把任意多个的源对象自身的可枚举属性拷贝给目标对象,然后返回目标对象。语法:
Object.assign(target, ...sources)
其中
target
是目标对象,sources
是源对象,可以有多个,返回修改后的目标对象target
。如果目标对象中的属性具有相同的键,则属性将被源对象中的属性覆盖。后来的源对象的属性将类似地覆盖早先的属性。
示例:
//定义一个复杂对象 let a = { name: "wyb", hobby: ["唱", "跳", "rap", "篮球"], girlfriends: { first: "桥本环奈", second: ["新垣结衣", "斋藤飞鸟", "迪丽热巴", "马尔扎哈"] } }; //通过Object.assign方法把a对象中所有可枚举的对象添加给一个新对象,用变量b保存 let b = Object.assign方法把a对象中所有可枚举的对象添加给一个新对象({}, a) console.log(a, b); console.log(a === b);
很多人都说Object.assign是浅拷贝的方法,可是我想问,如果他是浅拷贝,那么拷贝后的对象和原对象必然是同一个对象。可是,
从输出结果来看,a和b长的完全一样,但是,判断结果为false。而众所周知,判断两个对象是否相等,是看他们的堆内存的空间是否是同一个地址,如果是浅拷贝,那么就拥有同一地址。现在的结果为false说明通过Object.assign拷贝后的数据拥有新的内存地址,而这是深拷贝才有的特性,浅拷贝是不生成新的内存地址的。
// let b = Object.assign({}, a) let b = a; console.log(a, b); console.log(a === b);
如果我们不使用Object.assign的方法,而是直接通过的赋值的方法,那么得到的结果才为true,这才能证明是浅拷贝。
所以我觉得Object.assign不能算是严格的浅拷贝的方法
继续往下:修改b的属性
// 定义一个复杂对象a let a = { name: "wyb", hobby: ["唱", "跳", "rap", "篮球"], girlfriends: { first: "桥本环奈", second: ["新垣结衣", "斋藤飞鸟", "迪丽热巴", "马尔扎哈"] } }; let b = Object.assign({}, a) // let b = a; // console.log(a, b); // console.log(a === b); b.name = "wangyubo"; b.hobby[0] = "多人运动打篮球"; b.girlfriends.second[0] = "呆头鹅"; console.log(a, b);
继续往下,我们发现,通过修改b的属性,a中的某些属性相应地改变了,而有些属性又没有被改变。
没有被改变的是复杂对象中的属性的类型为原始类型的值,也就是这里的name它的值是字符串类型,被改变了的有数组和对象,以及对象中的数组,这些是引用类型的值。
所以我们可以判断出,Object.assign在拷贝时,如果复杂对象的属性值的类型为原始数据类型时,是深拷贝的;如果复杂对象的属性值的类型为引用类型时,是浅拷贝的。
5.2 Array.prototype.concat()
Array.prototype.concat()是数组的内置方法,用于合并多个数组,不会改变原数组,返回新数组。
示例:
// 定义一个复杂数组 let a = [1, [2, 3], { name: "wyb" }] let b = a.concat([]) console.log(a, b); console.log(a === b);
concat()方法和上述一样,不是一个纯粹的浅拷贝方法。
修改:
// 定义一个复杂数组 let a = [1, [2, 3], { name: "wyb" }] let b = a.concat([]) // console.log(a, b); // console.log(a === b); b[0] = 9; b[1][0] = 8; b[2].name = "wangyubo"; console.log(a, b);
结果仍然和上述一样,当复杂对象中的属性的值类型是原始数据类型时,是深拷贝,为引用类型时,是浅拷贝。
5.3 Array.prototype.concat()
slice()
也是数组的一个内置方法,该方法会返回一个新的对象,不会改变原数组示例:
// 定义一个复杂数组 let a = [1, [2, 3], { name: "wyb" }] let b = a.slice() console.log(a, b); console.log(a === b);
同样的代码模式,同样的返回值
同样的修改方法
同样的结果。
5.4 …扩展运算符
let a = [1, [2, 3], { name: "wyb" }] let b = [...a] // console.log(a, b); // console.log(a === b); b[0] = 9; b[1][0] = 8; b[2].name = "wangyubo"; console.log(a, b);
扩展运算符也是一样的结果。
5.5 JSON.parse(JSON.stringify())
JSON.stringify()
:将对象转成 JSON 字符串。JSON.parse()
:将字符串解析成对象。先把一个对象转成字符串,再将字符串解析成对象,通过这种方法来拷贝。
示例:
let a = { name: "wyb", hobby: ["唱", "跳", "rap", "篮球"], girlfriends: { first: "桥本环奈", second: ["新垣结衣", "斋藤飞鸟", "迪丽热巴", "马尔扎哈"] } }; let b = JSON.parse(JSON.stringify(a)) // // let b = a; console.log(a, b); console.log(a === b);
从结果中我们可以看到,虽然这两个对象一模一样,但是并不相等。
修改:
// 定义一个复杂对象a let a = { name: "wyb", hobby: ["唱", "跳", "rap", "篮球"], girlfriends: { first: "桥本环奈", second: ["新垣结衣", "斋藤飞鸟", "迪丽热巴", "马尔扎哈"] } }; let b = JSON.parse(JSON.stringify(a)) // // let b = a; // console.log(a, b); // console.log(a === b); b.name = "wangyubo"; b.hobby[0] = "多人运动打篮球"; b.girlfriends.second[0] = "呆头鹅"; console.log(a, b);
从我标记的几个地方来看,修改b之后a没有跟着改变,无论这个复杂对象的值是原始数据类型还是引用数据类型,这说明了,经过这两个方法的转换之后,确实是深拷贝。
但是这个方法是有一定缺陷的
- 不能存放函数或者 Undefined,否则会丢失函数或者 Undefined;
- 不要存放时间对象,否则会变成字符串形式;
- 不能存放 RegExp、Error 对象,否则会变成空对象;
- 不能存放 NaN、Infinity、-Infinity,否则会变成 null;
- 总之就是JavaScript和JSON存在差异,两者不兼容就会出问题
小结:
从网上可以看到很多文章把这前四种方法归为浅拷贝,把最后一种方法归为深拷贝,
但是在我看来,除了最后一种方法确实算的上是纯粹的深拷贝之外,前四种都算不上纯粹的浅拷贝。
他们只是遵守了复杂对象拷贝时的一般原则:如果复杂对象的值的类型为原始数据类型时,这个值就会被深拷贝;如果复杂对象的值的类型是引用数据类型的话,这个值就会被浅拷贝。
前4个方法都遵守这个原则。之所以会被有些人归纳为浅拷贝,是因为他们直接忽视原始数据类型,如果只是这样认为,那么就称得上是浅拷贝。
还有些人认为,不完全的深拷贝就是浅拷贝。emmmm,也行吧。
6.手写深拷贝
深拷贝:
const deepClone = obj => { // 类型控制,传入的所有非对象,都转换成对象 if (obj === null || typeof obj !== 'object') { return obj } // 再次判断传入的对象是数组还是对象,分别返回一个对应的新容器 let newObj = Array.isArray(obj) ? [] : {} // 遍历这个对象上所有的属性 for (let key in obj) { // 只要这个对象的自有属性,忽略继承属性 if (obj.hasOwnProperty(key)) { // 继续判断obj的子元素是否是object if (obj[key] && typeof obj[key] === "object") { newObj[key] = deepClone(obj[key]) //处理引用类型 } else { newObj[key] = obj[key]; //处理原始类型 } } } return newObj } //定义一个复杂对象 let a = { name: "wyb", age: 18, girlfriends: null, hobby: undefined, man: true, car: ["宝马", "奔驰", "保时捷"], school: { first: "one", second: "two", }, drive: function() {} } //调用自定义的方法 let b = deepClone(a); console.log(a, b);
此时的b已经完成了对a的拷贝,并且函数和undefined也已经拷贝过来了。现在测试修改b后a的情况
// console.log(a, b); b.name = "wangyubo"; b.age = "28"; b.girlfriends = undefined; b.hobby = null; b.man = false; b.car[0] = "凯迪拉克"; b.school.first = "zero"; b.drive = 1; console.log(a, b);
b的修改,无论是原始类型还是引用类型,a都不会变。这说明b实现了对a的深拷贝。
7. 参考文章
《浅拷贝和深拷贝(较为完整的探索)》
作者:jsliang
链接:https://juejin.im/post/5da7c76a6fb9a04ddc625014
来源:掘金
《【进阶4-2期】Object.assign 原理及其实现》
作者:木易杨说
链接:https://juejin.im/post/5c31e5c4e51d45524975d05a
来源:掘金
《JavaScript系列: 一、手撕JS中的深浅拷贝》
作者:lmran
链接:https://juejin.im/post/5e906ab8518825738e217414
来源:掘金