JavaScript

JavaScript中的深浅拷贝

总结了JavaScript中的深浅拷贝及代码实现

Yixuan Lang
2021-06-09
7 min

# JavaScript中的深浅拷贝

在前端的学习中“深浅拷贝”这个字眼频频出现,也算是前端中一个非常重要的知识点。对此有必要输出一篇文章来总结一下深浅拷贝的原理及其实现过程啦~😎

shallow and deep copy

# 一、变量的赋值


为了更好的理解深浅拷贝,首先要了解清楚一个概念“变量的赋值”。之前在没有深入的了解拷贝这个概念的时候,我没有细想拷贝和赋值之间到底有什么样的区别。但是赋值和拷贝之间在底层操作上还是有区别的。

说到赋值需要扯出一个十分基本的概念,就是数据类型。之前我也有总结过该部分的知识。在 JavaScript 中,变量包含两种不同的数据类型,即『基本类型』和『引用类型』,在将一个值赋给变量时,解析器必须确定这个值是基本类型还是引用类型。

  • 基本数据类型的特点:变量直接将基本数据类型的值存储在栈(stack)内存中
  • 引用数据类型的特点:变量将引用数据类型的引用(可以看作是地址)存储在栈(stack)内存,而对象本身存放在堆内存里,在栈中引用指向堆中的对象。

堆和栈

与其他语言不同,JavaScript 不允许直接访问内存中的地址,也就是说我们不能直接操作对象的内存空间,所以在操作对象时,实际上是在操作对象的引用而不是实际的对象,所以变量的赋值行为可以分为『传值』与『传址』两种。

  • 基本数据类型赋值:『传值』,直接将存储在栈内存中的值赋值给变量
  • 引用数据类型赋值:『传址』,当把一个引用型变量赋值给另一个变量时,实际上赋值的其引用变量在栈中存储的地址,而不是在堆中存储的数据。所以两者指向的是堆中的同一个存储空间,其中一者发生改变就会影响另一者。
变量赋值

# 二、深拷贝和浅拷贝

# 1. 什么是浅拷贝和深拷贝

  • 浅拷贝:是创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝,如果属性值是基本数据类型,拷贝的就是基本数据类型的值,如果拷贝的是引用数据类型,拷贝的就是内存地址。并且拷贝的对象中引用类型变量发生改变,会影响到所有对象。
  • 深拷贝:是将一个对象从内存中完整的拷贝一份出来,从堆内存中开辟一个新的区域存放新对象,且修改新对象不会对源对象造成影响
image-20211010212114213

深浅拷贝

# 2. 赋值、深拷贝、浅拷贝之间的区别

这三者的区别如下,不过比较的前提都是针对引用类型

image.png
  • 赋值:当把一个引用型变量赋值给另一个变量时,实际上赋值的其引用变量在栈中存储的地址,而不是在堆中存储的数据。所以两者指向的是堆中的同一个存储空间,其中一者发生改变就会影响另一者。

  • 浅拷贝:重新在堆中创建内存,拷贝前后对象的基本数据类型互不影响,但拷贝前后的引用类型共享同一块内存,互相影响

  • 深拷贝:从堆内存中开辟一个新的区域存放新对象,对对象中的引用类型进行递归拷贝,拷贝前后的两个对象互不影响

# 三、深浅拷贝的代码实现

说了这么多了,道理咱都懂了,take is shit show me the code 👨‍💻

# 1. 浅拷贝

# (1)浅拷贝的简单实现

function shallowCopy(source) {
    let target = {}
    for (let prop in source) {
        if(source.hasOwnProperty(prop)) target[prop] = source[prop]
    }
    return target
}
const person = {
    name: 'Langyixuan',
    age: 22,
    family: ['mom', 'dad', 'cat']
}

let person2 = shallowCopy(person)
console.log(person2 === person)  // false
person2.family.pop()
console.log(person.family)  // ['mom', 'dad']

for...in...循环不仅仅会遍历对象本身的属性,还会遍历其原型对象上的属性。所以使用hasOwnProperty()方法来判断遍历到的当前属性是不是对象本身的属性

# (2)Object.assign()

Obejct.assign()方法可以把多个源对象上所有可以枚举的属性拷贝到目标对象中,最终返回目标对象

let source = {
	person: {
  	name: 'Langyixuan',
    age: 22
  },
  cat: 'nuomi'
}
let target = Object.assign({}, source)
target.person.name = 'doudou'
console.log(source.person.name)   // 'doudou'
console.log(target.person.name)   // 'doudou'

# (3) 扩展元素符...

同Object.assgin( )的功能一样可以实现浅拷贝

let source = {
  	name: 'Langyixuan',
  	address: { x :100, y: 200}
}
let target = {...obj}
source.name = 'doudou'
source.address.x = 200
console.log(target.name)  //doudou
console.log(target.address.x) // 200

# (4)Array.prototype.concat( )

对于浅拷贝数组对象来说,concat()方法可以用于拼接数组,不会影响原数组,仅仅返回一个连接后的新数组。从而实现浅拷贝的效果

let arr = [1, 3, {username: 'Langyixuan'}]
let arr2 = arr.concat()
arr2[2].username = 'wade'
console.log(arr[2].username)  //'wade'

# (5)使用lodash库中的_.clone方法

现在已经有很多成熟的函数库可以帮助我们做深浅拷贝的工作,其中Lodash函数库就可以帮助其很好的实现

let _ = require('lodash')
let obj = {
	a: 1,
  b: { f: { g: 1 }},
  c: [1, 2, 3]
}
let obj2 = _.clone(obj)
console.log(obj.b.f === obj2.b.f)   // true

# 2. 深拷贝

# (1)递归实现

这里强烈推荐这篇文章对深拷贝的分析很透彻👉如何写出一个惊艳面试官的深拷贝

如果是深拷贝的话,考虑到我们要拷贝的对象是不知道有多少层深度的,我们可以用递归来解决问题

  • 如果是基本类型,无需继续拷贝,直接返回
  • 如果是引用类型,创建一个新的对象,遍历需要拷贝的对象,将需要拷贝对象的属性执行『深拷贝』后依次添加到新对象上

# 第一版(简易版本)

// 第一版
function deepClone(obj) {
    // 可能是对象或者普通的值  如果是函数的话是不需要深拷贝
    if (typeof obj !== 'object') return obj
    if (obj === null) return obj
    let cloneObj = new obj.constructor()
    for(let i in obj) {
        if (obj.hasOwnProperty(i)) {
            cloneObj[i] = deepClone(obj[i])
        }
    }
    return cloneObj
}

// 测试
const target1 = {
  a: 1,
  b: undefined,
  c: {
    name: 2
  },
  d: [3, 4, 5],
  e: null
}

const target2 = deepClone(target1)
target2.c.name = 4

console.log(target1)  // {a: 1, b: undefined, c: { name: 2 }, d: [3, 4, 5], e: null}
console.log(target2)  // {a: 1, b: undefined, c: { name: 4 }, d: [3, 4, 5], e: null}

上述代码中的new obj.constructor()的意思就是根据遍历到的不同的引用类型,创建相应的实例对象

# 第二版(解决循环引用)

如果源对象的属性直接或者间接的引用了自身,造成循环引用的问题,则在进行深拷贝的过程中会导致爆栈,比如像下面这种情况

const source = {
    name: 'Jane'
}
source.source = source
deepClone(source)  // 报错: Maximum call stack size exceeded

为了解决这种情况可以额外开辟一个存储空间,来存储当前对象和拷贝对象的对应关系,当需要拷贝当前对象时,先去存储空间中找,有没有拷贝过这个对象,如果有的话直接返回,如果没有的话继续拷贝,这样就巧妙化解的循环引用的问题

这个存储空间,需要可以存储key-value形式的数据,且key可以是一个引用类型,我们可以选择Map这种数据结构:

  • 检查map中有无克隆过的对象
  • 有 - 直接返回
  • 没有 - 将当前对象作为key,克隆对象作为value进行存储
  • 继续克隆

这里可以使用WeakMap来代替MapWeakMap是弱引用对象,垃圾回收机制会自动帮我们回收,可以帮助我们减少内存的消耗。

function deepClone(obj, hash = new WeakMap()) {
    if (typeof obj !== 'object') return obj
    if (obj === null) return obj
    if (hash.get(obj)) return hash.get(obj)
    let cloneObj = new obj.constructor()
    hash.set(obj, cloneObj)
    for(let key in obj) {
        if (obj.hasOwnProperty(key)) {
            cloneObj[key] = deepClone(obj[key], hash)
        }
    }
    return cloneObj
}

// 测试
const target = {
  val: 1
}
target.target = target
deepClone(target)
// {
//   val: 1,
//   target: [Circular]
// }

# 版本三(对于不可遍历的引用类型)

对于像是DateRegExp这类不可遍历的引用类型,没有必要再进行深拷贝,可以直接返回

function deepClone(obj, hash = new weakMap()) {
  if (obj === null) return obj; // 如果是null或者undefined我就不进行拷贝操作
  if (obj instanceof Date) return new Date(obj);
  if (obj instanceof RegExp) return new RegExp(obj);
  // 可能是对象或者普通的值  如果是函数的话是不需要深拷贝
  if (typeof obj !== "object") return obj;
  // 是对象的话就要进行深拷贝
  if (hash.get(obj)) return hash.get(obj);
  let cloneObj = new obj.constructor();
  // 找到的是所属类原型上的constructor,而原型上的 constructor指向的是当前类本身
  hash.set(obj, cloneObj);
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      // 实现一个递归拷贝
      cloneObj[key] = deepClone(obj[key], hash);
    }
  }
  return cloneObj;
}

这样一个较为完整的递归版本的深拷贝就算是完成了,其中还有很多细节可以处理,详细请看这篇文章👉如何写出一个惊艳面试官的深拷贝

# (2)JSON.parse(JSON.stringfy( ))

let arr = [1, 3, {username: 'kobe'}]
let arr2 = JSON.parse(JSON.stringfy(arr))
console.log(arr2)   // [1, 3, {username: 'kobe'}]
arr2[2].username = 'Langyixuan'
console.log(arr2)   //  [1, 3, {username: 'langyixuan'}]
console.log(arr)   //  [1, 3, {username: 'kobe'}]

这种方法虽然可以实现数组或对象深拷贝,但不能处理函数和正则,因为这两者基于JSON.stringify和JSON.parse处理后,得到的正则就不再是正则(变为空对象),得到的函数就不再是函数(变为null)了

# (3)使用lodash函数库中_.clondeDeep方法

let _ = require('lodash')
let obj = {
	a: 1,
  b: { f: { g: 1 }},
  c: [1, 2, 3]
}
let obj2 = _.cloneDeep(obj)
console.log(obj.b.f === obj2.b.f)   // false

# (4) jQuery.extend()

const $ = require('jquery');
const obj1 = {
    a: 1,
    b: { f: { g: 1 } },
    c: [1, 2, 3]
};
const obj2 = $.extend(true, {}, obj1);
console.log(obj1.b.f === obj2.b.f); // false

# 总结

浅拷贝只复制指向某个对象的指针,而不复制对象本身,新旧对象还是共享同一块内存。但深拷贝会另外创造一个一模一样的对象,新对象跟原对象不共享内存,修改新对象不会改到原对象

# 参考文章

✨✨如何写出一个惊艳面试官的深拷贝

一篇文章搞定 JavaScript 深浅拷贝

浅拷贝与深拷贝-javaScript