十分钟带你手撕一份"渐进式"JS深拷贝

1,357 阅读12分钟

写在前边

作为前端面中老生长谈的深拷贝,我相信许多前端开发者对它嗤之以鼻。

"21世纪了还在讲这种老掉牙的知识?!"

各位大佬别着急拔刀😅,文章中站在一个合格的面试官角度来谈谈一个基本合格的深拷贝需要考虑哪些边界情况:

  • 拷贝的日期格式处理。
  • 拷贝中的正则对象处理。
  • 拷贝中的循环对象引用。
  • 拷贝中的相同引用对象处理。
  • 拷贝中不能丢失原本对象原型。
  • 拷贝中原本对象的属性修饰符。

一个成熟的深拷贝最基本的实现一定是需要囊括上边六点,看到这里各位可以想一想脑海中的深拷贝是不是覆盖到了所有的点。

毕竟每一件看起来简单的事情其实背后都藏着很多值得我们反复思考的地方嘛。

浅拷贝

其实我并不打算对浅拷贝进行过于篇幅介绍的,js中可以所有关于拷贝的api都是在浅拷贝。

  • Object.assign
  • Es6扩展运算符
  • Array.prototype.slice.call/concat
  • ...

浅拷贝的方法太多了,这部分api不清楚的同学需要加油补充自己的基础知识了。

深拷贝

让我们先忘记文章中开头提到我们需要实现的点,先从最最最容易的方式我们来一步一步做改进。

JSON.stringify

提到JSON.stringify大家都很熟悉,它可以讲javascript值转成字符串从而可以非常简单的让我实现深拷贝:

image.png

看上去一切都那么美好是吧,它的原理也很简单: 先讲对象序列化成为json字符串,之后在通过json.parse将字符串转化成为object

JSON.stringify存在的问题

我们使用JSON.stringify来转化一个稍微复杂一点的对象:

image.png

我们可以发现原始obj对象在经过JSON系列api转化后,eatkeySymbol['name']这两个属性丢失了,同时NaN转换成为了null,正则的value变成了{}datevalue原本是date类型...这里变成了string类型。

同时我们注意到原始obj对象上的属性children1children2引用的是相同的对象,克隆前他们指向同一个引用对象地址。但是克隆后的cloneObjcloneObj.children1 === cloneObj.children2返回值是false,针对相同引用JSON.stringify是无法实现克隆后保持一致的。

看来JSON api用在深拷贝上真的是漏洞百出呀

表现问题

我们来稍微总结一下目前JSON.stringify用在深拷贝上存在的问题:

  • 拷贝后的Date类型会变成字符串string
  • 拷贝后的RegExp类型会变成空对象。
  • 拷贝对象中含valueNaN的值会变为null
  • 拷贝后的对象会丢失含有Symbol类型的属性。
  • 拷贝后的对象会丢失valueundefined的属性。
  • 拷贝后对象中的相同引用会变成完全两个不同的引用,只是看上去相同罢了。

其实同时变为null的还会有Infinity-Infinity,平常我们很少用到。有兴趣的童鞋可以自己去试一下。

从表现上来说目前JSON.stringify实现深拷贝目前存在的问题我们已经总结了绝大部分。

此时让我们切入深层次的思考点,所谓深层次就需要你回想一下文章中开头讲到的6点中的内容。

深层问题

本质上JSON.stringify实现的是一个将对象转化为json字符串之后在通过JSON.parse将字符串转化为一个全新的对象。

在这个过程中我们需要思考的是,JSON.stringfiy的过程会存在额外两个问题:

  • 原始对象的继承关系不会被继承
  • 原始对象的属性描述符丢失

在字符串重新转化对象时,JSON.stringify重新生成的对象会丢失原始对象的继承关系和属性描述符,这显然和我们实现深拷贝时的初衷是相反的。

循环引用问题

接下来我们谈谈所谓的循环引用问题,可能有一部分同学在实现深拷贝时很少会考虑到对象的循环引用问题。

我们先来用一个简单的例子来看一下所谓的循环引用:

image.png

所谓的循环引用简单来说就是对象中存在某个属性,这个属性指向了对象中已经存在的对象。

此时当我们使用Json.stringify来试试克隆这个obj对象会发生什么:

image.png

针对引用类型的调用,JSON.stringify会直接抛出错误,无法转换一个循环引用的对象。

从一个简易版深拷贝过度

我们先从实现一个简易版的深拷贝来看看所谓深拷贝的实现思路。

我相信大部分同学对于所谓简易版的深拷贝实现一定是信手拈来:

// 简易版深拷贝
const isReferenceType = (value) => typeof value === 'object' && value !== null;

    function cloneDeep(obj) {
      if (!isReferenceType(obj)) {
        return obj
      }
      const cloneObj = Array.isArray(obj) ? [] : {}
      Object.keys(obj).forEach(key => {
        const value = obj[key]
        // 如果深层依然是对象 递归调用
        const cloneValue = isReferenceType(value) ? cloneDeep(value) : value
        cloneObj[key] = cloneValue
      })
      return cloneObj
    }

    const object1 = {
      name: '19Qingfeng',
      yellow: false,
      release: {
        custom: true,
        github: '19Qingfeng'
      }
    }

    const cloneValue = cloneDeep(object1)
    console.log(cloneValue, '克隆后的对象')
    console.log(cloneValue === object1)

上边是一个最基础版本的深拷贝实现,我相信也是大多数人所谓的深拷贝实现。

但是在我们提到了上边已经成熟深拷贝应该考虑到的问题来出发的话,其实他和JSON.stingify是一样的简陋。

利用tyoe of判断是否是引用类型从而使用Object.keys方法迭代递归调用进行实现深拷贝。

如果平常你的深拷贝实现和这个方法差距不是很大的话,我希望在这个时候你停下下滑,思考一下咱们上边提到过需要实现的点。你会发现他仍然无法解决我提到的那些"问题",尝试一下对于上边提到的点你是否已经有对于问题的解决方法。

从"问题"出发实现深拷贝

让我们从问题出发先来一个一个梳理要解决文章最开始提出的问题可以使用哪些方案:

日期/正则格式处理

  • 拷贝的日期格式处理。
  • 拷贝中的正则对象处理。

针对数据格式中的日期和正则对象的处理,我们可以通过额外判断传入的value是否是日期/正则类型。

如果是,那么就直接new一个新的对应类型返回,判断是否是具体某个正则/日期类型我们可以基于原型对象上的constructor属性判断:

image.png

这里因为我们创建正则/日期对象时都是基于父类去new父类的构造函数,所以我们可以通过js中继承的关系去父类的原型对象prototype上的构造函数constructor来判断是否是对应类型。

当然你也可以通过Object.prototype.toString.call的结果来判断。

丢失原型/属性修饰符

  • 拷贝中Object.keys无法遍历keysymbol类型
  • 拷贝中不能丢失原本对象原型。
  • 拷贝中原本对象的属性修饰符。

针对这两个问题我们看下这几个js的基础api

Reflect.ownKeys()

关于Reflect你可以在这里查看他的官方简介

我们之所以使用Reflect.ownKeys()替代Object.keys()Reflect.ownKeys()相比起来存在以下优点:

  • 它支持遍历对象上的不可枚举enumerable:false属性,而Object.keys()不可。
  • 它支持遍历对象上的Symbol类型,而Object.keys()不可。
  • 同样他和Object.keys()仅会遍历自身的属性,而不会返回原型上的属性。

Object.getPrototypeOf()

Object.getPrototypeOf()  方法返回指定对象的原型(内部[[Prototype]]属性的值)。

我们可以通过它获得对象原本的原型对象,从而结合Object.create方法轻松实现对应的,轻松实现深拷贝中的继承关系。

Object.getOwnPropertyDescriptors()

Object.getOwnPropertyDescriptors()  方法返回指定对象上一个自有属性对应的属性描述符。(自有属性指的是直接赋予该对象的属性,不需要从原型链上进行查找的属性)。

我们来看看这个api的返回值吧,注意区分他和Object.getOwnPropertyDescriptors()

image.png

Object.create(proto,[propertiesObject])

Object.create支持传入两个参数从而返回一个全新的对象。

第一个参数支持传入一个对象并且将这个对象作为新创建对象的__proto__的指向,也就是新创建对象的原型对象。

第二个参数支持传入一个对象,这个对象的属性类型参照Object.defineProperties()的第二个参数。如果该参数被指定且不为 undefined,该传入对象的自有可枚举属性(即其自身定义的属性,而不是其原型链上的枚举属性)将为新创建的对象添加指定的属性值和对应的属性描述符

前边讲到我们已经可以通过:

  • getPrototypeOf()获取原始对象的原型对象。
  • getOwnPropertyDescriptors()获取原始对象的所有属性的属性描述符。

我们只要通过object.create方法将这两个方法的返回值就可以实现继承与属性符的深拷贝效果了。

解决循环引用问题

我们可以在deepClone方法中额外保存一个变量,他是一个hash表用来保存我们拷贝过程中递归的每一个对象。

从而下次在碰到相同的引用地址对象时,直接从保存的hash表中取出相同的引用地址进行赋值就可以了而不需要再次递归相同的object

这样就可以避免循环引用引发的爆栈,同时也可以解决相同引用的问题。

但是这里有一个应该注意的小tip,在js中我们通常用于object进行存储对应的key,value结构。但是这里我们需要存储的key需要是旧的引用对象,它是一个对象。

不难想到ES6中支持Map结构是我们最佳的选择,可是此时需要考虑的一个问题就是针对Map的引用类型其实是会造成引用计数的。我们想要的效果是这个hash对象中最好不要造成引用计算影响垃圾回收机制,当我们把保存对象消除时hash中的引用的值也会被清除掉。

此时结合来看,这个hash对象最佳的选择一定是使用一个WeakMap对象进行存储。

关于WeakMap你可以在这里查看到它的介绍

相比之下,原生的 WeakMap 持有的是每个键对象的“弱引用”,这意味着在没有其他引用存在时垃圾回收能正确进行。原生 WeakMap 的结构是特殊且有效的,其用于映射的 key 只有在其没有被回收时才是有效的。

如果单纯使用map在拷贝大量数据的循环/相同引用下非常容易出现内存泄露导致不必要的性能丢失。

讲了那么多原理,我们来看看最终版的的实现吧:

最终版深拷贝

/*
    实现深拷贝
    1。 判断循环引用
    2. 判断正则对象
    3. 判断日期对象
    4. 属性对象直接进行递归拷贝
    5. 考虑拷贝时不能丢失原本对象的原型继承关系
    6. 考虑拷贝时的属性修饰符
  */
function cloneDeep(value, map = new WeakMap()) {
  if (value.constructor === Date) {
    return new Date(value)
  }
  if (value.constructor === RegExp) {
    return new RegExp(value)
  }
  // 如果value是普通类型 直接返回
  if (typeof value !== 'object' || value === null) {
    return value
  }
  // 考虑对象的原型 获得原本对象的原型 创建一个新的对象继承这个对象的原型
  const prototype = Object.getPrototypeOf(value)
  // 考虑拷贝时不能 丢失对原有对象的属性描述符 
  const description = Object.getOwnPropertyDescriptors(value)
  // 创建新的空对象 同时继承原有对象原型 同时拥有对应的描述符
  const object = Object.create(prototype, description)
  // 遍历对象的属性 进行拷贝 Reflect.ownKeys 遍历获取自身的不可枚举以及key为Symbol的属性
  map.set(value, object)
  Reflect.ownKeys(value).forEach(key => {
    // key是普通类型
    if (typeof key !== object || key === null) {
      // 直接覆盖
      object[key] = value[key]
    } else {
      //  解决循环引用的关键是 每一个对象都给他存放在weakMap中 因为WeakMap是一个弱引用
      //  每次如果进入是对象 那么就把这个对象 优先存放在weakmap中 之后如果还有引用这个对象的地方 直接从weakmap中拿出来 而不需要再进行遍历造成爆栈
      //  同理,如果使用相同引用为了保证同一份引用地址的话 可以使用weakMap中直接拿出保证同一份引用
      //  这里判断之前是否存在相同的引用 如果存在相同的引用直接返回引用即可
      const mapValue = map.get(value)
      mapValue ? (object[key] = map.get(value)) : (object[key] = cloneDeep(value[key]))
    }
  })
  return object
}

代码中存在了每一个步骤的详细注释,其实实现一个深拷贝本身并不是很难,只是有很多边界情况需要我们注意。

接下来让我们写一段Demo来验证下方法的结果吧:

let obj = {
  age: 23,
  name: '19Qingfeng',
  boolean: true,
  empty: undefined,
  nul: null,
  customObj: { name: '19Qingfeng', github:'https://github.com/19Qingfeng' },
  customArr: [0, 1, 2],
  customFn: () => console.log('19Qingfeng'),
  date: new Date(100),
  reg: new RegExp('/19Qingfeng/ig'),
  [Symbol('hello')]: 'Welcome follow my github!',
};

// 定义不可枚举属性
Object.defineProperty(obj, 'innumerable', {
  enumerable: false, value: '不可枚举属性'
}
);
obj = Object.create(obj, Object.getOwnPropertyDescriptors(obj))
obj.loop = obj    // 设置loop成循环引用的属性
let cloneObj = cloneDeep(obj)
cloneObj.customArr.push(4)


console.log(cloneDeep(obj))

console.log(cloneDeep(obj) === obj)
// 注意不可以枚举属性是无法被打印显示出来的 我们可以通过Reflect.ownKeys进行验证
Reflect.ownKeys(cloneDeep(obj)).forEach(key => console.log(key))

image.png

大功告成,此时一个基础的深拷贝已经完整实现了!

写在最后

其实实现深拷贝的写法有很多种,但是原理上是大同小异的。

这里只是提供给大家思路,真正的深拷贝要兼容太多边界情况。比如我们如果拷贝的Map/Set类型文章中的代码就没有兼容到,但是针对学习来说我认为最终版的深拷贝已经足够应付面试官想考察你的知识体系了思考广度了。

大家有兴趣可以私下完善这份代码,不要再被问及深拷贝只是告诉别人你仅仅知道lodash中的cloneDeep方法了~