JavaScript深拷贝实现原理深度解析

1,966 阅读10分钟

前言

大家好,我是CoderBin。手写实现一个深拷贝函数几乎是前端开发人员的必备技能之一(面试😒),如果已经掌握了的可以把本文当做复习,如果还没掌握深拷贝如何实现的,建议学习起来啦。本次会用两种方式实现深拷贝:JSON.stringify、利用递归。

首先大家应该都了解过,使用 JSON.stringify 实现递归是有一定缺陷的,并不是深拷贝函数实现的最优解。本文先使用这种方式实现深拷贝,再指出它到底有什么缺点,然后再使用递归去一步步解决这些缺点,进一步优化深拷贝函数。希望对大家有所帮助,谢谢 💗

如果文中有不对、疑惑的地方,欢迎在评论区留言指正🌻

1. 深拷贝浅拷贝的区别

在实现深拷贝前,首先要知道深拷贝与浅拷贝的区别,这里只说最重要的一点:

  • 浅拷贝是拷贝地址,如果修改新对象里面值为对象的某个值,原本的对象也会被修改,原因是它们共享一份地址所指向的数据

  • 深拷贝会直接开辟一个新的空间,将数据拷贝进来。这样新旧对象互不相干,不会有浅拷贝的问题。

浅拷贝实现,具体代码:

const obj = {
  name: 'CoderBin',
  friend: {
    name: 'Jack'
  }
}

const newObj = Object.assign({}, obj)

newObj.friend.name = 'Tom'

console.log(obj)

这里使用 Object.assign() 实现浅拷贝,紧接着去修改新对象 newObj.friend.name 的值,然后输出原对象,结果如图:

image.png 可以看到,明明修改的是新对象的数据,可原对象 obj.friend.name 的值也被改为了 Tom,这就说明了浅拷贝只实现了外层的拷贝。接下来看看深拷贝的实现是否会有这个问题。

2. JSON.stringify实现深拷贝

实际上,是借用了 JSON 的两个API JSON.stringify()JSON.parse() 来实现深拷贝,具体代码:

const obj = {
  name: 'CoderBin',
  friend: {
    name: 'Jack'
  }
}

const newObj = JSON.parse(JSON.stringify(obj))

newObj.friend.name = 'Tom'

console.log(obj)

这里只修改了上面浅拷贝实现的一行代码,使用JSON的两个API实现深拷贝,让我们来看看结果会如何:

image.png

可以看到,修改了新对象的数据,原对象 newObj.friend.name 的值并没有被改变。

3. JSON.stringify的缺陷

使用JSON两个API实现的深拷贝只是较为浅显的实现方法,原因是这种方法还有较多的弊端

3.1 缺陷一:部分数据类型拷贝有误

在拷贝部分数据类型时,会有意想不到的情况发生,具体代码:

const s1 = Symbol('s1')
const s2 = Symbol('s2')

const obj = {
  name: 'CoderBin',
  friend: {
    name: 'Jack'
  },
  // undefined 类型
  b: undefined,
  // 函数类型
  foo: () => { },
  // Symbol作为键值
  [s1]: 's11',
  s2: s2,
  // Set 类型
  set: new Set(['a', 'b', 'c']),
  // Map 类型
  map: new Map([
    ['a', 'aa'],
    ['b', 'bb']
  ])
}

const newObj = JSON.parse(JSON.stringify(obj))
console.log(newObj)

上面在原对象中增加了undefined、函数、Set、Map类型的数据和Symbol作为键值的属性值,然后通过 JSON.stringify 实现深拷贝,输出新对象,看看输出结果如何:

image.png

可以看到,深拷贝出来的新对象数据明显和原对象有区别:

  • 省略了undefined、函数类型的数据
  • 省略了Symbol作为键值的数据
  • Set、Map类型的数据为空

3.2 缺陷二:循环引用报错

在拷贝到循环引用时,会直接报错,具体代码:

const obj = {
  name: 'CoderBin',
  friend: {
    name: 'Jack'
  }
}

// 循环引用
obj.info = obj

const newObj = JSON.parse(JSON.stringify(obj))

console.log(newObj)

上面代码中 obj.info = obj 就是一个循环引用,拷贝结果: image.png 可以看到,在拷贝过程中直接报错。解决循环引用的问题也是实现深拷贝的一个重点,这个问题会稍微复杂一点,所以放在最后再去解决。

4. 递归实现深拷贝

这里先使用递归对深拷贝进行基本实现,也就是完成JSON.stringify方法实现深拷贝的功能,其他问题在后面慢慢优化解决,具体代码:

// 判断是否为对象
function isObject(value) {
  const valueType = typeof value
  return (value !== null) && (valueType === "object" || valueType === "function")
}

// 深拷贝函数
function deepClone(originValue) {
  if (!isObject(originValue)) {
    return originValue
  }
  // 创建新对象
  const newObj = {}
  // 遍历原对象
  for (const key in originValue) {
    newObj[key] = deepClone(originValue[key])
  }
  return newObj
}

// 原对象
const obj = {
  name: 'CoderBin',
  friend: {
    name: 'Jack'
  }
}

const newObj = deepClone(obj)
newObj.friend.name = 'Tom'
console.log(obj)

代码虽然看起来有点复杂,但其实很简单,有如下分析:

1. 两个函数:

  • 先封装了个判断是否为对象的函数 isObject,作用是判断每个原对象的属性值是否为对象类型
  • 深拷贝函数 deepClone

2. 深拷贝函数 deepClone 实现流程:

  • 先判断原对象的属性值是否为对象类型
  • 创建新对象,最后return
  • 遍历原对象,往新对象上添加原对象的所有属性值。新对象的属性值都为isObject函数返回的值。

输出结果:

image.png 可以看到,修改了新对象的数据,原对象 newObj.friend.name 的值并没有被改变。

5. 深拷贝函数优化-数据类型

仅仅是上面的代码实现一个完整的深拷贝函数是不够的,依旧会存在部分数据类型拷贝失败的情况,原对象代码:

// 原对象
const s1 = Symbol('s1')
const s2 = Symbol('s2')

const obj = {
  name: 'CoderBin',
  friend: {
    name: 'Jack'
  },
  b: undefined,
  s2: s2,
  // 以下待优化
  skill: ['js', 'vue', 'react'],
  foo: () => { },
  [s1]: 's11',
  set: new Set(['a', 'b', 'c']),
  map: new Map([
    ['a', 'aa'],
    ['b', 'bb']
  ])
}

通过深拷贝函数拷贝后的新对象:

image.png

使用深拷贝函数创建的新对象主要有如下问题:

  • 数组类型拷贝格式错误
  • 函数类型拷贝为空对象
  • 当Symbol作为键时,会被忽略拷贝
  • Set类型拷贝为空对象
  • Map类型拷贝为空对象

下面就对以上存在的问题进行优化

5.1 优化数组类型拷贝

首先要弄清楚为什么深拷贝出来的数组会是那种情况,原因如下:

  • 新建对象 newObj 时,定为了{}
  • 使用 for...in 遍历数组时,key 为元素索引

解决方法:只需要在新建对象newObj时,进行三元表达式判断即可。判断 originValue 是否为数组,是则赋值空数组,否则赋值空对象。具体修改代码有:

// 深拷贝函数
function deepClone(originValue) {
  ...
  
  // 创建新对象
  const newObj = Array.isArray(originValue) ? [] : {}
  
  ...
}

拷贝结果:

image.png

5.2 优化函数类型拷贝

由上面可知,当拷贝函数类型的值时,会变成空对象。解决的方法比较简单,只需要在前面判断值是否为函数类型,是则直接返回,具体代码:

// 深拷贝函数
function deepClone(originValue) {

  if (typeof originValue === 'function') {
    return originValue
  }
  
  ...
  
  return newObj
}

拷贝结果如下:

image.png 可以看到,已经成功拷贝到属性 foo 属性的函数类型的值了,并且也是可以正常调用的。

5.3 优化Symbol类型拷贝

由上面可知,当Symbol作为键时,会被忽略拷贝,解决这个问题的步骤如下:

  • 先判断值是否为 symbol 类型,是则返回
  • 对 symbol类型的 key做特殊处理
// 深拷贝函数
function deepClone(originValue) {
  //判断 originVlaue 是否为 symbol 类型
  if (typeof originValue === 'symbol') {
    return Symbol(originValue.description)
  }
  
  ...

  //对 symbol类型的 key做特殊处理
  const symbolKeys = Object.getOwnPropertySymbols(originValue)
  for (const skey of symbolKeys) {
    newObj[skey] = deepClone(originValue[skey])
  }
  
  return newObj
}
  • Object.getOwnPropertySymbols() 方法返回一个给定对象自身的所有 Symbol 属性的数组。 拷贝结果:

image.png 可以看到,当 symbol 类型的数据作为键时,也可以成功拷贝了。

5.4 优化Set类型拷贝

由上面可知,当拷贝 Set 类型的值时,会变成空对象。解决的方法比较简单,只需要在前面判断值是否为 Set 类型,是则返回一个新的set数据,再把参数传进去,代码如下:

// 深拷贝函数
function deepClone(originValue) {

  //TODO 判断 originValue 是否为 Set 类型
  if (originValue instanceof Set) {
    return new Set([...originValue])
  }
  
  ...
  
  return newObj
}

使用 instanceof 方法来判断 originValue 是否为 Set 类型,拷贝结果如下:

image.png 可以看到,Set 类型的数据也可以成功拷贝了。

5.5 优化Map类型拷贝

由上面可知,当拷贝 Map 类型的值时,会变成空对象。解决的方法比较简单,只需要在前面判断值是否为 Map 类型,是则返回一个新的set数据,再把参数传进去,具体代码:

// 深拷贝函数
function deepClone(originValue) {

  //TODO 判断 originValue 是否为 Set 类型
  if (originValue instanceof Set) {
    return new Set([...originValue])
  }
  
  ...
  
  return newObj
}

使用 instanceof 方法来判断 originValue 是否为 Map 类型,拷贝结果:

image.png 可以看到,Map 类型的数据也可以成功拷贝了。

6. 深拷贝函数优化-循环引用

以目前的深拷贝函数,如果遇到循环引用问题依旧会报错,具体代码:

// 深拷贝函数
function deepClone(originValue) {
  ...
}

// 原对象
const s1 = Symbol('s1')
const s2 = Symbol('s2')

const obj = {
  name: 'CoderBin',
  friend: {
    name: 'Jack'
  },
  b: undefined,
  s2: s2,
  // 待优化
  skill: ['js', 'vue', 'react'],
  foo: () => { },
  [s1]: 's11',
  set: new Set(['a', 'b', 'c']),
  map: new Map([
    ['a', 'aa'],
    ['b', 'bb']
  ])
}

// 循环引用
obj.info = obj

const newObj = deepClone(obj)
console.log(newObj)

上述代码在倒数第三行进行了循环引用,下面看看拷贝结果如何:

image.png 毫无意外的直接报错了。

6.1 出现循环引用问题的原因

会出现该问题的原因是,出现了这行代码obj.info = obj,当深拷贝函数通过递归拷贝数据时,由于 obj.info 的值为 obj,而 obj 里面又有 info 属性,值又是 obj,就这样形成了一个闭环,永远不会结束。

这样就出现了循环引用的问题

6.1 使用map解决循环引用

根据上面的循环引用原因分析,想解决这个问题也不难,只需要保证递归函数内生成的 newObj 只被创建一次,当第二次循环时就把第一次创建的 newObj 给返回出去。

那么如何将 newObj 保存下来,并且可以判断值是否存在,可以取值呢?

答案是利用 Map,使用其两个API进行操作:

  • map.has(val) 用于判断val是否已存在于map中
  • map.get(val) 用于取出val对应的值

具体实现代码如下:

// 深拷贝函数
function deepClone(originValue,  map = new Map()) {
  // 如果已存在新对象,就将之返回
  if (map.has(originValue)) {
    return originValue
  }

  // 创建新对象
  const newObj = Array.isArray(originValue) ? [] : {}
  
  // 遍历原对象
  for (const key in originValue) {
    newObj[key] = deepClone(originValue[key], map)
  }

  //对 symbol类型的 key做特殊处理
  const symbolKeys = Object.getOwnPropertySymbols(originValue)
  for (const skey of symbolKeys) {
    newObj[skey] = deepClone(originValue[skey], map)
  }
  
  // 保存第一次对象
  map.set(originValue, newObj)
  
  return newObj

}

注意:在进行递归时,要将map当做参数传过去,这样可以保持map的延续使用

最后拷贝结果:

image.png

7. 完整代码

/** 深拷贝实现
 * 1. 基本功能实现,解决嵌套对象问题
 * 2. 对数组类型的处理
 * 3. 对函数类型的处理
 * 4. 对 Symbol类型的处理(分别作为键值时的处理)
 * 5. 对 Set 类型的处理
 * 6. 对 Map 类型的处理
 * 7. 解决循环引用问题
 */
// *判断是否为对象
function isObject(value) {
  const valueType = typeof value
  return value !== null && (valueType === 'object' || valueType == 'function')
}

function deepClone(originValue, map = new WeakMap()) {

  //TODO 判断 originValue 是否为 Set 类型
  if (originValue instanceof Set) {
    return new Set([...originValue])
  }

  //TODO 判断 originValue 是否为 Map 类型
  if (originValue instanceof Map) {
    return new Map([...originValue])
  }

  //TODO 判断 originVlaue 是否为 symbol 类型
  if (typeof originValue === 'symbol') {
    return Symbol(originValue.description)
  }

  //TODO 判断 originValue 是否为函数类型
  if (typeof originValue === 'function') {
    return originValue
  }

  //TODO 判断 originValue 是否为对象类型
  if (!isObject(originValue)) {
    return originValue
  }

  // 2. 判断 newObj 是否已存在
  if (map.has(originValue)) {
    return map.get(originValue)
  }

  //TODO 判断 originValue 是否为数组类型
  const newObj = Array.isArray(originValue) ? [] : {}

  // 1. 将 newObj保存下来,用于下次运行时判断是否已经创建了 newObj
  map.set(originValue, newObj)

  for (const key in originValue) {
    newObj[key] = deepClone(originValue[key], map)
  }

  //TODO 对 symbol类型的 key做特殊处理
  const symbolKeys = Object.getOwnPropertySymbols(originValue)
  for (const skey of symbolKeys) {
    newObj[skey] = deepClone(originValue[skey], map)
  }

  return newObj
}

// 测试代码

let s1 = Symbol('aaa')
let s2 = Symbol('bbb')

const obj = {
  name: 'CoderBin',
  friend: {
    name: 'Jack'
  },
  b: undefined,
  s2: s2,
  // 待优化
  skill: ['js', 'vue', 'react'],
  foo: () => { },
  [s1]: 's11',
  set: new Set(['a', 'b', 'c']),
  map: new Map([
    ['a', 'aa'],
    ['b', 'bb']
  ])
}

obj.info = obj

const newObj = deepClone(obj)

console.log(newObj)

每文一句:知识是智慧的火炬。

本次的分享就到这里,如果本章内容对你有所帮助的话欢迎点赞+收藏。文章有不对的地方欢迎指出,有任何疑问都可以在评论区留言。希望大家都能够有所收获,大家一起探讨、进步!

本文正在参加「金石计划 . 瓜分6万现金大奖」