前言
对于深拷贝,大家肯定都不陌生。虽然可能深拷贝实际应用场景不多,但是在面试或者刷题过程中,大家免不了需要亲自动手去实现一个深拷贝功能。接下来,以个人的思路带大家实现一遍深拷贝功能,希望能帮助到大家。
思路
深拷贝实现并不难,这里可能需要注意的点,就是如何解决循环引用了。私以为,实现深拷贝只需以下三个核心步骤:
- 创建出对应数据类型的空白数据。即,若须拷贝的是对象类型数据,则创建一个空白对象,以此类推。
- 拷贝目标数据。即把目标数据拷贝到步骤1中创建的空白数据里。
- 返回拷贝的数据。
综上,可以得出以下伪代码框架:
typescript
export function deepClone(data: any){
// 普通类型数据则直接返回
// 循环引用处理
// 1. 创建对应类型的空白数据
// 2. 复制
// 3. 返回
}
接下来,我们来逐个步骤实现,循环引用处理最后再补充实现。
实现
准备工作
js数据类型的获取
js里获取数据的类型有很多种方式,这里利用 Object.prototype.toString.call
方式实现。
首先,我们在文件中定义一个对象,用于列出常见的可能需要深拷贝的数据类型:
js
/**
* 常量定义
*/
// js 的数据类型
export const JS_DATA_TYPE = {
OBJECT: "[object Object]",
ARRAY: "[object Array]",
FUNCTION: "[object Function]",
MAP: "[object Map]",
SET: "[object Set]",
DATE: "[object Date]",
REGEXP: "[object RegExp]",
};
这里,我们简单列出了接下来拷贝过程中会遇到的数据类型,其中,对象和数组就不用说了,高频出现的数据类型。为了全面一些,这里还用到了 Function,Map, Set, Date, RegExp 这些数据类型。
接下来,我们把获取数据类型等工具函数,在另一个文件内实现并导出:
typescript
/**
* 工具函数
*/
/**
* 获取给定数据的类型
* @param data 目标数据
* @returns 类型字符串
*/
export function getType(data: any): string {
return Object.prototype.toString.call(data);
}
/**
* 判断 item 是否为 target 的实例
* @param item 目标数据
* @param target 作为对比的目标数据
* @returns Boolean
*/
export function isInstanceOf(item: any, target: any): boolean{
return item instanceof target;
}
getType
工具函数,用于获取数据的类型,返回的结果就是上面说过的 JS_DATA_TYPE
对象里列出的字符串。
isInstanceOf
工具函数,就是把 instanceof
封装了一下,后续判断拷贝的数据是否为普通类型数据时用到。
创建对应数据类型的空白数据
创建对应数据类型的空白数据,我们通常会利用 Object.create
和 Object.getPrototypeOf
这两个API实现。例如:创建一个空白的对象:
可能有人会说,干嘛这么麻烦,直接 emptyObj = {}
不就完了。说的没错,但这里主要考虑到,不仅仅拷贝目标数据,还需要把目标数据原型上的数据也拷贝过来。例如,我在上图里的 obj1
的原型里定义了一个通用方法 globalFn
,这时候,直接 emptyObj = {}
的话,那这个通用方法,emptyObj
上就没有了。而利用上述的两个api来创建一个空白数据,是可以做到的:
但这里,我们也不利用 Object.create
和 Object.getPrototypeOf
这两个API实现创建空白数据。因为这样的话,在创建空白Map, Set,Date 等数据时,会有问题:
你会发现,这样创建出来的空白数据,使用对应的api会报错,所以这条路行不通。
那么,还有哪种方式既可以拷贝原型上的共享数据,又不会出现这样的错误呢?如果你有更好的方法,欢迎评论区里提出来。
本文采用的是,利用目标数据的构造函数:
那么,我们把利用目标数据的构造函数创建空白数据的逻辑封装到函数去:
typescript
/**
* 获取对应类型的空白数据
* @param data 被克隆的数据
* @returns 对应类型的空白数据
*/
function getRelativeEmptyData(data: any){
const Ctor = data.constructor;
return new Ctor();
}
在 deepClone
里调用该方法,第一步就完成了:
typescript
export function deepClone(data: any){
// 普通类型数据则直接返回
// 循环引用处理
// 1. 创建对应类型的空白数据
let clone = getRelativeEmptyData(data);
// 2. 复制
// 3. 返回
}
拷贝目标数据
拷贝目标数据,说起来就是,被拷贝的数据是什么类型的,我们就拷贝一份一模一样的相应类型的数据。我们可能会这样写:
typescript
export function deepClone(data: any){
// 普通类型数据则直接返回
// 循环引用处理
// 1. 创建对应类型的空白数据
let clone = getRelativeEmptyData(data);
// 2. 复制
const dataType = getType(data);
if(dataType === JS_DATA_TYPE.OBJECT){
// 对象数据拷贝逻辑
}else if(dataType === JS_DATA_TYPE.ARRAY){
// 数组数据拷贝逻辑
}else if(dataType === JS_DATA_TYPE.MAP){
// 映射数据拷贝逻辑
}else if(dataType === JS_DATA_TYPE.SET){
// 集合数据拷贝逻辑
}else if(dataType === JS_DATA_TYPE.FUNCTION){
// 函数拷贝逻辑
}else if(dataType === JS_DATA_TYPE.DATE){
// 日期数据拷贝逻辑
}else if(dataType === JS_DATA_TYPE.REGEXP){
// 正则拷贝逻辑
}
// 3. 返回
}
或者这样写:
typescript
export function deepClone(data: any){
// 普通类型数据则直接返回
// 循环引用处理
// 1. 创建对应类型的空白数据
let clone = getRelativeEmptyData(data);
// 2. 复制
const dataType = getType(data);
switch(dataType){
case JS_DATA_TYPE.OBJECT: {
// 对象数据拷贝逻辑
break
}
case JS_DATA_TYPE.ARRAY: {
// 数组数据拷贝逻辑
break
}
case JS_DATA_TYPE.MAP: {
// 映射数据拷贝逻辑
break;
}
case JS_DATA_TYPE.SET: {
// 集合数据拷贝逻辑
break;
}
case JS_DATA_TYPE.FUNCTION: {
// 函数拷贝逻辑
break;
}
case JS_DATA_TYPE.DATE: {
// 日期数据拷贝逻辑
break;
}
case JS_DATA_TYPE.REGEXP: {
// 正则拷贝逻辑
break;
}
}
// 3. 返回
}
无论是在函数内使用 if-else 或者 switch-case 来实现逻辑,虽然都是完全可以实现拷贝的目的,但这样有什么不足之处?相信大家都能说出来,无非就是:
- 违反SOLID五大原则里的第二条,开放封闭原则。在这里就是函数应该是可拓展的,但是是不可修改的。因为你想,如果后续需要新增拷贝DOM元素的逻辑,是不是得在最后再加一个
else if
,这样是不是就是修改了函数。 - 当有越来越多类型的数据需要拷贝时,这个拷贝逻辑的代码量会越来越多,维护起来不方便,阅读起来也不方便。
能说出代码的不足之处固然好,如果能解决问题,能用一种更优雅的方式来解决就更好了。这里我们把所有的拷贝数据的逻辑,都归纳到一个对象上:
typescript
// 拷贝函数处理器
const COPIER_HANDLER = {
[JS_DATA_TYPE.OBJECT]: objectCopier,
[JS_DATA_TYPE.ARRAY]: arrayCopier,
[JS_DATA_TYPE.MAP]: mapCopier,
[JS_DATA_TYPE.SET]: setCopier,
[JS_DATA_TYPE.FUNCTION]: functionCopier,
[JS_DATA_TYPE.DATE]: dateCopier,
[JS_DATA_TYPE.REGEXP]: regExpCopier,
};
使用的时候这样使用:
typescript
// 拷贝函数处理器
const COPIER_HANDLER = {
[JS_DATA_TYPE.OBJECT]: objectCopier,
[JS_DATA_TYPE.ARRAY]: arrayCopier,
[JS_DATA_TYPE.MAP]: mapCopier,
[JS_DATA_TYPE.SET]: setCopier,
[JS_DATA_TYPE.FUNCTION]: functionCopier,
[JS_DATA_TYPE.DATE]: dateCopier,
[JS_DATA_TYPE.REGEXP]: regExpCopier,
};
export function deepClone(data: any){
// 普通类型数据则直接返回
// 循环引用处理
// 1. 创建对应类型的空白数据
let clone = getRelativeEmptyData(data);
// 2. 复制
const copierHandler: Func = COPIER_HANDLER[getType(data)] ?? function(){ return data };
clone = copierHandler(clone, data);
// 3. 返回
}
这样,我们把拷贝数据的逻辑都收归到了 COPIER_HANDLER
对象上,后续即需要使新增拷贝数据逻辑,也不需要修改函数,只需在这个对象里新增一项即可。而且这样函数里的逻辑更简洁,阅读起来更容易些。
拷贝数据的函数,它们的实现思路都是一样的:遍历目标数据,逐个拷贝到新的空白数据里。为了各拷贝函数逻辑和结构的一致,我们统一约定:
- 函数都有两个参数,第一个是拷贝数据,即空白数据,第二个是被拷贝数据,即目标数据;
- 函数都需要把拷贝数据返回。
像这样:
typescript
/**
* 拷贝数据
* @param clone 拷贝数据
* @param data 被拷贝的数据
* @returns 对应类型的空白数据
*/
function xxxCopier(clone, data){
// 具体的拷贝逻辑
return clone;
}
接下来,我们逐一实现:
对象拷贝逻辑
对象数据的拷贝,我们需要留意的一点,就是数据的嵌套,例如,对象里的嵌套对象等情况。所以,遍历的时候,每个属性值都需要递归调用深拷贝函数来复制数据:
typescript
/**
* 对象拷贝
* @param clone 拷贝对象
* @param data 被拷贝的对象
* @returns 拷贝对象
*/
function objectCopier(clone: Record<any, any>, data: Record<any, any>): Record<any, any>{
Object.keys(data).forEach((key: string) => (clone[key] = deepClone(data[key])));
return clone;
}
数组拷贝逻辑
数组的拷贝和对象拷贝一样,需要考虑到数据嵌套的情况,对应的处理方式也一致,遍历时每个属性值都递归调用深拷贝函数进行处理:
typescript
/**
* 数组拷贝
* @param clone 拷贝数组
* @param data 被拷贝的数组
* @returns 拷贝数组
*/
function arrayCopier(clone: any[], data: any[]): any[]{
data.forEach((item) => clone.push(deepClone(item)));
return clone;
}
Map拷贝逻辑
映射数据的拷贝,因为属性对应的值可能是对象,数组等复杂数据类型,所以需要调用深拷贝函数进行处理:
typescript
/**
* 映射数据拷贝
* @param clone 拷贝映射数据
* @param data 被拷贝的映射数据
* @returns 拷贝映射数据
*/
function mapCopier(clone: Map<any, any>, data: Map<any, any>): Map<any, any>{
data.forEach((val, key) => clone.set(key, deepClone(val)));
return clone;
}
Set拷贝逻辑
集合数据的拷贝,因为集合的每个元素,也可能是对象,数组等复杂数据类型,所以同样需要调用深拷贝函数进行处理:
typescript
/**
* 集合数据拷贝
* @param clone 拷贝集合数据
* @param data 被拷贝的集合数据
* @returns 拷贝集合数据
*/
function setCopier(clone: Set<any>, data: Set<any>): Set<any>{
data.forEach((val) => clone.add(deepClone(val)));
return clone;
}
函数拷贝逻辑
函数的拷贝,因为被拷贝的函数,可能绑定了上下文,所以我们直接用 function
套一层即可:
typescript
/**
* 函数拷贝
* @param clone 拷贝函数
* @param data 被拷贝的函数
* @returns 拷贝函数
*/
function functionCopier(clone: Func, data: Func): Func{
clone = function(this: any, ...args: any){
return data.call(this, ...args);
}
return clone;
}
日期数据拷贝逻辑
日期数据的拷贝,我们直接把被拷贝的数据当作参数传递给 new Date()
即可:
typescript
/**
* 日期数据拷贝
* @param clone 拷贝日期数据
* @param data 被拷贝的日期数据
* @returns 拷贝日期数据
*/
function dateCopier(clone: Date | number, data: Date | number): Date {
clone = new Date(data);
return clone;
}
正则拷贝逻辑
我们可能平时比较少用到正则,或者比较少使用构造函数的方式创建正则表达式。所以我们先复习下,使用构造函数的方式创建正则表达式,以及创建的正则,里面是长什么样的。
首先,我们来看下,使用构造函数的方式创建正则,使用方式是怎样的:
我们可以看到,使用构造函数的方式是 new RegExp(pattern[, flags])
我们需要提供必填的 pattern
参数,和可选的 flags
参数,像这样使用:
复习了如何使用构造函数的方式创建正则后,我们来看一下,创建出来的正则,里面包含什么我们需要用到的信息:
很明显,根据 new RegExp(pattern[, flags])
的方式创建正则,我们需要用到里面的 flags
和 source
字段。所以,我们这样来拷贝正则:
typescript
/**
* 正则拷贝
* @param clone 拷贝正则
* @param data 被拷贝的正则
* @returns 拷贝正则
*/
function regExpCopier(clone: RegExp, data: RegExp): RegExp{
clone = new RegExp(data.source, data.flags);
return clone;
}
至此,我们所有的拷贝数据的函数都实现了。
返回拷贝数据
这个不用多说,直接返回拷贝数据即可:
typescript
/**
* 深拷贝
* @param data 被拷贝的数据
* @returns 拷贝数据
*/
export function deepClone(data: any){
// 普通类型数据则直接返回
// 循环引用处理
// 1. 创建对应类型的空白数据
let clone = getRelativeEmptyData(data);
// 2. 复制
const copierHandler: Func = COPIER_HANDLER[getType(data)] ?? function(){ return data };
clone = copierHandler(clone, data);
// 3. 返回
return clone;
}
循环引用
虽然我们利用递归调用深拷贝函数的方式,可以解决对象嵌套对象等情况,但是如果对象嵌套的对象存在循环引用的情况下,递归调用深拷贝函数,是会爆栈的:
那么,如何解决呢?不难,我们利用一个 WeakMap
即可解决。思路是:创建一个全局的 WeakMap
数据结构,不妨名为 globalMap
,用于存放存在循环引用的数据,每次深拷贝数据前,都先判断该数据是否以存在于globalMap
中,存在的话直接返回从globalMap
获取的数据即可。然后在每次创建完空白数据后,都以被拷贝数据为key,以拷贝数据为value,存放到globalMap
去。
typescript
const globalMap = new WeakMap();
/**
* 深拷贝
* @param data 被拷贝的数据
* @returns 拷贝数据
*/
export function deepClone(data: any){
// 普通类型数据则直接返回
// 循环引用处理
if(globalMap.has(data)) return globalMap.get(data);
// 1. 创建对应类型的空白数据
let clone = getRelativeEmptyData(data);
globalMap.set(data, clone);
// 2. 复制
const copierHandler: Func = COPIER_HANDLER[getType(data)] ?? function(){ return data };
clone = copierHandler(clone, data);
// 3. 返回
return clone;
}
细节补充:普通数据类型的处理
此时深拷贝的基本逻辑已经实现,最后把处理普通类型数据的逻辑补充下即可,即,判断到当前需拷贝的数据属于普通类型,直接返回即可。
typescript
/**
* 深拷贝
* @param data 被拷贝的数据
* @returns 拷贝数据
*/
export function deepClone(data: any){
// 普通类型数据则直接返回
if(!isInstanceOf(data, Object)) return data;
// 循环引用处理
if(globalMap.has(data)) return globalMap.get(data);
// 1. 创建对应类型的空白数据
let clone = getRelativeEmptyData(data);
globalMap.set(data, clone);
// 2. 复制
const copierHandler: Func = COPIER_HANDLER[getType(data)] ?? function(){ return data };
clone = copierHandler(clone, data);
// 3. 返回
return clone;
}
至此,深拷贝函数实现完成。我们看到,按照三个步骤的思路来实现深拷贝,核心函数也就十几二十行代码。看着就比较简洁易懂。
深拷贝函数文件全貌
我们来看下,整个深拷贝函数的全貌:
typescript
/**
* 深拷贝
*/
import { JS_DATA_TYPE } from '../utils/const/index.js';
import { getType, isInstanceOf } from '../utils/tools/index.js';
const globalMap = new WeakMap();
// 拷贝函数处理器
const COPIER_HANDLER = {
[JS_DATA_TYPE.OBJECT]: objectCopier,
[JS_DATA_TYPE.ARRAY]: arrayCopier,
[JS_DATA_TYPE.MAP]: mapCopier,
[JS_DATA_TYPE.SET]: setCopier,
[JS_DATA_TYPE.FUNCTION]: functionCopier,
[JS_DATA_TYPE.DATE]: dateCopier,
[JS_DATA_TYPE.REGEXP]: regExpCopier,
};
/**
* 深拷贝
* @param data 被拷贝的数据
* @returns 拷贝数据
*/
export function deepClone(data: any){
// 普通类型数据则直接返回
if(!isInstanceOf(data, Object)) return data;
// 循环引用处理
if(globalMap.has(data)) return globalMap.get(data);
// 1. 创建对应类型的空白数据
let clone = getRelativeEmptyData(data);
globalMap.set(data, clone);
// 2. 复制
const copierHandler: Func = COPIER_HANDLER[getType(data)] ?? function(){ return data };
clone = copierHandler(clone, data);
// 3. 返回
return clone;
}
/**
* 获取对应类型的空白数据
* @param data 被克隆的数据
* @returns 对应类型的空白数据
*/
function getRelativeEmptyData(data: any){
const Ctor = data.constructor;
return new Ctor();
}
/**
* 对象拷贝
* @param clone 拷贝对象
* @param data 被拷贝的对象
* @returns 拷贝对象
*/
function objectCopier(clone: Record<any, any>, data: Record<any, any>): Record<any, any>{
Object.keys(data).forEach((key: string) => (clone[key] = deepClone(data[key])));
return clone;
}
/**
* 数组拷贝
* @param clone 拷贝数组
* @param data 被拷贝的数组
* @returns 拷贝数组
*/
function arrayCopier(clone: any[], data: any[]): any[]{
data.forEach((item) => clone.push(deepClone(item)));
return clone;
}
/**
* 映射数据拷贝
* @param clone 拷贝映射数据
* @param data 被拷贝的映射数据
* @returns 拷贝映射数据
*/
function mapCopier(clone: Map<any, any>, data: Map<any, any>): Map<any, any>{
data.forEach((val, key) => clone.set(key, deepClone(val)));
return clone;
}
/**
* 集合数据拷贝
* @param clone 拷贝集合数据
* @param data 被拷贝的集合数据
* @returns 拷贝集合数据
*/
function setCopier(clone: Set<any>, data: Set<any>): Set<any>{
data.forEach((val) => clone.add(deepClone(val)));
return clone;
}
/**
* 函数拷贝
* @param clone 拷贝函数
* @param data 被拷贝的函数
* @returns 拷贝函数
*/
function functionCopier(clone: Func, data: Func): Func{
clone = function(this: any, ...args: any){
return data.call(this, ...args);
}
return clone;
}
/**
* 日期数据拷贝
* @param clone 拷贝日期数据
* @param data 被拷贝的日期数据
* @returns 拷贝日期数据
*/
function dateCopier(clone: Date | number, data: Date | number): Date {
clone = new Date(data);
return clone;
}
/**
* 正则拷贝
* @param clone 拷贝正则
* @param data 被拷贝的正则
* @returns 拷贝正则
*/
function regExpCopier(clone: RegExp, data: RegExp): RegExp{
clone = new RegExp(data.source, data.flags);
return clone;
}
实际上,为了文件内的总代码数少一些,我们还可以把各拷贝函数收归到一个文件内,这样这个文件看着就更清爽干净些。
验证
测试数据
为了验证我们写的深拷贝函数是否靠谱,我们来验证下。
首先,我们创建一个 .html
文件,引入 index.js
文件:
接着,`index.js`文件内创建这么一个测试数据:
typescript
import { deepClone } from './deepClone/index.js';
const testData = {
// String
field_1: "",
field_2: "test test",
// Number
field_3: 1,
field_4: -1,
field_5: 0,
// Boolean
field_6: false,
field_7: true,
// null
field_8: null,
// undefined
field_9: undefined,
// Symbol
field_10: Symbol(""),
field_11: Symbol("1111"),
// Object
field_12: {a: 1, b: 2},
field_13: {a: 1, b: 2, c: {c1: 3, c2: 4, c3: {}}, d: [1, 2, "", true, false]},
// Array
field_14: [1, 2, "", true, false],
field_15: [[1, 2], {a: 1, b: 2}],
// Map
field_16: new Map([
["a", 1],
["b", 2],
["c", 5],
]),
// Set
field_17: new Set([1, 2, 3, "", true, false, ["a", "b", "c"], {a: 1, b: 2}]),
// Function
field_18: function(x: number, y: number): number{
return x + y;
},
field_19: (txt: any) => console.log("[field_19] ===> ", txt),
// Date
field_20: new Date(),
field_21: Date.now(),
// RegExp
field_22: /^a.*b$/ig,
field_23: new RegExp("^b.*c$", "ig"),
};
// 循环引用
testData.field_13.c.c3 = testData.field_13.c;
// 验证
const cloneData = deepClone(testData)
console.log({testData, cloneData, result: cloneData === testData});
(window as unknown as any).cloneData = cloneData;
(window as unknown as any).testData = testData;
拷贝测试数据,输出被拷贝数据以及拷贝数据,以及它们是否相等的结果。为了进一步验证,我们把两个数据都绑定到 window
对象上:
我们来看下打印结果:
我们看到,result
为 false
,说明两个数据没有指向同一块地址,深拷贝是成功的。没有运行出错,说明循环引用处理逻辑也是正常的:
Map,Set, Date, 拷贝的函数也能调用:
通过以上验证,说明我们写的深拷贝函数还是靠谱的。
总结
深拷贝函数,实现思路总共分为三个步骤:
- 创建对应类型的空白数据。
- 拷贝数据
- 返回拷贝数据
需要留意的点,就是循环引用。
根据这三个步骤,再把一个重点处理好。我们就能实现一个靠谱的深拷贝函数。
当然了,这个深拷贝函数还是存在不足之处的。例如,我们都知道,递归调用的不足之处,其中之一就是递归深度过深的情况下,存在爆栈的可能性。例如:当数据嵌套太深了,本文的深拷贝函数还是会爆栈的。