阅读 1359

彻底理解并实现深拷贝(循环引用)

前言

当我们需要操作一个对象或者数组的副本又不想对原来的数据产生影响的时候就会使用到深拷贝

深拷贝函数式编程中很为常见,因为函数式编程中提倡我们使用纯函数(相同的输入永远得到相同的输出),那么就需要对传递的应用类型的数据做深拷贝,然后操作深拷贝得到的数据。

类似的在 jQuery 中,我们使用 $.extend() 为一个普通对象扩展属性方法时默认是浅拷贝,我们也可以手动指定其为深拷贝。

可以看到深拷贝在开发是很常见的,所以我们必须掌握如何对一个源对象进行深拷贝,并且考虑每一个环节出现的问题,例如拷贝具有循环引用的对象

实现深拷贝的方法

javascript 中实现深拷贝的方法有两种:

  • JSON.parse()JSON.stringify()
  • 递归算法

下面会对这两种方法进行阐述和对比。

JSON.parse()JSON.stringify()

可以通过 JSON.stringify() 可以将值转换为响应的 JSON 格式,JSON.parse() 可以将 JSON 字符串解析的特性实现对象的深拷贝。代码演示如下:

const person = {
   name: 'seven',
   info: {
      age: 20
   }
}

const person1 = JSON.parse(JSON.stringify(person))
person.info.age = 18
person1.name = 'juejin'
console.log(person) // { name: 'seven', info: { age: 18 } }
console.log(person1) // { name: 'juejin', info: { age: 20 } }
复制代码

可以看到拷贝得到的 person1 与源对象 person 没有任何关系,它们修改属性值的时候都不会对彼此产生影响,所以这就实现了深拷贝。

存在的问题

这种做法的深拷贝实现起来轻而易举,但是却存在着很多的问题,探究问题前必须搞清楚 JSON.stringify() 有哪些弊端:

  • undefined任意的函数以及 symbol 值,在序列化过程中会被忽略(出现在非数组对象的属性值中时)或者被转换成 null(出现在数组中时)。函数undefined 被单独转换时,会返回 undefined,如 JSON.stringify(function(){}) 或者 JSON.stringify(undefined)
  • 对包含循环引用的对象(对象之间相互引用,形成无限循环)执行此方法,会抛出错误
  • ......

更多关于 JSON.stringify() 将值转换为相应的 JSON 格式的规则可以翻阅 MDN 文档

上面罗列出来的两点是最为重要的也是实际中最为常见的情形,其实很好解释这两种情况的产生原因:

  • JSON 的本质是一种轻量级的数据交换格式,被用作前后端交互时的传输数据,所以需要其他高级语言例如 javapython 等都要可以解析,所以对于 javascript 中的 undefined、函数、symbol 类型的数据,其他语言无法解析,所以就会引起错误。
  • 对于有循环引用的对象来说,它的嵌套层级是无限深的,所以序列化的时候会进入死循环或者死递归

递归实现

我们已经对 JSON.parse()JSON.stringify() 方法实现深拷贝讲解完毕了,可以看到它的缺点还是很多的,但是如果你确保 “安全” 也是可以直接使用的,毕竟只需要一行代码就可以实现了。

之后我们再来探讨一下递归方法实现的深拷贝,分析一下我们需要怎么做:

  • 对于基本数据类型的属性值来说,直接拷贝赋值就可以了。
  • 对于复杂数据类型的属性值来说,直接拷贝赋值会拿到值的引用,没有实现深拷贝;所以我们要对复杂类型的对象递归取值。

我们先来实现一版简易版的深拷贝代码:

const deepClone = (obj) => {
   // 定义递归的出口,如果源对象不是对象就返回 {}
   if (!obj || typeof obj !== 'object') {
      return {}
   }
   // 新的拷贝的数据,考虑数组和对象
   const newObj = Array.isArray(obj) ? [] : {}
   // 循环编译数组或对象的下标或者属性
   for (const key in obj) {
      const value = obj[key]
      // 如果属性值是对象那么递归进行深拷贝,否则直接赋值
      newObj[key] = typeof value === 'object' ? deepClone(value) : value
   }
   return newObj
}

const person = {
   name: 'seven',
   info: {
      age: 20
   }
}

const person1 = deepClone(person)
person1.name = 'juejin'
person.info.age = 18
console.log(person) // { name: 'seven', info: { age: 18 } }
console.log(person1) // { name: 'juejin', info: { age: 20 } }
复制代码

相信上面的代码还是很简单实现的,可以看到也同样可以实现对象的深拷贝。

存在的问题

同样我们来分析一下上面的代码存在的问题,对于 JSON.parse()JSON.stringify() 方法不能实现的 undefined、函数、symbol 值的拷贝,该递归方法都可以实现。

对于循环引用的对象来说,我们可以做出尝试:

const seven = {
   name: 'seven'
}
const juejin = {
   name: 'juejin',
   relative: seven
}
seven.relative = juejin
const newObj = deepClone(seven)
console.log(newObj)
复制代码

代码运行后直接报错:Maximum call stack size exceeded,调用栈溢出,因为我们的需要拷贝的源对象存在循环引用,所以deepClone 函数会不断入栈,最后栈溢出。

解决这一循环引用的问题其实很简单,我们只需要在每次对复杂数据类型进行深拷贝前保存其值,如果下次又出现了该值,就不再进行拷贝,直接截止。

这里我们选用 ES6 中的 WeakMap 或者 Map 数据结构来存储每一次的复杂类型的值,我们也要对原来的 deepClone 函数内部的逻辑封装到内部的另外一个函数内,目的是为了在内部函数外部我们定义映射,形成闭包:

对于 WeakMapMap 的区别你可以查阅文档。

const deepClone = (obj) => {
   // 定义一个映射,初始化的时候将 obj 本身加入映射中
   const map = new WeakMap()
   map.set(obj, true)
   // 封装原来的递归逻辑
   const copy = (obj) => {
      if (!obj || typeof obj !== 'object') {
         return {}
      }
      const newObj = Array.isArray(obj) ? [] : {}
      for (const key in obj) {
         const value = obj[key]
         // 如果拷贝的是简单类型的值直接进行赋值
         if (typeof value !== 'object') {
            newObj[key] = value
         } else {
         	// 如果拷贝的是复杂数据类型第一次拷贝后存入 map
            // 第二次再次遇到该值时直接赋值为 null,结束递归
            if (map.has(value)) {
               newObj[key] = null
            } else {
               map.set(value, true)
               newObj[key] = copy(value)
            }
         }
      }
      return newObj
   }
   return copy(obj)
}

// test
const seven = {
   name: 'seven'
}
const juejin = {
   name: 'juejin',
   relative: seven
}
seven.relative = juejin
const newObj = deepClone(seven)
console.log(newObj)
// { name: 'seven', relative: { name: 'juejin', relative: null } }
复制代码

可以看到这样同样可以实现深拷贝并且解决了之前的循环引用导致栈溢出的异常。

文章分类
前端
文章标签