聊聊深拷贝

277 阅读7分钟

为什么会出现深拷贝?

我们都知道,在进行引用数据赋值操作时,都是把数据的内存地址给赋值过去,两个变量都是指向的同一个内存空间

const obj1 = { a: "a", b: "b" };
const obj2 = obj1;
obj2.b = "c";
console.log(obj1); // { a: 'a', b: 'c' }

将对象2的【b】属性改变了,对象1的属性【b】也会跟着改变了

所以为了防止这样的操作,我们通常会复制一份新的数据,然后在进行赋值操作

const obj1 = { a: "a", b: "b" };
const obj2 = { ...obj1 };
obj2.b = "bbb";
console.log(obj1); // { a: 'a', b: 'b' }
console.log(obj2); // { a: 'a', b: 'bbb' }

这时又出现了一个问题,如果需要进行赋值的对象,存在深层嵌套时,修改深层次的属性时,也会影响原对象

const obj1 = { a: "a", b: "b", c: { d: "d" } };
const obj2 = { ...obj1 };
obj2.b = "bbb";
obj2.c.d = "ddd";
console.log(obj1); // { a: 'a', b: 'b', c: { d: 'ddd' } }
console.log(obj2); // { a: 'a', b: 'bbb', c: { d: 'ddd' } }

这时就出现了深拷贝——两个对象的属性、值完全相同,但是两个对应的是两个不同的地址。这样,我们在修改新对象的属性时,不会影响原对象。所在在这种情况下,我们叫之前的拷贝方式为浅拷贝

但是,那么多种写法,我到底应该选择哪种写法?

我认为,在实际开发中,我们应该尽量避免使用手写的深拷贝代码,因为手写的深拷贝代码往往是非常复杂的,而且容易出错。

所以,在实际开发中,我们应该尽量使用第三方库来实现深拷贝,因为第三方库考虑的问题很全面,基本上不会出问题。而且第三方库的性能也会更好。

那接下来我们先来讲讲存在问题的一些深拷贝写法

首先第一个就是先将对象转JSON字符串,然后在字符串传为对象

let obj1 = {
  str: 'aaaaa',
  num: 1111,
  arr: ['bbb', { c: 'ccc', d: 'ddd' }],
  normalObj: {
    e: 'eee'
  },
  fn: function () {},
  unde: undefined,
  nan: NaN,
  nullnull: null,
  date: new Date(),
  symblo: Symbol('key'),
  set: new Set([1, 2, 3]),
  map: new Map([
    ['a', 'b'],
    ['c', 'd']
  ])
}
let obj2 = JSON.parse(JSON.stringify(obj1))
console.log(obj2)
// {
//   str: 'aaaaa',
//   num: 1111,
//   arr: [ 'bbb', { c: 'ccc', d: 'ddd' } ],
//   normalObj: { e: 'eee' },
//   nan: null,
//   nullnull: null,
//   date: '2024-08-05T14:16:34.763Z',
//   set: {},
//   map: {}
// }

这时,你们会发现

1.没有拷贝值为undefined的属性。

2.没有拷贝函数

3.date变成字符串了

4.没有拷贝symbol

原因是JSON在执行字符串化的这个过程时,会先进行一个JSON格式化,会把undefined、function、symbol,这些类型进行过滤。并且set、map这种数据格式的对象,也会变成空字符串

所以说,用这种方法会有问题

接下来第二种,普通递归函数

function deepClone(source) {
  if (typeof source !== 'object' || source == null) {
    return source
  }
  const target = Array.isArray(source) ? [] : {}
  for (const key in source) {
    if (Object.prototype.hasOwnProperty.call(source, key)) {
      if (typeof source[key] === 'object' && source[key] !== null) {
        target[key] = deepClone(source[key])
      } else {
        target[key] = source[key]
      }
    }
  }
  return target
}
let obj1 = {
  str: 'aaaaa',
  num: 1111,
  arr: ['bbb', { c: 'ccc', d: 'ddd' }],
  normalObj: {
    e: 'eee'
  },
  fn: function () {},
  unde: undefined,
  nan: NaN,
  nullnull: null,
  date: new Date(),
  symblo: Symbol('key')
}
const obj2 = deepClone(obj1)
console.log(obj2)
// {
//   str: 'aaaaa',
//   num: 1111,
//   arr: [ 'bbb', { c: 'ccc', d: 'ddd' } ],
//   normalObj: { e: 'eee' },
//   fn: [Function: fn],
//   unde: undefined,
//   nan: NaN,
//   nullnull: null,
//   date: {},
//   symblo: Symbol(key),
// }

这里,说一下这个方法

Object.prototype.hasOwnProperty.call

为什么要用这个方法?这个是为了,检查source对象是否真的含有【key】这个属性。但有人会问,为什么不直接读取属性呢?直接使用【obj[key]】判断是否能读取的到不就行了?

如果使用后者的方式去判断,这时会出现一个问题。

那么在引出这个问题之前,先回顾一下,在我们以前学习JS读取属性时,听过这么一个说法,在读取对象属性时,如果在对象的身上没有该属性,那么JS就会去读取对象的原型链。向上搜寻,去查找在原型链中的对象属性。

这时,如果我们在深拷贝中使用直接读取属性判断原对象是否含有该对象时。就会受到原型链的干扰。如果在obj中没有该属性,但是在obj的原型链上有该属性,那么这个方法读取的结果则为真。这样就会对我们的拷贝结果造成影响。

所以我们要使用【Object.prototype.hasOwnProperty.call】这个方法,去避免原型链的干扰。这是一个JS内置的方法,可以让我们在检查对象属性时,不去检查对象原型链。

但是,跟上述方法,大家常见的,还有一个跟上述方法差不多的一个方法,就是【原对象.hasOwnProperty(key)】这个方法。其实,你们去看MDN文档,会发现,这两种方法的功能都差不多。都是为了在检查对象属性时,避免原型链的干扰。那么我们为什么要用【Object.prototype.hasOwnProperty.call】。而不用【原对象.hasOwnProperty(key)】。而且我们开发讲究高效简洁。为什么要用这么长写法的一个方法。

这里存在这么个原因。

就是JS没有将【hasOwnProperty】作为一个敏感词,所以我们很有可能将对象的一个属性命名为【hasOwnProperty】,这样一来就无法再使用对象原型的【hasOwnProperty】方法来判断属性是否是来自原型链。

举个例子

var fn = {
  hasOwnProperty: function () {
    return false
  },
  a: 'aaaaaa'
}

console.log(fn.hasOwnProperty('a')) // 始终返回 false
// 但我们如果直接使用Object原型链上真正的hasOwnProperty方法,就不会出现这个问题
console.log(Object.prototype.hasOwnProperty.call(fn, 'a')) // true

说完这个方法,回到深拷贝。

普通递归

使用普通递归时,如果存在一些复杂数据类型,比如set,map类型,那么上述方法也无法对它们进行拷贝

{
  str: 'aaaaa',
  num: 1111,
  arr: [ 'bbb', { c: 'ccc', d: 'ddd' } ],
  normalObj: { e: 'eee' },
  fn: [Function: fn],
  unde: undefined,
  nan: NaN,
  nullnull: null,
  date: {},
  symblo: Symbol(key),
  set: {},
  map: {}
}

所以特殊情况下,进行特殊处理

const deepClone = (source, cache = new Map()) => {
  // 检查缓存中是否已存在当前源对象
  if (!cache.has(source)) {
    // 根据源对象的类型执行不同的克隆操作
    if (source instanceof Set) {
      // 创建一个新的集合来存储克隆的值
      const clonedSet = new Set()
      // 遍历源集合中的每个值,递归地克隆它们,并添加到新集合中
      source.forEach(value => clonedSet.add(deepClone(value, cache)))
      // 将克隆后的集合添加到缓存中
      cache.set(source, clonedSet)
    } else if (source instanceof Map) {
      // 创建一个新的 Map 来存储克隆的值
      const clonedMap = new Map()
      // 遍历源 Map 中的每个键值对,递归地克隆值,并设置到新 Map 中
      source.forEach((value, key) => clonedMap.set(key, deepClone(value, cache)))
      // 将克隆后的 Map 添加到缓存中
      cache.set(source, clonedMap)
    } else if (source instanceof Object) {
      // 创建一个新的空对象来存储克隆后的属性
      const clonedObj = {}
      // 遍历源对象的所有属性
      for (const key in source) {
        // 检查属性是否属于对象本身(而非继承自原型)
        if (Object.prototype.hasOwnProperty.call(source, key)) {
          // 递归地克隆属性值,并存储在新对象中
          clonedObj[key] = deepClone(source[key], cache)
        }
      }
      // 将克隆后的对象添加到缓存中
      cache.set(source, clonedObj)
    } else {
      // 对于基本类型(如数字、字符串、布尔值)或 null、undefined,直接返回
      return source
    }
  }

  // 从缓存中返回克隆后的对象或集合
  return cache.get(source)
}
let obj1 = {
  str: 'aaaaa',
  num: 1111,
  arr: ['bbb', { c: 'ccc', d: 'ddd' }],
  normalObj: {
    e: 'eee'
  },
  fn: function () {},
  unde: undefined,
  nan: NaN,
  nullnull: null,
  date: new Date(),
  symblo: Symbol('key'),
  set: new Set([1, 2, 3]),
  map: new Map([
    ['a', 'b'],
    ['c', 'd']
  ])
}
const obj2 = deepClone(obj1)
console.log(obj2)
// {
//   str: 'aaaaa',
//   num: 1111,
//   arr: { '0': 'bbb', '1': { c: 'ccc', d: 'ddd' } },
//   normalObj: { e: 'eee' },
//   fn: {},
//   unde: undefined,
//   nan: NaN,
//   nullnull: null,
//   date: {},
//   symblo: Symbol(key),
//   set: Set(3) { 1, 2, 3 },
//   map: Map(2) { 'a' => 'b', 'c' => 'd' }
// }

总结

上述的几种方式中,仅供大家练习参考,如果大家在开发中遇到深克隆需求时,建议直接使用第三方库。