拷贝的意义

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

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

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

拷贝的分类

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

浅拷贝

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

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

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) {
  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
  }
  // 无法获取代理对象的属性名,只能返回
  if (value instanceof Proxy) {
    return value
  }
 
  // 返回的结果继承原型
  let result
  if (tag == arrayTag) {
    // 由于 Array 的空属性不会被遍历,单纯继承原型会导致长度不一
    result = new value['__proto__'].constructor(value.length)
  } else {
    result = new value['__proto__'].constructor()
  }

  // 记录已拷贝的对象
  // 用于解决循环引用的问题
  stack || (stack = new Map())
  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))
    }
  }

  // 递归拷贝对象/数组的属性
  for (const prop of Object.keys(value)) {
    result[prop] = deepClone(value, stack)
  }
  // 拷贝符号属性
  for (const sy of Object.getOwnPropertySymbols(value)) {
    result[sy] = deepClone(value, stack)
  }

  return result
}

讲解

在上面的代码中我们是根据传入数据的类标签来区分数据类型的,类标签相关内容可以查看 细述 JS 各数据类型的检测与转换symbol 类型用法介绍

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

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

下一步,调用对象原型的构造器获取新示例同时也继承原型,由于复制的数组要与原数组长度相同,所以调用数组(或其子类)的构造函数时要传入长度。

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

最后根据对象的类型,递归拷贝其属性值,对 Map 和 Set 特别处理,对象和数组都可以通过 Object.keys() 获取所有键/索引,再拷贝一遍符号属性,结束深拷拷贝代码。

总结

我们自己实现的深拷贝函数,对比 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 方法来针对性的实现拷贝的功能。

结语

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

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

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


清隆
29 声望2 粉丝

学完某项技能一定要写写文章,用的时候都是照搬代码,写出来才能深入理解!