JS:手写实现深拷贝,这次彻底搞懂

2022-06-30  本文已影响0人  limengzhe

深拷贝和浅拷贝的区别

在 JS 中,数据类型 分为原始类型和引用类型,我们平常所说的深、浅拷贝都是针对引用类型而言,因为不管是深拷贝还是浅拷贝,在遇到原始类型的时候,都会直接拷贝它们的值,并无区别。

深拷贝和浅拷贝都是创建一个新的对象。区别是

原生 JS 可以很简单实现浅拷贝,比如使用 Object.assign():

let source = { obj: { bio: 'I am an object' }, str: 'Hello' }
let target = Object.assign({}, source)

console.log(target) // { obj: { bio: "I am an object" }, str: "Hello" }

source.obj.bio = 'I am I am'
source.str = 'Hi'

console.log(source) // { obj: { bio: "I am I am" }, str: "Hi" }
console.log(target) // { obj: { bio: "I am I am" }, str: "Hello" }

我们看到,source 对象中 obj 是一个对象,对象是引用类型。当改变 objbio 时,sourcetarget 两者都会改变,这就是浅拷贝。str 是一个字符串,是原始类型,会直接拷贝值,修改原对象不会影响新对象,所以只有 source 中的发生了改变。

而想要实现一个两者互不影响的深拷贝则没有这么简单。在实际项目中,我们可能都是直接使用下面这个方法:

const a = JSON.parse(JSON.stringify(b))

这种方法在大部分情况下虽然简单可行,但面对一些特殊对象,比如对象中包含函数、循环引用等,继续使用这个方法就行不通了。为了彻底搞懂深拷贝,我们不妨自己手动实现,在过程中逐渐理解。

第一步:实现拷贝

首先我们创建一个函数,在函数内部判断该对象是否属于引用类型 object,如果是,创建一个全新的对象,通过遍历将对象内的属性和值全部存入到该对象,最后将其返回。反之则说明是原始类型,原始类型不存在深拷贝概念,直接返回该原始值即可。

function deepClone(source) {
  if (typeof source === 'object') {
    let target = {}
    for (const key in source) {
      target[key] = source[key]
    }
    return target
  } else {
    return source
  }
}

第二步:处理多层对象

我们知道,对象中也是可以无限包含对象的。在上一步骤,我们已经有判断类型的过程,所以在这一步骤中递归调用该函数即可,直至遇到原始类型。

function deepClone(source) {
  if (typeof source === 'object') {
    let target = {}
    for (const key in source) {
      target[key] = deepClone(source[key]) // +
    }
    return target
  } else {
    return source
  }
}

第三步:处理 Array

在上面的步骤中,我们给需要返回的 target 默认设置的是对象 {} 形式,很显然没有考虑数组的情况。这里我们只需要在设置 target 的时候判断一下 source 是否为数组即可。

function deepClone(source) {
  if (typeof source === 'object') {
    let target = Array.isArray(source) ? [] : {} // +
    for (const key in source) {
      target[key] = deepClone(source[key])
    }
    return target
  } else {
    return source
  }
}

第四步:处理 null、正则、日期

我们知道,虽然 null数据类型中属于原始类型,但 typeof null 却输出为 object。而正则、日期等也会输出 object。此三者是不能被遍历的,所以在遍历对象和数组之前需要先处理一下 null、正则、日期,防止进入遍历。

function deepClone(source) {
  // 处理 null
  if (source === null) return source // +
  // 处理正则
  if (source instanceof RegExp) return new RegExp(source) // +
  // 处理日期
  if (source instanceof Date) return new Date(source) // +
  // 处理对象和数组
  if (typeof source === 'object') {
    let target = Array.isArray(source) ? [] : {}
    for (const key in source) {
      target[key] = deepClone(source[key])
    }
    return target
  }

  // 处理原始类型
  return source
}

第五步:处理 Symbol

Symbol 虽然属于原始类型,但同时它拥有一个特性,就是每个从 Symbol() 返回的 symbol 值都是唯一的。如果按照上面的步骤直接返回同一个 Symbol,那显然就违背了该特性。此时我们生成一个相同描述的 Symbol 即可。

function deepClone(source) {
  // 处理 null
  if (source === null) return source

  // 处理正则
  if (source instanceof RegExp) return new RegExp(source)

  // 处理日期
  if (source instanceof Date) return new Date(source)

  // 处理 Symbol
  if (typeof source === 'symbol') return Symbol(source.description) // +

  // 处理对象和数组
  if (typeof source === 'object') {
    let target = Array.isArray(source) ? [] : {}
    for (const key in source) {
      target[key] = deepClone(source[key])
    }
    return target
  }

  // 处理原始类型
  return source
}

第六步:处理 Map 和 Set

在上面的步骤中,我们使用花括号 {} 收集了对象,但是如果用同样的方式收集 Map 和 Set,则失去了拷贝的准确性。为此,我们需要单独对它们进行处理。

function deepClone(source) {
  // 处理 null
  if (source === null) return source

  // 处理正则
  if (source instanceof RegExp) return new RegExp(source)

  // 处理日期
  if (source instanceof Date) return new Date(source)

  // 处理 Symbol
  if (typeof source === 'symbol') return Symbol(source.description)

  // 处理 Map
  if (Object.prototype.toString.call(source) === '[object Map]') {
    let target = new Map()
    source.forEach((value, key) => {
      target.set(key, deepClone(value))
    })
    return target
  }

  // 处理 Set
  if (Object.prototype.toString.call(source) === '[object Set]') {
    let starget = new Set()
    source.forEach(value => {
      target.add(deepClone(value))
    })
    return target
  }

  // 处理对象和数组
  if (typeof source === 'object') {
    let target = Array.isArray(source) ? [] : {}
    for (const key in source) {
      target[key] = deepClone(source[key])
    }
    return target
  }

  // 处理原始类型
  return source
}

第七步:处理循环引用

此时我们对类型的处理已经结束,还有一些其他的类型没有考虑,比如 DOM 元素,这里不再赘述。

在下面的代码中,source 对象的 obj 属性,引用了其本身。这个时候还能进行深拷贝吗?

let source = {
  str: 'hello',
  arr: [1, 2, 3],
}

source.obj = source

let target = deepClone(source)

调用 deepClone 方法,发现控制台抛出了以下错误:

RangeError: Maximum call stack size exceeded

这是因为 deepClone 处理对象时递归调用了 deepClone 方法,而 source 也在引用自身,所以会无限递归下去,从而造成了内存溢出问题。

为了防止无限递归,我们就要先判断有没有循环引用,如果出现循环引用,则直接返回,不再递归,避免出现内存溢出。所以我们需要额外的存储空间进行记录。当需要拷贝自身时,去这个存储空间查找是否已经存在该对象即可。

WeakMap 正好可以满足这种需求。

function deepClone(source, map = new WeakMap()) {
  // 处理 null
  if (source === null) return source

  // 处理正则
  if (source instanceof RegExp) return new RegExp(source)

  // 处理日期
  if (source instanceof Date) return new Date(source)

  // 处理 Symbol
  if (typeof source === 'symbol') return Symbol(source.description)

  // 处理原始类型
  if (typeof source !== 'object') return source

  // 创建 target 实例
  let target = new source.constructor()

  // 处理循环引用
  if (map.get(source)) {
    return source
  } else {
    map.set(source, target)
  }

  // 处理 Map
  if (Object.prototype.toString.call(source) === '[object Map]') {
    source.forEach((value, key) => {
      target.set(key, deepClone(value))
    })
    return target
  }

  // 处理 Set
  if (Object.prototype.toString.call(source) === '[object Set]') {
    source.forEach(value => {
      target.add(deepClone(value))
    })
    return target
  }

  // 处理对象和数组
  if (typeof source === 'object') {
    for (const key in source) {
      target[key] = deepClone(source[key])
    }
    return target
  }
}

之前的代码在处理 Map、Set、对象和数组时分别创建了实例,比较麻烦,所以改用了 Object 原型上的 constructor 构造函数。调用该函数会自动创建实例对象。

第八步:优化

我们在处理非 object 类型之后,一共处理了四个可遍历的类型。但此代码存在两个问题,一是仍然有可遍历的类型,二是并不是所有 object 类型都有可执行的 constructor 构造函数。所以我们需要对此进行优化处理。

// 可遍历对象
// 如果想处理其他的可遍历对象,比如函数的 arguments,可加入此数组,便于维护
const iterations = [
  '[object Object]',
  '[object Array]',
  '[object Map]',
  '[object Set]',
]

function deepClone(source, map = new WeakMap()) {
  // 处理 null
  if (source === null) return source

  // 获取对象类型
  const type = Object.prototype.toString.call(source)

  // 处理不可遍历对象
  if (!iterations.includes(type)) {
    // 处理日期
    if (type === '[object Date]') return new Date(source)

    // 处理正则
    if (type === '[object RegExp]') return new RegExp(source)

    // 处理 Symbol
    if (type === '[object Symbol]') return Symbol(source.description)

    // 其他未处理的类型,一般是原始类型或函数,直接返回
    return source
  }

  // 处理可遍历对象
  // 创建 target 实例
  let target = new source.constructor() // {} | [] | Map(0) | Set(0)

  // 处理循环引用,防止死循环
  if (map.get(source)) {
    return source // 如果已经处理过,则直接返回,不再遍历
  } else {
    map.set(source, target)
  }

  // 处理 Map
  if (type === '[object Map]') {
    source.forEach((value, key) => {
      target.set(key, deepClone(value))
    })
    return target
  }

  // 处理 Set
  if (type === '[object Set]') {
    source.forEach(value => {
      target.add(deepClone(value))
    })
    return target
  }

  // 处理对象和数组
  for (const key in source) {
    target[key] = deepClone(source[key])
  }
  return target
}

总结

到这里,我们实现的 deepClone 方法已经能够应对大部分情况了。但是仍然存在许多不足,尤其是对类型的判断和性能问题,还有很大的改进空间。但我们的目的在于——了解深拷贝的作用、原理以及对类型的更深入了解等方面。在实际项目中,更推荐使用成熟的开源库,比如 lodash 等。


参考资料:

上一篇下一篇

猜你喜欢

热点阅读