教你写一个深拷贝函数

拷贝的意义 所谓拷贝,是克隆 数据,是要在不改变原数据的情况下的操作数据。
有些文章或面试官提到的拷贝函数、拷贝类,纯粹没事找事,拷贝出来的与原功能一样,干嘛不使用原函数
想要扩展函数就用新函数封装,想扩展类就使用继承,拷贝 功能 是完全无意义的操作
拷贝的分类 拷贝分两种,浅拷贝和深拷贝
浅拷贝
浅拷贝只会展开拷贝对象第一层,如果数据内又包含了引用类型,克隆出的对象依旧指向原对象的引用,修改克隆对象可能会影响到原对象。
一般浅拷贝推荐使用 ... 展开运算符,快捷方便

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 = objJSON.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 = https://www.it610.com/article/{ 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 方法来针对性的实现拷贝的功能。
结语 目前没有一款深拷贝函数能完美实现所有需求,本文给出了一个较为通用的深拷贝函数,希望读者能够理解并掌握,在有需求的时候专门定制自己的拷贝函数。
如果文中有不理解或不严谨的地方,欢迎评论提问。
如果喜欢或有所帮助,希望能点赞关注,鼓励一下作者。

    推荐阅读