博客:pionpill
判断类型
深拷贝对象需要将对象的所有属性都拷贝一份,先简单说一下 JS 的类型。
简单类型
JS 分为简单类型和复杂类型,简单类型。假设有变量 x 那么其常见判断方式为:
undefined:x === undefinednull:x === nullboolean:typeof x === 'boolean'number:typeof x === 'number'string:typeof x === 'string'symbol:typeof x === 'symbol'bigInt:typeof x === 'bigint'
undefined 和 null 可以直接用强等于判断,因为他们的类型对应值只有一个,且存在于全局变量中。
无法用 typeof null === 'null' 来判断 null,因为历史原因,会返回 null 是 object 类型,不考虑函数的情况下判断简单类型可以这样写:
typeof target !== 'obj' || target === null // 判断简单类型
typeof target === 'obj' && target !== null // 判断对象
复杂类型
复杂类型就是 Object 和继承自 Object 的对象。他们都存在于堆内存中,ES6 之前的几种对象判断方式为:
Object:typeof x === 'object' && x !== nullFunction:typeof x === 'function'Array:Array.isArray(x)
在 ES6 之后,又添加了很多内置容器对象,例如 Map, Set 如果使用 typeof 进行判断会返回 Object, 此时可以通过 instanceOf 检查原型链来判断:
Map:x instanceOf MapWeakMap:x instanceOf WeakMapSet:x instanceOf SetWeakSet:x instanceOf WeakSet
对于自定义的类型,也可以通过这个方式判断。但这个操作符本质上是去对象原型链上找是否存在构造函数的原型,如果需要准且判断是否为某个构造函数的实例,可以这样(例如 Array):
target.__proto__ === Array.prototype
此外还有一种万能解决方案:
Object.prototype.toString.apply(x)
该方法会返回变量的准确类型。
浅拷贝
浅拷贝非常简单,这里归纳几种常见方法:
- 对象(
obj):- 解构语法:
const newObj = { ...obj } Object.assign(newObj, obj): 将obj属性浅拷贝到一个空对象newObj中
- 解构语法:
- 数组(
Array):Array.prototype.slice(): 不传参数,返回一个浅拷贝数组对象Array.of(): 创建一个新的数组对象
这些方法都有一些小缺陷:无法拷贝不和枚举属性。如果需要将所有属性找出来,可以参考以下几个 API:
Object.getOwnPropertyNames: 获取非Symbol的所有属性Object.getOwnPropertySymbols:获取所有Symbol属性Object.getOwnPropertyDescriptors():获取所有属性的属性描述符Reflect.ownKeys():获取所有属性(包括Symbol)
一个能够完整拷贝包括不可枚举与 Symbol 属性的方法如下:
const shallowClone = <T extends Object>(obj: T ) => {
const allKeys = Reflect.ownKeys(obj);
const newObj = {};
allKeys.forEach(key => {
(newObj as any)[key] = Reflect.get(obj, key);
})
return newObj;
}
深拷贝
如果深拷贝对象的所有属性都是可序列化的(可以转换为 json 文件中对应的格式),那么可以用这个简易方案:
const jsonDeepClone = <T extends Object>(obj: T): T => {
return JSON.parse(JSON.stringify(obj))
}
缺点是包括Function, BigInt, Symbol在内的不可序列化类型无法被深拷贝。
递归对象属性
下面开始一步步手写深拷贝,首先考虑两个问题:
- 如何区分简单类型与引用类型
- 如何获取对象的所有直接属性
- 如何深拷贝对象的属性
第一点,封装一个函数:
const isObject = (target: any) => ["object", "function"].includes(typeof target) && target !== null;
第二点,上文说过了,用 Reflect.ownKeys() 获取包括 Symbol, 不可枚举属性在内的所有属性。网上常见的方式是使用 for...in 遍历对象属性,但这有几个缺点:
for...in会获取原型链上的可枚举属性,需要使用Object.hasOwnProperty()在判断一遍。for...in无法获取Symbol与不可枚举属性。
本文只是处于严谨考虑将 Symbol 与不可枚举属性也深拷贝进来,具体是否要这样做请以业务场景为准。如果要更针对性地拷贝属性,可以使用 Object.getOwnPropertyDescriptors() 获取属性描述再做判断
第三点,用递归。但有一个特殊情形,函数如何深拷贝。最简单的方式是用 eval:
const cloneFunc = <T extends Function>(func: T): T => {
return eval(func.toString())
}
但 eval 有安全性问题,而且不是所有的类型都可以简单地 toString 再使用 eval 执行。
一般情况下函数不需要深拷贝,但为了 this 指向正确,可以使用 Function.prototype.bind 调整指向,否则 this 还可能指向被拷贝的对象环境(箭头函数万岁)。
整理前两个问题,写一个基础版:
// unknown 在判断后编译器会自动推导类型
const deepClone = (obj: unknown) => {
// 基本类型和函数
if (typeof obj !== "object" || obj === null) {
return obj;
}
// 两类每必要返回的对象
if (obj instanceof Date || obj instanceof RegExp) {
return obj;
}
// 数组处理
if (obj instanceof Array) {
const result: any[] = [];
obj.forEach((item, index) => {
result[index] = deepClone(item); // 下面解释为什么这样做
});
return result;
}
// Map 处理
if (obj instanceof Map) {
const result = new Map<any, any>();
Array.from(obj.keys()).forEach((key) =>
result.set(key, deepClone(obj.get(key)))
);
return result;
}
// Set 处理
if (obj instanceof Set) {
const result = new Set<any>();
Array.from(obj.keys()).forEach(
(item) => result.add(deepClone(item)),
obj
);
return result;
}
// 常规 Object 处理
const result = {};
// 原型加上去
Object.setPrototypeOf(result, Object.getPrototypeOf(obj));
Reflect.ownKeys(obj).forEach((key) => {
Reflect.set(
result,
key,
deepClone(Reflect.get(obj, key))
);
});
return result;
};
有几个细节注意下,在处理数组时有这样一段:
arrayObj.forEach((item, index) => {
result[index] = deepClone(item, obj);
});
为什么不直接 result.append(deepClone(item)) 呢?JS 的数组有一个极其特殊的情形:空元素,不是指元素值为假值,而是没有元素值。如果直接 append,那么空元素就不会被包括在内。使用 forEach 会跳过空元素处理逻辑(没找到好的空元素判断方法),只需要通过下标赋值,那么没有赋值的元素就是空元素。
这里全部使用 instanceof 操作符判断,但我只考虑了常见情形,如果项目根据需要做了封装,那么需要特殊处理。
循环引用问题
为了避免循环引用问题,可以使用一个 map 缓存属性:
const deepClone = (obj: unknown, map = new WeakMap()) => {
if (typeof obj !== "object" || obj === null) {
return obj;
}
if (obj instanceof Date || obj instanceof RegExp) {
return obj;
}
if (map.has(obj as Object)) {
return map.get(obj as Object);
}
if (obj instanceof Array) {
const result: any[] = [];
map.set(obj, result);
obj.forEach((item, index) => {
result[index] = deepClone(item, map);
});
return result;
}
if (obj instanceof Map) {
const result = new Map<any, any>();
map.set(obj, result);
Array.from(obj.keys()).forEach((key) =>
result.set(key, deepClone(obj.get(key), map))
);
return result;
}
if (obj instanceof Set) {
const result = new Set<any>();
map.set(obj, result);
Array.from(obj.keys()).forEach(
(item) => result.add(deepClone(item, map)),
obj
);
return result;
}
const result = {};
map.set(obj, result);
Object.setPrototypeOf(result, Object.getPrototypeOf(obj));
Reflect.ownKeys(obj).forEach((key) => {
Reflect.set(
result,
key,
deepClone(Reflect.get(obj, key), map)
);
});
return result;
};
函数 this 指向问题
上面代码中,我们的函数是直接浅拷贝过来,也即获得了原来函数的一个引用。那么这个函数的 this 还是指向原来的对象的属性,这就可能出问题,因此需要修改 this 指向:
const isObject = (target: any) => ["object", "function"].includes(typeof target) && target !== null;
const deepClone = (
obj: unknown,
map = new WeakMap(),
self: Object = globalThis
) => {
if (!isObject(obj)) {
return obj;
}
if (obj instanceof Date || obj instanceof RegExp) {
return obj;
}
if (map.has(obj as Object)) {
return map.get(obj as Object);
}
if (obj instanceof Array) {
const result: any[] = [];
map.set(obj, result);
obj.forEach((item, index) => {
result[index] = deepClone(item, map, obj);
});
return result;
}
if (obj instanceof Map) {
const result = new Map<any, any>();
map.set(obj, result);
Array.from(obj.keys()).forEach((key) =>
result.set(key, deepClone(obj.get(key), map, obj))
);
return result;
}
if (obj instanceof Set) {
const result = new Set<any>();
map.set(obj, result);
Array.from(obj.keys()).forEach(
(item) => result.add(deepClone(item, map, obj)),
obj
);
return result;
}
if (obj instanceof Function) {
return obj.bind(self);
}
const defaultObj = obj as Object;
const result = {};
map.set(defaultObj, result);
Object.setPrototypeOf(result, Object.getPrototypeOf(defaultObj));
Reflect.ownKeys(defaultObj).forEach((key) => {
Reflect.set(
result,
key,
deepClone(Reflect.get(defaultObj, key), map, defaultObj)
);
});
return result;
};
最后总结以下深拷贝的要点
- 基本类型:直接返回
- 引用类型,分类讨论:
Data,RegExp,Function:可以直接返回- 其他引用类型:按照各自的深拷贝方法递归
- 解决循环引用:使用
map做一个属性缓存,如果存在直接返回 - 函数指向性问题:获取将函数所在对象,并使用
bind修正指向 - 细节-对象:使用
Reflect.ownKeys能获取Symbol,不可枚举属性在内的所有属性 - 细节-对象:原型链保持前后一致
- 细节-数组:考虑空元素情形
此外,WeakMap 和 WeakSet 是没法直接深拷贝的,因为无法获取他们的 key 集。