教你写一个深拷贝函数

126 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第8天,点击查看活动详情

拷贝的意义

所谓拷贝,是克隆 数据,是要在不改变原数据的情况下的操作数据。

有些文章或面试官提到的拷贝函数、拷贝类,纯粹没事找事,拷贝出来的与原功能一样,干嘛不使用原函数

想要扩展函数就用新函数封装,想扩展类就使用继承,拷贝 功能 是完全无意义的操作

拷贝的分类

拷贝分两种,浅拷贝和深拷贝

浅拷贝

浅拷贝只会展开拷贝对象第一层,如果数据内又包含了引用类型,克隆出的对象依旧指向原对象的引用,修改克隆对象可能会影响到原对象。

一般浅拷贝推荐使用 ... 展开运算符,快捷方便

const arr = [1, 2, 3]
const arrClone = [...arr]

const obj = {
  a: 1,
  b: {
    c: 2,
  },
}
const objClone = {
  ...obj,
}

objClone.a = 2
objClone.b.c = 3
console.log(obj.a) // 1
console.log(obj.b.c) // 3

深拷贝

在上一节浅拷贝已经发现问题了,在拷贝多层引用对象后,修改克隆对象时原对象数据可能也会跟着变,这明显是我们不希望的。

深拷贝就是要解决这个问题,对于多层的数据,逐层拷贝

最常见的深拷贝是借助 JSON 转换:JSON.parse(JSON.stringify(obj))

但 JSON 转换存在很多不足

  • JSON 只能转换普通对象和数组,JS 中许多类对象并不支持,比如:Map、Set、Date、RegExp 等等
  • JSON 在转换某些基础类型也存在问题,比如:NaN转换成null、忽略Symbol、BigInt报错
  • JSON 无法处理循环引用的问题
const obj = {}
obj.obj = obj

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

综上,在下一章我们要实现自己的深拷贝函数

深拷贝实现

代码

先上代码,然后再讲解

/**
 * @description: 深拷贝函数
 * @param {any} value 要拷贝的数据
 * @param {Map} [stack] 记录已拷贝的对象,避免循环引用
 * @return {any} 拷贝完成的数据
 */
function deepClone(value, stack = new Map()) {
  const objectTag = '[object Object]'
  const setTag = '[object Set]'
  const mapTag = '[object Map]'
  const arrayTag = '[object Array]'

  // 获取对象标签
  const tag = Object.prototype.toString.call(value)

  // 只需要递归深拷贝的种类有 对象、数组、集合、映射
  // 其余一律直接返回
  const needCloneTag = [objectTag, arrayTag, setTag, mapTag]
  if (!needCloneTag.includes(tag)) {
    return value
  }

  // 返回的对象继承原型
  let result = new value['__proto__'].constructor()

  // 记录已拷贝的对象
  // 用于解决循环引用的问题
  if (stack.has(value)) {
    return stack.get(value)
  }
  stack.set(value, result)

  // 递归拷贝映射
  if (tag == mapTag) {
    for (const [key, item] of value) {
      result.set(key, deepClone(item, stack))
    }
  }

  // 递归拷贝集合
  if (tag == setTag) {
    for (const item of value) {
      result.add(deepClone(item, stack))
    }
  }

  // 递归拷贝对象/数组
  // 获取对象的所有 属性与其对应的描述符
  // 包含符号属性
  // 包含不可枚举的属性
  const propertyDescriptors = Object.getOwnPropertyDescriptors(value)
  for (const prop of Object.keys(propertyDescriptors)) {
    const descriptor = propertyDescriptors[prop]
    if ('value' in descriptor) {
      // 对数据描述符递归拷贝
      descriptor.value = deepClone(descriptor.value, stack)
    }
    Object.defineProperty(result, prop, descriptor)
  }

  return result
}

讲解

在上面的代码中我们是根据传入数据的类标签来区分数据类型的

关于要递归深拷贝的对象,在此说明一下:

  • 我们只用递归深拷贝存有数据的对象:对象、数组、集合、映射。
  • 对于基础数据类型,无法存储数据,直接返回。
  • 对于 Date、RegExp、Function、Number、String 等对象,由于它们的属性均是不可改变的,使用原对象与克隆对象功能相同,也无需拷贝,同样直接返回。
  • 对于无法遍历的对象,比如:弱引用对象(WeakMap WeakSet)、代理对象(Proxy)、因为无法获取它们的键/属性,也就无法拷贝。
  • 还有一些类数组对象也能存储数据(Typed Arrays、ArrayBuffer、arguments、nodeList),它们在平时使用的并不多,而且拷贝方式也与数组类似,为了简便没有在代码中体现。

下一步,调用对象原型的构造器获取示例同时继承原型,作为克隆的对象。

然后通过一个 Map 记录原对象中已经拷贝过的对象,避免循环引用无限递归的问题。

最后根据原对象的类型,递归拷贝其属性值,对 Map 和 Set 特别处理,对象和数组都可以通过 Object.getOwnPropertyDescriptors() 获取所有属性、符号与其对应的描述符,通过 Object.defineProperty 将其添加到拷贝对象上,如果是数据描述符(包含 value 属性),也要递归深拷贝。

总结

我们自己实现的深拷贝函数,对比 JSON 转换,多了以下优点

  • 能够处理 Map、Set 等数据类型
  • 能够继承原型的属性
  • 解决了循环引用的问题

虽然我们的深拷贝代码可以复制类的实例,但对于构造函数会产生副作用的类,可能会出现错误

下面是我在项目中遇到的一个 Bug

const globalData = {
  project: null,
}

class Project {
  constructor() {
    this.itemId = 0 // 用于自增的id
    this.itemMap = new Map()
  }
  newItem(item) {
    this.itemMap.set(++this.itemId, item)
    return this.itemId
  }
}
class Item {
  constructor() {
    // 每个新建的 Item 都从全局 Project 获取 Id,并加入到 itemMap 中
    this.itemId = globalData.project.newItem(this)
  }
}

const project = new Project()
globalData.project = project
const item = new Item()

console.log(globalData.project)
// Project {
//   itemId:1
//   itemMap: Map(1) {1 => Item}
// }
const clone = deepClone(project) // 无限创建Item,页面卡死

探究原因就是因为 for of 遍历 itemMap 时,创建了新的 Item 添加进 itemMap 中,新的 Item 又被迭代,导致了无限创建、添加 Item

解决办法也有,就是将要遍历的属性先保存到数组中,只遍历数组

  // 递归拷贝映射
  if (tag == mapTag) {
    for (const key of [...value.keys()]) {
      result.set(key, deepClone(value.get(key), stack))
    }
  }
  // 递归拷贝集合
  if (tag == setTag) {
    for (const item of [...value.values()]) {
      result.add(deepClone(item, stack))
    }
  }

但这不一定符合我们想要的结果,比如我们不希望新克隆的对象被加入到 itemMap 中

所以我在项目中,为那些构造函数会产生副作用的类定义了自己的 clone 方法来针对性的实现拷贝的功能。

结语

目前没有一款深拷贝函数能完美实现所有需求,本文给出了一个较为通用的深拷贝函数,希望读者能够理解并掌握,在有需求的时候专门定制自己的拷贝函数。

如果文中有不理解或不严谨的地方,欢迎评论提问。

如果喜欢或有所帮助,希望能点赞关注,鼓励一下作者。