浅拷贝和深拷贝谈累了+手写实现深拷贝

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张图片

实现浅拷贝 (拷贝为了解决引用类型赋值后互相影响的问题)

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: nulle:{}
}

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

你可能感兴趣的:(前端面试题,javascript,前端,vue.js)