实现深拷贝的一次尝试

基本概念

深拷贝

众所周知,在JS中将一个对象赋值给变量时,这个变量保存的是存放该对象的内存地址,这也是将Object称为引用类型的原因。

假设现在有变量a和b,a已经被赋值了一个对象obj,此时想要将obj也赋值给b,如果直接使用等号赋值,那么就会出现一个问题:对a或b的改动会同时影响到另一个变量的值。原因就是a和b的值指向同一个内存地址,它们两个的修改也是对该内存地址进行修改。

因此,想要a和b的对象值表面上一致,并且还不会因为对方的修改而被迫修改,我们就需要实现深拷贝。由此可得,深拷贝的概念就是:完全的拷贝一个对象,即使嵌套了对象,两者也相互分离,修改一个对象的属性,也不会影响另一个。

广义表

广义表是一种数据结构,它一般写作L=(a1,a2,a3,a4,...)。其中,每一个子项a可以是单个元素,也可以是一个广义表。由此可以发现广义表可以具有递归性。

我们可以举几个广义表的例子:

1
2
3
4
5
6
7
8
// 所有项都是单个元素
A = (1,2,3)
// 空表
B = ()
// 子项可以是另一个广义表
C = (A,1,2) = ((1,2,3),1,2)
// 可以循环调用自身
D = (1,D)

此外,广义表还有表头和表尾的概念,但是这里并不需要。只是简单地向读者们介绍一下广义表,因为可以看出JS中的Object和Array实际上就是一种广义表,只不过是表的类型有多种。

需求

实现一个函数,传入被拷贝的对象,返回深拷贝后新的对象。

实现思路

由于我现在已经写完了相关代码,所以回过头来看觉得思路还是比较简单的。

第一次尝试

初期,我只需要实现一个拷贝函数并且能够进行递归调用:

  1. 拷贝函数先检查传入参数类型,如果不是引用类型则原样返回;
  2. 如果是引用类型,先获取其所有自有属性,也就是不包括原型属性;
  3. 遍历其自有属性,如果该属性值为非引用类型,则直接赋值;
  4. 如果属性值为数组或对象,则递归调用拷贝函数,将返回值赋值给新对象;
  5. 返回新对象。

第二次尝试

之后,我们需要解决循环引用的问题。

1
2
const testObj = {};
testObj.test = testObj;

testObj这个对象就循环引用了自身。而在初期的代码中没有考虑这个情况,那么怎么改进呢?

之前说过变量实际上保存的是对象的内存地址,而比较两个对象相等时其实也是在比较两个对象的内存地址是否一致。

因此,对于循环引用的对象obj,我们只需要声明一个全局记录变量record,并在拷贝函数的开头保存该对象obj以及其对应的新对象newObj。而通过全局记录变量record,在遍历属性值为对象obj时,能够判断出它已经被递归深拷贝过了。此时不需要再对其进行递归深拷贝,仅仅需要将记录中拷贝后的新对象newObj赋值给目标对象targetObj对应的属性即可。

综上,我们需要添加两个功能:

  1. objnewObj变量记录在record变量;
  2. 遍历record,从中检索出是否存在指定对象,如果存在则返回其深拷贝后的对象。

第三次尝试

由于前期没有考虑到数组项也有可能是对象的细节,所以拷贝函数每次返回的都是一个Object类型变量。因此需要再次对原有函数进行改进:

  1. 当原对象的属性值为一个数组时,也需要递归调用拷贝函数;
  2. 当检测到传入参数为Array类型时,依然使用一个Object类型的变量targetObj保存其属性与数组项值;
  3. 在拷贝函数结束时,将数组的length属性赋值给targetObj的length属性,最终返回Array.from(targetObj)

以上,就是一个简单的对象深拷贝函数的(尝试性)实现。

源码

核心代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
// 存放已遍历过的对象和复制后的对象
const mapOldtoNew = [];

// 检查一个对象是否被遍历并且返回其复制对象
function check(obj) {
const result = mapOldtoNew.filter((item) => {
if (Object.is(item.old, obj)) {
return true;
}
else {
return false;
}
});
// 如果已被遍历
if (result.length === 1) {
return result[0].new;
}
// 否则返回undefined
else {
return;
}
}

// 记录已遍历的对象和其映射
function record(oldObj, newObj) {
const item = {
new: newObj,
old: oldObj
};

mapOldtoNew.push(item);
}

/**
* 遍历对象属性和值并输出
* 1. 对于number、string、boolean、null和undefined和Function和Date直接输出
* 2. 对于Array、Object类型需要递归调用
*/
function tranverse(obj) {
// 如果不是对象或数组则返回原变量
if ((Object.prototype.toString.call(obj) !== '[object Object]') && !Array.isArray(obj)) {
return obj;
}

// 目标对象
const newObj = obj;
// 源对象的键
const keys = Object.keys(obj);
// 记录当前复制的对象
record(obj, newObj);

for (const key of keys) {
const value = obj[key];
// 如果属性值类型为基础数据类型和Function
if (typeof value !== 'object') {
newObj[key] = value;
}
// 如果是null
else if (Object.is(value, null)) {
newObj[key] = value;
}
// 如果为数组
else if (Array.isArray(value)) {
// TODO: 如果数组项为对象?
const checked = check(value);
// 如果已经遍历过了
if (checked) {
newObj[key] = checked;
}
// 否则进行递归遍历
else {
newObj[key] = tranverse(value);
}
}
// 如果是Date或Math等对象
else if (Object.prototype.toString.call(value) !== '[object Object]') {
newObj[key] = value;
}
// 如果是对象
else if (Object.prototype.toString.call(value) === '[object Object]') {
const checked = check(value);
// 如果已经遍历过了
if (checked) {
newObj[key] = checked;
}
// 否则进行递归遍历
else {
newObj[key] = tranverse(value);
}
}
}
// 如果是数组则需复制其length属性,否则Array.from方法无法将其转换成数组
if (Array.isArray(obj)) {
newObj.length = obj.length;
return Array.from(newObj);
}
else {
return newObj;
}
}

测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 深拷贝对象
const testObj = {
a: 'a',
b: '1',
c: true,
d: undefined,
e: null,
f: [1, 2, 3],
h: function () {
alert(123);
}
};
const childObj = {
i: 1,
j: false
};
testObj.f.push(childObj);
testObj.g = testObj;
testObj.k = childObj;
testObj.l = childObj;

const newObj = tranverse(testObj);
newObj.f[3].i = 0;
newObj.f.push(4);
newObj.g.m = 123;
childObj.i = 2;

testObj;
newObj;

// 深拷贝数组
const array = [1, 2, 3];
const newArray = tranverse(array);
newArray[1] = 4;
newArray;

// 测试继承
function SuperObject() {
this.name = 'test';
this.info = {
sex: 'male'
};
}

项目地址

如果你是N年后看到本文,也许上面的代码都经过多次迭代,面目全非了。不过不要担心,你可以访问这个地址来获取最新版本。

未实现的功能

本来我觉得该实现的功能都已经实现了,但是在写本文的时候突然想到如果要拷贝的原对象是继承自某个父类呢?于是我使用下面的测试代码测试后发现拷贝父类的功能并没有实现。回头我翻翻Object.assign的文档,看看能否解决。

1
2
3
4
5
6
7
8
9
10
11
12
// 测试继承
function SuperObject() {
this.name = 'test';
this.info = {
sex: 'male'
};
}

const subObject = new SuperObject();
const cloneObject = tranverse(subObject);
console.error('是否为子类:', cloneObject instanceof SuperObject);
// TODO: 继承失败

总结

深拷贝的概念由来已久,我也听过很多次,但是一直没有自己去实现这样一个功能。最近重新看严蔚敏的数据结构时,发现广义表的概念与JS里的对象、数组是相似的,并且书中也提到广义表的比较与复制。这激起了我的兴趣,让我开始思考如何实现深拷贝这个功能。

目前来看,我还是挺满意这次尝试的。