对深拷贝的理解

260 阅读5分钟

三个概念:引用赋值、浅拷贝和深拷贝

引用赋值:定义一个变量,给该变量赋值(引用类型)的过程,实际上是在内存中开辟一块空间存放该引用类型值,这块被开辟出来的空间有一个内存地址如:0x0010001,然后该内存地址赋值给定义的变量。当使用该变量时,它会顺着内存地址找到内存中真正的内容。

如图,定义一个变量obj1,并给obj1赋值一个对象,再定一个变量obj2,并把obj1赋值给obj2。 这过程中在内存中开辟一块空间,然后将该空间地址赋值给变量,将obj1赋值给obj2也就是把该内存地址赋值给obj2,当改变obj2时,会顺着内存地址找到内存中真正的内容去修改它。

我们没有修改obj1,但是打印输出obj1,会发现obj1输出的内容却改变了,这就是引用赋值会造成的影响。

地址引用.png

浅拷贝:浅拷贝时,在堆内存中开辟一块新的内存空间,这块新的内存空间保存的数据包含属性与源数据属性完全一致,但是从字面意思就能看它只能拷贝浅层次的数据属性。

如下图所示:定义一个obj1对象,它的值是一个对象且该对象中的某个属性的值也是一个对象。 那么保存在堆内存中的体现是会开辟出两块空间,一块空间保存的书obj1的数据,属性值为非引用类型的则直接赋值,属性值为引用类型则保存引用地址,指向实际的数据内容(内存地址:x010111)

当我们将obj1拷贝到obj2,在堆内存中会为obj2开辟一块空间,存放实际数据,给obj2赋值引用地址。如此当修改obj1.name时,obj2不会受影响。但是如果修改obj1.info.job,此时输出obj2,会发现obj2的info属性也发生了改变

浅拷贝.png

深拷贝:理解了浅拷贝,深拷贝就也很容易理解了,顾名思义深拷贝就是完全的复制拷贝一份,除了源数据的基本类型值属性外,源数据的引用类性值也会在堆内存新开辟空间存放,从最外层开始查找一直到最里层。

深拷贝的几种方法比较

JSON转换

在实际项目中,大部分深拷贝使用需求JSON转换的方式就足够了,借助JSON.stringify()JSON.parse()这两个方法就能符合我们大多数的项目需求。 举个例子:以下代码,任意修改obj1或者obj2的属性值,两者都不会受到影响

var obj1 = {
  name:'赵老四',
  age:20,
  info:{
    job:'前端工程师',
    time:3
  }
}
const obj2 = JSON.parse(JSON.stringify(obj1))
console.log(obj2)
obj1.info.job = '转行java开发'
obj1.age = 21
console.log(obj1,obj2)

但是JSON转换的方式并不能完美的深拷贝出源数据,当元数据中属性有函数、正则、时间对象、undefined时,它并不能拷贝到。它会忽略属性值为函数、undefined的属性,遇到属性值为时间对象,它会解析成时间字符串,遇到正则表达式会解析为空对象

var obj1 = {
  name:'赵老四',
  age:20,
  date:new Date(),
  func:function(){},
  nund:undefined,
  reg:/\.js/
}
const obj2 = JSON.parse(JSON.stringify(obj1))

我们知道JSON转换的方式可以做到深拷贝的效果,但是它为什么可以呢?我们知道深拷贝的本质是在堆内存中开辟空间存放实际数据,那么使用JSON转换的过程中是不是在堆内存中开辟了这样一块空间呢?

MDN上说JSON.stringify()JSON.parse()就是序列化和反序列化的过程,序列化的作用是存储和传输,将一个javascript对象通过JSON.stringify()序列化成JSON字符串,然后再通过JSON.parse()对JSON字符串反序列化返回一个新的js对象

递归实现深拷贝

在用递归实现深拷贝之前,我们先用遍历的方式来实现一个浅拷贝,如下,obj1是可遍历对象,创建一个新的对象obj2,遍历obj1,将需要克隆对象的属性依次添加到新对象obj2上,这就实现了一个最基础版本的拷贝

var obj1 = {
  name:'赵老四',
  age:20,
  info:{
    job:'前端工程师'
  }
}
const obj2 = {}
for(var k in obj1){
  obj2[k] = obj1[k]
}

但前面所说,这种并没有将源数据中引用类型的属性完全拷贝,上面例子中obj1内有一个info属性是引用类型的值。要完成深层次的拷贝,就需要利用递归去赋值所有层级的属性,直到层级属性值为基本类型为止。我们将上面代码改装成一个递归函数

function recursion(obj){
  if(typeof obj === 'object'){
    let res = {}
    for(const k in obj){
      res[k] = recursion(obj[k])
    }
    return res
  }else{
    return obj
  }
}

如上代码,只是考虑了属性值为对象的类型,假如有属性值为数组类型的源数据通过它去拷贝会发生什么事情,我们不妨来测验一下。

var obj1 = {
  name:'赵老四',
  age:20,
  arr:[1,2,3],
  info:{
    job:'前端工程师'
  }
}
const obj2 = recursion(obj1)    // 拷贝生成新对象obj2
console.log(Array.isArray(obj1.arr),Array.isArray(obj2.arr))  //输出obj1和obj2是否为数组

打开控制台可以看到输出的结果,obj2的arr属性不是数组,而是一个键值对形式,这是因为我们在递归函数中并没有对数组类型的值做处理,我们要做的是在遍历源数据的每一项对其进行类型判断,然后根据其类型进行不同决定接收值的变量是何种类型

拷贝输出.png

继续对其进行改造优化如下,既可对对象进行遍历拷贝,也可以对数组进行遍历拷贝

function recursion(obj){
  if(typeof obj === 'object'){
    let res = Array.isArray(obj) ? [] : {}
    for(const k in obj){
      res[k] = recursion(obj[k])
    }
    return res
  }else{
    return obj
  }
}