前言
最近在掘金读到了一篇文章《Radash 能取代 Lodash???真幽默 - 掘金》,文章的评论区讨论起了深拷贝。"深拷贝" 我的"老朋友",还记得在学习我人生中的第二道面试题的时候认识了它,还记得那是一道手写深拷贝的面试题,还记的那个讲题的老师很贱很嚣张,弹幕中戏称他讲的是"嘴硬代码"。不知道为什么想起这些心中莫名的有些忧伤,我想或许是时候总结一下"我与深拷贝的故事",来一场"朝花夕拾"。
一、什么是深拷贝?
1.基本数据类型与引用数据类型的区别
想要理解深拷贝,必须要先知道基本数据类型和引用数据类型的区别。
基本数据类型
基本数据类型的值存储在栈内存中,所以在进行基本数据类型的赋值操作时,前后两个值是相互独立的,请看下面的例子:
JavaScript
let a = 10
let b = a
console.log(a,b)//10 10
这里就进行了一次基本数据类型的赋值操作,将变量a
中的基础数据类型值10
复制了一份放到了变量b
中。此时虽然a
、b
中的值相同(都是10
),但它们是相互独立的,因此修改其中一个变量的值并不会影响到另一个变量。
JavaScript
let a = 10
let b = a
b = 20
console.log(a,b)//10 20
引用数据类型
引用数据类型则是保存在堆内存中。与其他语言的不同是,在JS中不可以直接访问堆内存空间中的位置和操作堆内存空间。只能操作对象在栈内存中的引用地址。
引用数据类型在栈内存 中保存的实际上是对象在堆内存中的引用地址。因此,在进行引用数据类型的赋值操作时,复制的就不是值了,而是一个引用地址,请看下面的例子:
JavaScript
let a = { age = 10 }
let b = a
console.log(a)//{ age = 10 }
console.log(b)//{ age = 10 }
同样还是将a
赋值给b
,但这次a
中保存的是引用数据类型的值(一个对象),因此在赋值之后,修改a
或b
对象中的属性,另一个也会跟着改变,因为实际上它俩都是同一个数据。
JavaScript
let a = { age = 10 }
let b = a
b.age = 20
console.log(a)//{ age = 20 }
console.log(b)//{ age = 20 }
2.赋值与拷贝的区别
这里探讨一个问题:什么是"拷贝"?
我之前一直认为上面讲到的赋值就是拷贝 ( 例如: b = a
) ,在查阅了一些相关的资料后我发现似乎并不是这样。
赋值
按照我的理解,赋值 是指在栈内存中将一个变量值复制一份存储到另一个变量中,如果是普通数据类型 复制的就是数据 ,如果是引用数据类型 复制的就是引用地址。
拷贝
而拷贝 则是指在堆内存中开辟一个新的区域,将原有的一个引用数据类型值复制一份存储到这个新区域中,此时栈内存中的两个变量存储的是两个不同的引用地址。
总结
所以这里我就得出两个重要的结论:
- 赋值与拷贝不是一回事,对于引用数据类型来说,赋值只复制栈中的引用地址,不改变堆中存储的对象;而拷贝则不复制引用地址,转而复制堆中存储的对象。
- 只有引用数据类型才存在拷贝的概念(包括下面要讲的深拷贝和浅拷贝),基本数据类型不存在拷贝。
3.浅拷贝与深拷贝的区别
拷贝可以分为两种,一种是不完全拷贝即浅拷贝 ,另一种是完全拷贝即深拷贝。
浅拷贝
浅拷贝是指在堆中复制对象时只复制第一层属性 ,这种复制是不完全的、是浅层的,对象(数组)内部的嵌套的对象(数组)只会保留引用地址,也就是说对象中普通数据类型 的属性会被复制 ,而引用数据类型 的属性则只会被赋值(只会复制引用地址)。因此这些嵌套对象是共享一块内存的,会相互影响。
浅拷贝的实现方式
浅拷贝的实现方式包括:对象/数组展开运算, Object.assign
, Object.create
, Array.prototype.concat
, Array.prototype.slice
等。
JavaScript
let a = { age: 10 }
let b
//i-展开运算符(对象和数组)
a = { age: 10 , info:{ value:1 } }
b = { ...a }
b.age = 20
b.info.value = 2
console.log(a, b)//{ age: 10 , info: { value: 2 } } { age: 20 , info: { value: 2 } }
// i- Object.assign 对象混入
a = { age: 10 , info:{ value:1 } }
b = Object.assign({}, a)
b.age = 20
b.info.value = 2
console.log(a, b)//{ age: 10 , info: { value: 2 } } { age: 20 , info: { value: 2 } }
//i- Object.create 寄生式继承
a = { age: 10 , info:{ value:1 } }
b = Object.create(a)
b.age = 20
b.info.value = 2
console.log(a, b)//{ age: 10 , info: { value: 2 } } { age: 20 , info: { value: 2 } }
//❤️🐼🐸🐽🐻🐻❄️🐔🐨
/**
* i- arr.concat() 数组合并
* */
a = [1, { age: 10 }]
b = [].concat(a)
b[0] = 10
b[1].age = 20
console.log(a, b) //[ 1 , { age: 20 }] [ 10 , { age: 20 }]
/**
* i- arr.slice() 数组剪切
*/
a = [1, { age: 10 }]
b = a.slice()
b[0] = 10
b[1].age = 20
console.log(a, b) //[ 1 , { age: 20 }] [ 10 , { age: 20 }]
深拷贝
深拷贝则是会对对象中的每一层都进行拷贝(包括嵌套的对象),因此拷贝前后的两个对象完全独立互不影响。
对比赋值、浅拷贝和深拷贝
两个变量是否指向同一对象 | 修改基本类型的属性是否会影响另一个变量 | 修改引用类型的属性是否会影响另一个变量 | |
---|---|---|---|
赋值 | 是 | 是 | 是 |
浅拷贝 | 否 | 否 | 是 |
深拷贝 | 否 | 否 | 否 |
二、如何实现深拷贝?
这部分才是这篇文章我真正想讨论的内容,我会首先讲述我自己用过的深拷贝方法,最后再补充其他的方法。
1.手写深拷贝
在前言中提到我最早接触深拷贝就是在一个讲手写深拷贝的面试题课中,所以手写深拷贝就是我最早接触的深拷贝方法。
实现深拷贝的基本原理就是:基本数据类型不存在赋值问题,而对象数组等引用数据类型又是由基本数据类型所组成的。
因此基本思路就是:遍历对象/数组,如果属性为基本数据类型就直接复制,如果属性为另一个对象/数组,那就进行递归处理。
当然关于深拷贝还有许多的细节需要去处理可千万不要小看它,这里我推荐文章《如何写出一个惊艳面试官的深拷贝?》它里面详细的讲述了许多的细节问题。
手写深拷贝有着无限的可能,我们可以不断的打磨使其趋近完美,在这个过程还能够锻炼我们的能力,不过...
2.JSON.parse(JSON.stringify())
手写虽好但毕竟实际工作不等同于面试,很快我就发现了手写深拷贝方法就很麻烦,毕竟每个项目都重写一遍,或者重新复制一份真的很麻烦。所以很快我就抛弃了手写深拷贝,转用JSON.parse(JSON.stringify())
。
用法介绍
这种方法利用JSON.stringify
将对象转成JSON字符串,再用JSON.parse
把字符串解析成对象,一去一来,新的对象产生了,而且对象会开辟新的栈,实现深拷贝。
JavaScript
let a = { age: 10, info: { value: 1 } }
let b
// JSON方法实现深拷贝
b = JSON.parse(JSON.stringify(a))
b.info.value = 2
console.log(a, b) //{ age: 10 , info: { value: 1 } } { age: 20 , info: { value: 2 } }
局限性
当然这个方法有一定的局限性。
比如说如果克隆的对象存在循环引用的话,就会报错
JavaScript
let a = { age: 10, info: { value: 1 } }
let b
a.target = a
b = JSON.parse(JSON.stringify(a))
又比如无法正确的克隆函数和正则
JavaScript
let a = { age: 10, info: { value: 1 } }
let b
a.go = function () {
console.log('我走了')
}
a.check = /^[0-9]/
b = JSON.parse(JSON.stringify(a))
console.log(a, b)
可以看到克隆之后,函数直接消失了,正则变为了一个空对象{}
3.lodash的_.cloneDeep方法
在工作之后发现用的的最多的还是各种工具库中的深拷贝方法。我常用的就是lodash的cloneDeep
:
JavaScript
import { cloneDeep } from 'lodash-es'
let a = { age: 10, info: { value: 1, check: /^[0-9]/ }, go: function () {} }
a.target = a
let b = cloneDeep(a)
console.log(b);
4.structuredClone
最近我了解到在前几年JS终于也有了自己内置的深拷贝方法structuredClone
。
使用
我们来尝试一下:
JavaScript
let a = { age: 10, info: { value: 1, check: /^[0-9]/ }, go: function () {} }
a.target = a
let b = structuredClone(a)
console.log(b)
结果报错了,显示structuredClone
无法克隆函数。那去掉函数再试一下:
JavaScript
let a = { age: 10, info: { value: 1, check: /^[0-9]/ }}
a.target = a
let b = structuredClone(a)
console.log(b)
这次顺利的拷贝出来了,可以看到structuredClone
可以克隆正则,也可以处理循环引用。
局限性
structuredClone
并非完美的它还有一些局限性:
- 原型 :如果你使用
structuredClone
克隆类实例,你将获得一个普通对象作为返回值,因为结构化克隆会丢弃对象的原型链。 - 不可克隆:有些值不是结构化可克隆的,尤其是Error、 DOM 节点 和 Function。尝试这样做将引发 DataCloneError 异常。
- 属性描述符:setter和getter(以及类似元数据的功能)不会被复制。例如,如果使用属性描述符将对象标记为只读,则复制后的对象中是可读写(默认配置)。
- RegExp:RegExp对象的lastIndex字段不会保留。