Javascript深拷贝

1,364 阅读8分钟

深拷贝

说起深拷贝,常见的一个深拷贝方法就是JSON.parse(JSON.stringify(object))。但这个方法有一些缺陷,导致一些特殊场景下,这个方法无法满足需求。例如,拷贝函数成员、循环引用。

这个问题的本质其实是JSON字符串不支持函数成员与循环引用。

const obj = { fn() {} }

console.log(JSON.stringify(obj)) // 这里的结果是一个空对象{}
const obj = {}
obj.self = obj;

JSON.stringify(obj) // TypeError: Converting circular structure to JSON

下面,我们渐进式的写一个较完善的深拷贝。

1. 从浅拷贝开始

function Test() {
  console.assert(null === clone(null), 'null')
  console.assert(undefined === clone(undefined))
  console.assert(1 === clone(1), 'number')
  console.assert(true === clone(true), 'boolean')
  console.assert('1' === clone('1'), 'string')
  const s = Symbol()
  console.assert(s === clone(s))
  

  const a = {
    foo: 'bar'
  }

  const b = {
    name: 'hello'
  }

  const obj = {
    a,
    b,
  }

  const objClone = clone(obj)
  console.assert(obj !== objClone, '对象拷贝后引用不相同')
  console.assert(obj.a === objClone.a, '浅拷贝后内部对象引用相同')
  console.assert(obj.b === objClone.b, '浅拷贝后内部对象引用相同')
}

Test()

function clone(target) {
  if(target === null || typeof target !== 'object') {
    return target
  }

  const newObject = {}
  for(const key in target) {
    newObject[key] = target[key]
  }
  return newObject;
}

思路很简单,检查一下要克隆的值是不是一个对象,如果不是,直接返回;如果是一个对象,则通过for-in遍历属性,然后浅拷贝这些属性。

上面代码唯一要注意的一点就是,因为typeof null的值是'object',所以我们需要显示的处理target === null的情况;而对于undefined来说,typeof undefined的值是'undefined',就不需要特殊处理了。

当然,这里更简单的写法是if(!target || typeof target !== 'object'),上面是为了记录一下typeof null的特殊之处。

上面的代码有不少问题,我们一点一点解决。

1.1 问题1:到底拷贝哪些属性?使用for-in真的合适吗?

这个问题没有一个标准的答案,这个要看具体的需求。就拿for-in来说,它会遍历自身以及原型链上所有的可枚举的string类型的key

如果这正是你需要的,那它就是合适的。但在这里,我们想要构造两个一摸一样的对象,甚至原型链也一摸一样,就不能把原型上的属性变成新对象的自有属性。

这里,我们不再使用for-in,而是使用Reflect.ownKeys();这样,就可以获取对象的所有自由属性,包括不可枚举的属性和keysymbol类型的属性。

关于属性的遍历,可以看我的另一篇文章Javascript属性遍历与可迭代对象 - 掘金 (juejin.cn)

function clone(target) {
  if(target === null || typeof target !== 'object') {
    return target
  }

  const newObject = {}

  // 不使用for-in
  for(const key of Reflect.ownKeys(target)) {
    newObject[key] = target[key]
  }
  return newObject;
}

还有一个刚才提到的问题,现在我们不遍历原型链,那怎么浅拷贝原型链上的属性?换个思路嘛,直接拷贝原型链。

1.2 浅拷贝原型链

function clone(target) {
  if(target === null || typeof target !== 'object') {
    return target
  }

  // 浅拷贝原型链
  const newObject = Object.create(Object.getPrototypeOf(target))

  for(const key of Reflect.ownKeys(target)) {
    newObject[key] = target[key]
  }
  return newObject;
}

最后我们改一下测试用例,已经完美通过了:

const proto = {
  prop: 'value'
}

const a = {
  foo: 'bar'
}

const b = {
  name: 'hello'
}

const obj = Object.create(proto, {
  a,
  b,
})


const objClone = clone(obj)
console.assert(obj !== objClone, '对象拷贝后引用不相同')
console.assert(obj.a === objClone.a, '浅拷贝后内部对象引用相同')
console.assert(obj.b === objClone.b, '浅拷贝后内部对象引用相同')

console.assert(obj.prop === objClone.prop, '浅拷贝原型链')
console.assert(Object.getPrototypeOf(obj) === Object.getPrototypeOf(objClone), '浅拷贝原型链')

2. 深拷贝

2.1 简单深拷贝

现在改一下我们的测试用例:

const a = {
  foo: 'bar'
}

const b = {
  name: 'hello'
}

const obj = {
  a,
  b,
}


const objClone = clone(obj)
console.assert(obj !== objClone, '对象拷贝后引用不相同')
// 下面两句做了修改
console.assert(obj.a !== objClone.a, '浅拷贝后内部对象引用不相同')
console.assert(obj.b !== objClone.b, '浅拷贝后内部对象引用不相同')

要实现这样简单的深拷贝,其实只需要对上面的浅拷贝做一点点修改:

function clone(target) {
  if(target === null || typeof target !== 'object') {
    return target
  }

  const newObject = Object.create(Object.getPrototypeOf(target))

  for(const key of Reflect.ownKeys(target)) {
    // newObject[key] = target[key]
    newObject[key] = clone(target[key])
  }
  return newObject;
}

浅拷贝对象其实只拷贝了一层,但是,一旦递归的调用浅拷贝,就会一层一层的浅拷贝下去,最终就实现了深拷贝。

但是,如果尝试用这个函数拷贝一个数组,那问题就出来了:

const array = [1, 2, 3]
const arrayClone = clone(array)
console.log(array) // [ 1, 2, 3 ]
console.log(arrayClone) // Array { '0': 1, '1': 2, '2': 3, length: 3 }

数组变对象了!

2.1 深拷贝数组

所以,对于数组来说,我们并不能使用Object.create()函数创建普通对象,而是创建一个数组对象:

function clone(target) {
  if(target === null || typeof target !== 'object') {
    return target
  }

  // 对数组进行特殊处理
  if(Array.isArray(target)) {
    return Array.from(target)
  }

  const newObject = Object.create(Object.getPrototypeOf(target))

  for(const key of Reflect.ownKeys(target)) {
    newObject[key] = clone(target[key])
  }
  return newObject;
}

好起来的,但是还有些问题,数组中的内容是浅拷贝的:

const array = [{a: 1 }, 2, 3]
const arrayClone = clone(array)
console.log(array) // [ { a: 1 }, 2, 3 ]
console.log(arrayClone) // [ { a: 1 }, 2, 3 ]
console.log(array[0] === arrayClone[0]) // true

再改一下,和之前浅拷贝改深拷贝的思路一样,递归就完事儿了~:

function clone(target) {
  if(target === null || typeof target !== 'object') {
    return target
  }

  if(Array.isArray(target)) {
    // 戍主元素递归调用clone进行深拷贝
    return Array.from(target).map(clone)
  }

  const newObject = Object.create(Object.getPrototypeOf(target))
  for(const key of Reflect.ownKeys(target)) {
    newObject[key] = clone(target[key])
  }
  return newObject;
}

2.2 循环引用问题

新的问题又来了,如果有循环引用怎么办:

const circular = {  }
circular.self = circular
circularCloned = clone(circular) // RangeError: Maximum call stack size exceeded

我们上面的代码直接栈溢出了,原因是因为“我克隆我自己”,一直递归地克隆。

解决循环引用地思路是:在克隆过程中,遇到循环引用,就不要深拷贝了,而应该浅拷贝。

顺着这个思路,我们要解决以下两个问题:

  1. 怎么知道发生了循环引用?
  2. 既然要浅拷贝,我怎么拿到这个浅拷贝地引用?

首先,我们需要标记一个对象是不是正在克隆;在克隆的时候,检查一下这个对象有没有被标记,即是不是正在克隆,如果一个对象正在克隆,但它又被克隆了,这就说明发生了循环引用,需要进行浅拷贝了。

对于拷贝后的引用,我们可以用一个Map来存储,这个Mapkey就是拷贝前的对象,Mapvalue就是拷贝后的对象;同时,这里也解决了上面的问题,如果一个对象在克隆的过程中,Mapkey中已经存在该对象了,那就说明有循环引用。

由于在递归过程中要共享同一个Map,有两种做法:

  1. 通过参数传递
  2. 通过闭包

这里我们就使用闭包的方式吧:

function clone(target) {
  
  const oldToNew = new Map()

  // 把之前的clone改个名字
  function _baseClone(target) {

    if(target === null || typeof target !== 'object') {
      return target
    }

    if(Array.isArray(target)) {
      return Array.from(target).map(_baseClone)
    }

    const newRef = oldToNew.get(target)
    // 如果当前对象已经在克隆了
    if(newRef) { // 直接返回新对象的引用
      return newRef;
    }

    const newObject = Object.create(Object.getPrototypeOf(target))
    // 标记target正在克隆,并且保存新对象的引用
    oldToNew.set(target, newObject)
    for(const key of Reflect.ownKeys(target)) {
      newObject[key] = _baseClone(target[key])
    }
    return newObject;
  }

  return _baseClone(target)
}

2.3 拷贝其它特殊对象 & Symbol.toStringTag

就像Array一样,还有一些其它的对象需要处理,例如DateRegExpSetMap。 但是,它们和Array不一样的地方在于,可以使用Array.isArray()来判断一个对象是不是一个数组,其他对象并没有这样的一个方法。

instanceof这时候就派上用场了。但是,这里我们并不使用instanceof,没有其他原因,就是想多记录一点东西。

使用Object.prototype.toString.call()这样的方式,来获取一个描述类型的字符串。

const date = new Date()
const set = new Set()
const array = new Array()

console.log(Object.prototype.toString.call(date)) // [object Date]
console.log(Object.prototype.toString.call(set)) // [object Set]
console.log(Object.prototype.toString.call(array)) // [object Array]

但是为什么我们自定义的类型创建的对象得到的是[object Object]呢?关键是一个keySymbol.toStringTag的属性; 我们可以在Set的原型上找到这个属性,它的值为"Set"

class Person {

}
const p = new Person()
console.log(Object.prototype.toString.call(p)) // [object Object]

Person.prototype[Symbol.toStringTag] = 'Person'
console.log(Object.prototype.toString.call(p)) // [object Person]

注意:对于ArrayDate,我没有在它们的prototype上找到这个属性。但是,它们得到的结果格式都是一致的。

提取类型名字:

function getObjectType(object) {
  return Object.prototype.toString.call(object, 8, -1);
}

回到上面的代码,我们现在是要处理一些特殊对象:

function clone(target) {
  
  const oldToNew = new Map()

  function getObjectType(object) {
    return Object.prototype.toString.call(object).slice(8, -1);
  }

  function _baseClone(target) {

    if(target === null || typeof target !== 'object') {
      return target
    }

    if(Array.isArray(target)) {
      return Array.from(target).map(_baseClone)
    }

    // 一些特殊对象的处理
    // 这里仅列举一部分
    switch(getObjectType(target)) {
      case 'Date': return new Date(target)
      case 'RegExp': return new RegExp(target)
      case 'Set': {
        const newSet = new Set()
        target.forEach(item => {
          // 深拷贝内容元素
          newSet.add(_baseClone(item))
        })
        return newSet
      }
    }


    const newRef = oldToNew.get(target)
    if(newRef) {
      return newRef;
    }

    const newObject = Object.create(Object.getPrototypeOf(target))
    oldToNew.set(target, newObject)
    for(const key of Reflect.ownKeys(target)) {
      newObject[key] = _baseClone(target[key])
    }
    return newObject;
  }

  return _baseClone(target)
}

上面的代码我们添加了对于DateRegExpSet的克隆处理。对于Set来说,它和Array一样,也需要深拷贝它的内容元素。

2.4 深拷贝原型链

如果想要深拷贝原型链,还是老思路,将浅拷贝原型链出的代码const newObject = Object.create(Object.getPrototypeOf(target))改成深拷贝:

function clone(target) {
  
  const oldToNew = new Map()

  function getObjectType(object) {
    return Object.prototype.toString.call(object).slice(8, -1);
  }

  function _baseClone(target) {

    if(target === null || typeof target !== 'object') {
      return target
    }

    if(Array.isArray(target)) {
      return Array.from(target).map(_baseClone)
    }

    switch(getObjectType(target)) {
      case 'Date': return new Date(target)
      case 'RegExp': return new RegExp(target)
      case 'Set': {
        const newSet = new Set()
        target.forEach(item => {
          newSet.add(_baseClone(item))
        })
        return newSet
      }
    }


    const newRef = oldToNew.get(target)
    if(newRef) {
      return newRef;
    }

    // const newObject = Object.create(Object.getPrototypeOf(target))
    // 改为深拷贝
    const newObject = Object.create(_baseClone(Object.getPrototypeOf(target)))
    oldToNew.set(target, newObject)
    for(const key of Reflect.ownKeys(target)) {
      newObject[key] = _baseClone(target[key])
    }
    return newObject;
  }

  return _baseClone(target)
}

直接这样改完之后,会沿着原型链向上一直拷贝到Object.prototype,甚至连Object.prototype也克隆了,这就导致了克隆后的对象使用instancetype of Object会返回false