JS 分为基本数据类型(又称原始数据类型)和引用数据类型(又称复合数据类型),对于基本数据类型的拷贝,并没有深浅拷贝的区别(都是深拷贝),我们讨论的深浅拷贝都只针对引用数据类型。
1.浅拷贝和深拷贝都复制了值和地址,都是为了解决引用类型赋值后互相影响的问题。
2.但是浅拷贝只进行一层复制,深层次的引用类型还是共享内存地址,原对象和拷贝对象还是会互相影响。
3.深拷贝就是无限层级拷贝,深拷贝后的原对象不会和拷贝对象互相影响
两个对象指向同一地址, 用 == 运算符作比较会返回 true。
两个对象指向不同地址, 用 == 运算符作比较会返回 false。
(==表面上是代表两个值相等,本质是在堆中有相同的地址,反过来即是指向同一地址代表有相同的值)
如下
var obj = {}
var newObj = obj //赋值了指向堆中的同一地址
console.log(obj == newObj) // true
var obj = {}
var newObj = {}
//没有赋值操作,本质上只是在栈中开辟了两个地址
console.log(obj == newObj) // false
var ob = {
a:5,
person: {
name: 'lin'
}}
var newOb={}
newOb=ob //赋值了指向堆中的同一地址
ob.a=3
console.log(newOb); //{a: 3, person: {…}}
console.log(ob == newOb) // true
对于引用数据类型来说,直接赋值,两个对象指向同一地址,就会造成引用类型之间互相影响的问题:
例 let arr=[1,2,3]
let arr2=[5,6,7]
arr2=arr
arr2[0]=9
console.log(arr); //[9, 2, 3]
console.log(arr2); //[9, 2, 3]
console.log('两者指向同一地址',arr2==arr);
例 const obj = {
name: 'lin'
}
const newObj = obj
newObj.name = 'xxx' // 改变原来的对象
console.log('原来的对象', obj)
console.log('新的对象', newObj)
console.log('两者指向同一地址', obj == newObj)
对于引用数据类型来说,就有了堆和栈的说法,obj会开辟一个内存地址,这就是栈,但这个内存地址的值又会被分配到另一个地址,在另一个地址中创建了对应的值(也就是堆),obj在进行复制时,就会通过栈一路找到堆进行复制,一旦修改,那么obj和newObj 都会受影响,因为它们指向的都是同一个地址。
对基本数据类型进行复制,并重新赋值,并不会影响到原来的变量,这就是深拷贝
let myname = '提灯';
let age = myname;
age = 22;
console.log(myname); //提灯
console.log(age); //22
myname这个变量会在内存中开辟一个地址,存储一个值为提灯,age这个变量复制了myname也会在内存中开辟一个地址,存储一个值为提灯,但对age重新赋值时,会在内存在开辟一个新地址用于存储22,然后变量age指向这个地址
实现浅拷贝 (拷贝为了解决引用类型赋值后互相影响的问题)
1.Object.assign是ES6新添加的接口,主要的用途是用来合并多个JavaScript的对象
const obj = {
name: 'lin'
}
const newObj = Object.assign({}, obj)
obj.name = 'xxx' // 改变原来的对象
console.log(newObj) // { name: 'lin' } 新对象不变
console.log(obj === newObj) // false 两者指向不同地址
举例验证说明浅拷贝的思想
//举例验证说明浅拷贝的思想
const obj = {
name: 'lin',
person:{
age:20
}
}
const newObj = Object.assign({}, obj)
obj.name = 'xxx' // 改变原来的对象第一层
obj.person.age=25 // 改变原来的对象的深层
console.log(newObj) // { name: 'lin',person:{ age:25}} 新对象只有第一层不受影响,深层次的还会相互影响
console.log(obj); // { name: 'xxx',person:{ age:25}}
console.log(obj === newObj) // false 两者指向不同地址
console.log(obj.person === newObj.person) //true 引用数据类型,只比较堆中的地址,没做任何处理,所以还是指向同一地址
console.log(obj.name === newObj.name) //false 基本数据类型,只比较值和类型,值已经被改变
2.数组的 slice 和 concat 方法(两个方法都不会改变原有的数组)
const arr = ['lin', 'is', 'handsome']
const newArr = arr.slice(0)
arr[2] = 'rich' // 改变原来的数组
console.log(newArr) // ['lin', 'is', 'handsome']
console.log(arr == newArr) // false 两者指向不同地址
//concat()方法是用于连接两个或多个数组
const arr = ['lin', 'is', 'handsome']
const newArr = [].concat(arr)
arr[2] = 'rich' // 改变原来的数组
console.log(newArr) // ['lin', 'is', 'handsome'] // 新数组不变
console.log(arr == newArr) // false 两者指向不同地址
3.数组静态方法 Array.from
const arr = ['lin', 'is', 'handsome']
const newArr = Array.from(arr)
arr[2] = 'rich' // 改变原来的数组
console.log(newArr) // ['lin', 'is', 'handsome']
console.log(arr == newArr) // false 两者指向不同地址
4.扩展运算符
const arr = ['lin', 'is', 'handsome']
const newArr = [...arr]
arr[2] = 'rich' // 改变原来的数组
console.log(newArr) // ['lin', 'is', 'handsome'] // 新数组不变
console.log(arr == newArr) // false 两者指向不同地址
const obj = {
name: 'lin'
}
const newObj = { ...obj }
obj.name = 'xxx' // 改变原来的对象
console.log(newObj) // { name: 'lin' } // 新对象不变
console.log(obj == newObj) // false 两者指向不同地址
实现深拷贝
1.使用JSON的方法完成深拷贝,先使用JSON.stringify()将JS对象转换为JSON格式的字符串,在使用JSON.parse(),将JSON格式的字符串转换成JS对象,这样得到的就是一个全新JS对象,与之前的内存地址不相连.
const obj = {
person: {
name: 'lin'
}
}
const newObj = JSON.parse(JSON.stringify(obj))
obj.person.name = 'xxx' // 改变原来的深层对象
console.log(newObj) // { person: { name: 'lin' } } 新的深层对象不变
但是这种方式存在弊端,会忽略undefined、symbol和函数(原来的值不变)
const obj = {
a: undefined,
b: Symbol('b'),
c: function () {},
d:5
}
const newObj = JSON.parse(JSON.stringify(obj))
console.log(obj); //{a: undefined, b: Symbol(b), d: 5, c: ƒ}
console.log(newObj) // {d: 5}
NaN、Infinity、-Infinity 会被序列化为 null (原来的值不变)
const obj = {
a: NaN,
b: Infinity,
c: -Infinity,
d:5
}
const newObj = JSON.parse(JSON.stringify(obj))
console.log(obj); //{a: NaN, b: Infinity, c: -Infinity, d: 5}
console.log(newObj) //{a: null, b: null, c: null, d: 5}
而且还不能解决循环引用的问题:
const obj = {
a: 1
}
obj.o = obj
console.log(obj); // 打印的结果是无限层循环{a: 1, o: {…}}
const newObj = JSON.parse(JSON.stringify(obj)) // 报错 Converting circular structure to JSON
这种JSON格式的字符串与js对象的转换适用于日常开发中深拷贝一些简单的对象,接下来,我们试着一步步深入手写一个深拷贝,处理各种边界问题。
中途简单了解下JSON:
1.JSON(JavaScript Object Notation,JS 对象标记)是一种轻量级的数据交换格式,目前使用特别广泛。
2.采用完全独立于编程语言的文本格式来存储和表示数据
3.简洁和清晰的层次结构使得 JSON 成为理想的数据交换语言。
4.易于人阅读和编写,同时也易于机器解析和生成,并有效的提供网络传输效率。
5.JavaScript语言中,一切都是对象。因此,任何JavaScript支持的类型都可以通过JSON来表示,例如字符串、数字、对象、数组等。
6.JSON 和 JavaScript 对象的关系:JSON 是 JavaScript 对象的字符串表示法,它使用文本表示一个 JS 对象的信息,本质是一个字符串
7.JSON 数据格式与语言无关,脱胎自JavaScript,但当前很多编程语言都支持 JSON 格式数据的生成和解析。JSON 的官方 MIME 类型是 application/json,json格式文件就是后缀名为.json的文件.
8.知道XML吧(数据交换格式),JSON 也是数据交换格式,相同数据不同格式的表示,两者在不同场景下各有优势,平分秋色
9.传输较大数据的js对象时可以先把js对象转换为JSON 格式的数据再进行传输
先实现一个浅拷贝
function clone (obj) {
const cloneObj = {} // 创建一个新的对象
for (const key in obj) { // 遍历需克隆的对象
cloneObj[key] = obj[key] // 将需要克隆对象的属性依次添加到新对象上
}
return cloneObj
}
const obj = {
a:5,
person: {
name: 'lin'
}
}
const newObj = clone(obj)
obj.person.name = 'xxx' // 改变原来的对象
obj.a=3
console.log('原来的对象', obj) //{a: 3, person: {name: 'xxx'}}
console.log('新的对象', newObj) //{a: 5, person: {name: 'xxx'}}
console.log('第一层的不指向同一地址', obj == newObj) //false //相当于obj和newObj的值的比较,但本质上是在堆中的地址
console.log('更深层的指向同一地址', obj.person== newObj.person) //true
简单版本
现在用递归来实现深拷贝,让原对象和克隆对象不互相影响。
function deepClone (target) {
if (typeof target !== 'object') { // 如果是原始类型,无需继续拷贝,直接返回
return target
}
// 如果是引用类型,递归实现每一层的拷贝
const cloneTarget = {} // 定义一个克隆对象
for (const key in target) { // 遍历原对象
cloneTarget[key] = deepClone(target[key]) // 递归拷贝每一层
}
return cloneTarget // 返回克隆对象
}
const obj = {
a:5,
person: {
name: 'lin'
}
}
const newObj = deepClone(obj)
obj.person.name = 'xxx' // 改变原来的对象
obj.a=3
console.log('原来的对象', obj) //{a: 3, person: {name: 'xxx'}}
console.log('新的对象', newObj) //{a: 5, person: {name: 'lin'}}
console.log('第一层的不指向同一地址', obj == newObj) //false
console.log('更深层的不指向同一地址', obj.person== newObj.person) //flase
用上面的方法处理数组、日期、正则、null最后的结果是空的:
const obj = {
a: [],
b: new Date(),
c: /abc/,
d: null,
e:{}
}
const newObj = deepClone(obj)
console.log('原来的对象', obj) //不变
console.log('新的对象', newObj) //{a: {}, b: {}, c: {}, d: {},e:{}}
上文的方法实现了最简单版本的深拷贝,但是没有处理 null 这种原始类型,也没有处理数组、日期和正则这种比较常用的引用数据类型。
处理数组、日期、正则、null:
function deepClone (target) {
if (target === null) return target // 处理 null
if (target instanceof Date) return new Date(target) // 处理日期
if (target instanceof RegExp) return new RegExp(target) // 处理正则
if (typeof target !== 'object') return target // 处理原始类型
// 处理对象和数组
console.log(new target.constructor() ); //[] //{}
let cloneTarget = new target.constructor() //利用target的constructor构造函数创建一个新的克隆对象或克隆数组
for (const key in target) { // 递归拷贝每一层
cloneTarget[key] = deepClone(target[key])
}
return cloneTarget
}
const obj = {
a: [1,555],
b: new Date(),
c: /abc/,
d: null
}
const newObj = deepClone(obj)
obj.a=[8]
console.log('原来的对象', obj) //{a: [8], b: Mon Aug 08 2022 11:21:34 GMT+0800 (中国标准时间), c: /abc/, d: null}
console.log('新的对象', newObj) //{a: [1,5], b: Mon Aug 08 2022 11:21:34 GMT+0800 (中国标准时间), c: /abc/, d: null}
实例的 constructor 其实就是构造函数
console.log(new {}.constructor()) // {}
等价于
console.log(new Object()) // {}
console.log(new [].constructor()) // {}
等价于
console.log(new Array()) // []
处理 Symbol
上面的方法无法处理 Symbol 作为键,测试一下。
//由于每一个 Symbol 的值都是不相等的,所以 Symbol 作为对象的属性名,可以保证属性名不重名
const obj = {}
let name = Symbol('name') //'name'只是描述而已 //接受一个字符串作为参数,为新创建的 Symbol 提供描述,用来显示在控制台或者作为字符串的时候使用,便于区分。
// 相同参数 Symbol() 返回的值不相等
let name2 = Symbol("name");
name === name2; // false
typeof(name ); // "symbol"
console.log(name); //Symbol(name)
obj[name] = 'lin' // name 作为键
const newObj = deepClone(obj)
console.log(obj); //{Symbol(name): 'lin'}
console.log(newObj) // {}
可以把 for in 换成 Reflect.ownKeys 来解决
Reflect 是一个内置的对象,它提供拦截 JavaScript 操作的方法,是 ES6 为了操作对象而提供的新 API。
Reflect不是一个函数对象,因此它是不可构造的。
Reflect的所有属性和方法都是静态的。
静态方法 Reflect.ownKeys() 返回一个由目标对象自身的属性键组成的数组。
例子:
const object1 = {
property1: 42,
property2: 13
};
const array1 = [1];
const str = 3; // 如果目标不是 Object,抛出一个 TypeError。
console.log(Reflect.ownKeys(str)); // TypeError
console.log(Reflect.ownKeys(object1));// ["property1", "property2"]
console.log(Reflect.ownKeys(array1));// ['0', 'length'] 索引
继续改造克隆函数:
function deepClone (target) {
if (target === null) return target
if (target instanceof Date) return new Date(target)
if (target instanceof RegExp) return new RegExp(target)
if (typeof target !== 'object') return target
const cloneTarget = new target.constructor()
// 换成 Reflect.ownKeys
Reflect.ownKeys(target).forEach(key => {
cloneTarget[key] = deepClone(target[key]) // 递归拷贝每一层
})
return cloneTarget
}
测试一下:
const obj = {}
const name = Symbol('name')
obj[name] = 'lin'
const newObj = deepClone(obj)
console.log(obj); //{Symbol(name): 'lin'}
console.log(newObj) //{Symbol(name): 'lin'}
处理循环引用
上面的方法无法处理循环引用,测试一下。
const obj = {
a: 1
}
obj.obj = obj
const newObj = deepClone(obj)
console.log(newObj) //报错
原因是对象存在循环引用的情况,递归进入死循环导致栈内存溢出了。
解决循环引用问题,可以额外开辟一个存储空间来存储当前对象和拷贝对象的对应关系。
当需要拷贝当前对象时,先去存储空间中找,有没有拷贝过这个对象,如果有的话直接返回,就不会一直递归导致栈内存溢出了。
function deepClone (target, hash = {}) { // 额外开辟一个存储空间来存储当前对象和拷贝对象的对应关系
if (target === null) return target
if (target instanceof Date) return new Date(target)
if (target instanceof RegExp) return new RegExp(target)
if (typeof target !== 'object') return target
if (hash[target]) return hash[target] // 当需要拷贝当前对象时,先去存储空间中找,如果有的话直接返回
const cloneTarget = new target.constructor()
hash[target] = cloneTarget // 如果存储空间中没有就存进存储空间 hash 里
Reflect.ownKeys(target).forEach(key => {
cloneTarget[key] = deepClone(target[key], hash) // 递归拷贝每一层
})
return cloneTarget
}
测试一下:
const obj = {
a: 1
}
obj.obj = obj
const newObj = deepClone(obj)
console.log('原来的对象', obj)
console.log('新的对象', newObj)
上面的方法我们使用的是对象来创建的存储空间,这个存储空间还可以用WeakMap,这里优化一下,使用 WeakMap,配合垃圾回收机制,防止内存泄漏。
const wm = new WeakMap() //新建一个 WeakMap 实例
WeakMap 就是为了解决内存泄露(内存泄露其实就是占用了内存但该内存已不再使用了)而诞生的,它的键名所引用的对象都是弱引用,即垃圾回收机制不将该引用考虑在内。因此,只要所引用的对象的其他引用都被清除,垃圾回收机制就会释放该对象所占用的内存。也就是说,一旦不再需要,WeakMap 里面的键名对象和所对应的键值对会自动消失,不用手动删除引用。
引入 WeakMap后的写法:
了解了 WeakMap 的作用,我们来继续优化深拷贝函数,
存储空间把对象改成 WeakMap,WeakMap 主要是为了处理经常被删除的 DOM 元素,在深拷贝函数里也加入对 DOM 元素的处理。
如果拷贝对象是 DOM 元素就直接返回,拷贝 DOM 元素没有意义,都是指向页面中同一个
function deepClone (target, hash = new WeakMap()) { // 额外开辟一个存储空间WeakMap来存储当前对象
if (target === null) return target
if (target instanceof Date) return new Date(target)
if (target instanceof RegExp) return new RegExp(target)
if (target instanceof HTMLElement) return target // 处理 DOM元素
if (typeof target !== 'object') return target
if (hash.get(target)) return hash.get(target) // 当需要拷贝当前对象时,先去存储空间中找,如果有的话直接返回
const cloneTarget = new target.constructor()
hash.set(target, cloneTarget) // 如果存储空间中没有就存进 hash 里
Reflect.ownKeys(target).forEach(key => {
cloneTarget[key] = deepClone(target[key], hash) // 递归拷贝每一层
})
return cloneTarget
}
更多边界情况
其实上面的深拷贝方法还有很多缺陷,有很多类型对象都没有实现拷贝,毕竟 JS 的标准内置对象实在太多了,要考虑所有的边界情况,就会让深拷贝函数变得特别复杂。
日常开发中,如果要使用深拷贝,为了兼容各种边界情况,一般是使用三方库,推荐两个:
lodash
xe-utils
lodash插件的简单用法:
1.浅拷贝
使用 lodash 浅拷贝 clone 方法,让他们俩指向不同地址:
import { clone } from 'lodash'
const obj = {
name: 'lin'
}
const newObj = clone(obj)
obj.name = 'xxx' // 改变原来的对象
console.log('原来的对象', obj) //{ name: 'xxx'}
console.log('新的对象', newObj) //{ name: 'lin'}
console.log('两者不指向同一地址', obj == newObj) //flase
2.但是浅拷贝只能解决一层,更深层的对象还是会指向同一地址,互相影响,这个时候,就需要使用深拷贝来解决:
import { cloneDeep } from 'lodash'
const obj = {
person: {
name: 'lin'
}
}
const newObj = cloneDeep(obj)
obj.person.name = 'xxx' // 改变原来的对象
console.log('原来的对象', obj)
console.log('新的对象', newObj)
console.log('更深层的对象不指向同一地址', obj.person== newObj.person) //flase
未来的深拷贝
其实,浏览器自己实现了深拷贝函数,想不到吧。
这个 Web API 名称叫 structuredClone()
const obj = {
person: {
name: 'lin'
}
}
const newObj = structuredClone(obj) //
obj.person.name = 'xxx' // 改变原来的对象
console.log('原来的对象', obj)
console.log('新的对象', newObj)
console.log('更深层的对象不指向同一地址', obj.person== newObj.person) //flase
深拷贝生效了,那是不是说以后再也不用 lodash 的 cloneDeep 了呢?
很显然,不能,毕竟这是一个新的 API,从兼容性来考虑,很多浏览器应该都不会支持。
总结
关于浅拷贝和深拷贝的使用选择,保险的做法是所有的拷贝都用深拷贝,而且一般是直接引三方库,毕竟自己写深拷贝,各种边界情况有时候考虑不到。
像手写深拷贝这种卷王行为也只会出现在面试场景中了,面试场景中能把本文的深拷贝手写出来,并且能说出 lodash 源码的实现思路,也差不多了。
其实,如果只是拷贝一层对象,只要能解决引用类型赋值后相互影响的问题,用浅拷贝又怎么了?
另外,如果 JSON.parse(JSON.stringify(object))能实现你的功能,你却非要去引入 lodash 的 cloneDeep 方法,那不就徒增了项目的打包体积了吗?自己平时写着玩的项目,用 JSON.parse(JSON.stringify(object)) 又怎么了?
当然,如果团队有规范,为了代码风格统一或者说为了避免潜在的风险,统一全部用三方库的方法,增加一些打包的体积也没关系,毕竟企业级的项目还是要严谨一点。
到此为止…
参考链接:https://juejin.cn/post/7072528644739956773#heading-14