拷贝的意义
所谓拷贝,是克隆 数据,是要在不改变原数据的情况下的操作数据。
有些文章或面试官提到的拷贝函数、拷贝类,纯粹没事找事,拷贝出来的与原功能一样,干嘛不使用原函数
想要扩展函数就用新函数封装,想扩展类就使用继承,拷贝 功能 是完全无意义的操作
拷贝的分类
拷贝分两种,浅拷贝和深拷贝
浅拷贝
浅拷贝只会展开拷贝对象第一层,如果数据内又包含了引用类型,克隆出的对象依旧指向原对象的引用,修改克隆对象可能会影响到原对象。
一般浅拷贝推荐使用 ...
展开运算符,快捷方便
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
方法来针对性的实现拷贝的功能。
结语
目前没有一款深拷贝函数能完美实现所有需求,本文给出了一个较为通用的深拷贝函数,希望读者能够理解并掌握,在有需求的时候专门定制自己的拷贝函数。
如果文中有不理解或不严谨的地方,欢迎评论提问。
如果喜欢或有所帮助,希望能点赞关注,鼓励一下作者。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。