美文网首页
JS:手写实现深拷贝,这次彻底搞懂

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

作者: limengzhe | 来源:发表于2022-06-30 21:27 被阅读0次

    深拷贝和浅拷贝的区别

    在 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 等。


    参考资料:

    相关文章

      网友评论

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

          本文链接:https://www.haomeiwen.com/subject/bclqbrtx.html