持续创作,加速成长!这是我参与「掘金日新计划 · 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 方法来针对性的实现拷贝的功能。
结语
目前没有一款深拷贝函数能完美实现所有需求,本文给出了一个较为通用的深拷贝函数,希望读者能够理解并掌握,在有需求的时候专门定制自己的拷贝函数。
如果文中有不理解或不严谨的地方,欢迎评论提问。
如果喜欢或有所帮助,希望能点赞关注,鼓励一下作者。