Abo

如何实现深克隆与浅克隆(⭐⭐⭐)

停止如此那样的JSON.parse(JSON.stringify(oldObj))


浅克隆

浅克隆之所以被称为浅克隆,是因为对象只会被克隆最外部的一层,至于更深层的对象,则依然是通过引用指向同一块堆内存.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 浅克隆函数
function shallowClone(o) {
const obj = {};
for ( let i in o) {
obj[i] = o[i];
}
return obj;
}
// 被克隆对象
const oldObj = {
a: 1,
b: [ 'e', 'f', 'g' ],
c: { h: { i: 2 } }
};

const newObj = shallowClone(oldObj);
console.log(newObj.c.h, oldObj.c.h); // { i: 2 } { i: 2 }
console.log(oldObj.c.h === newObj.c.h); // true

我们可以看到,很明显虽然oldObj.c.h被克隆了,但是它还与oldObj.c.h相等,这表明他们依然指向同一段堆内存,这就造成了如果对newObj.c.h进行修改,也会影响oldObj.c.h,这就不是一版好的克隆.

1
2
newObj.c.h.i = 'change';
console.log(newObj.c.h, oldObj.c.h); // { i: 'change' } { i: 'change' }

我们改变了newObj.c.h.i的值,oldObj.c.h.i也被改变了,这就是浅克隆的问题所在.

当然有一个新的api Object.assign()也可以实现浅复制,但是效果跟上面没有差别,所以我们不再细说了.

深克隆

JSON.parse方法

前几年微博上流传着一个传说中最便捷实现深克隆的方法, JSON对象parse方法可以将JSON字符串反序列化成JS对象,stringify方法可以将JS对象序列化成JSON字符串,这两个方法结合起来就能产生一个便捷的深克隆.

1
const newObj = JSON.parse(JSON.stringify(oldObj));

我们依然用上一节的例子进行测试

1
2
3
4
5
6
7
8
9
10
11
const oldObj = {
a: 1,
b: [ 'e', 'f', 'g' ],
c: { h: { i: 2 } }
};

const newObj = JSON.parse(JSON.stringify(oldObj));
console.log(newObj.c.h, oldObj.c.h); // { i: 2 } { i: 2 }
console.log(oldObj.c.h === newObj.c.h); // false
newObj.c.h.i = 'change';
console.log(newObj.c.h, oldObj.c.h); // { i: 'change' } { i: 2 }

确实,这个方法虽然可以解决绝大部分是使用场景,但是却有很多坑.

1.他无法实现对函数 、RegExp等特殊对象的克隆

2.会抛弃对象的constructor,所有的构造函数会指向Object

3.对象有循环引用,会报错

主要的坑就是以上几点,我们一一测试下.

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
// 构造函数
function person(pname) {
this.name = pname;
}

const Messi = new person('Messi');

// 函数
function say() {
console.log('hi');
};

const oldObj = {
a: say,
b: new Array(1),
c: new RegExp('ab+c', 'i'),
d: Messi
};

const newObj = JSON.parse(JSON.stringify(oldObj));

// 无法复制函数
console.log(newObj.a, oldObj.a); // undefined [Function: say]
// 稀疏数组复制错误
console.log(newObj.b[0], oldObj.b[0]); // null undefined
// 无法复制正则对象
console.log(newObj.c, oldObj.c); // {} /ab+c/i
// 构造函数指向错误
console.log(newObj.d.constructor, oldObj.d.constructor); // [Function: Object] [Function: person]

我们可以看到在对函数、正则对象、稀疏数组等对象克隆时会发生意外,构造函数指向也会发生错误。

对于RegExp修饰符

i - 修饰符是用来执行不区分大小写的匹配。

g - 修饰符是用于执行全文的搜索(而不是在找到第一个就停止查找,而是找到所有的匹配)。

其他错误

而且构造函数指向也会发生错误。

1
2
3
4
5
6
const oldObj = {};

oldObj.a = oldObj;

const newObj = JSON.parse(JSON.stringify(oldObj));
console.log(newObj.a, oldObj.a); // TypeError: Converting circular structure to JSON

对象的循环引用会抛出错误.

构造一个深克隆函数

我们知道要想实现一个靠谱的深克隆方法,上一节提到的序列/反序列是不可能了,而通常教程里提到的方法也是不靠谱的,他们存在的问题跟上一届序列反序列操作中凸显的问题是一致的.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function isArray(arr) {
return Object.protoType.toString.call(arr) === '[object Array]';
}
//深度克隆
function deepClone(obj) {
if(typeof obj !== "object" && obj !== 'function') {
return obj; //原始类型直接返回
}
var o = isArray(obj) ? [] : {};
for(i in obj) {
if(obj.hasOwnProperty(i)) {
o[i] = typeof obj[i] === 'object' ? deepClone(obj[i]) : obj[i];
}
}
return o;
}

科普(本文涉及到的陌生知识点)

为什么用 Object.prototype.toString.call() ?

Object.prototype.toString.call(obj) 可以用来检测传入参数的数据类型,最常见的就是用来检测数组。你是否有被问过怎样判断一个数据是否是数组类型?这样的问题,如果你回答的是typeof,那就得抓把紧多学学了。

这里我就不介绍所有可以判断数组类型的方法了,我只说Object.prototype.toString.call()这一种方法,因为它让人觉得有点奇怪。

使用 Object.prototype.toString.call() 判断数组类型

使用方法很简单,举个例子:

1
2
var arr = [1, 2, 3]
console.log(Object.prototype.toString.call(arr)) // [object Array]

当我第一次知道可以这样判断数组类型时,我就觉得很奇怪,为什么要用这么奇怪的方法呢?当时的我并没有深究,只是记住了这个方法,但回过头来再仔细想想,这样的方法是有它的道理的。这段语法的意思是Object这个对象的原型链上的toString()方法执行在arr这个上下文中,这里让人疑惑的就是为什么要用Object上的toString()方法,因为数组本身也有toString()。要了解其原理,我们可能要先了解一下toString()这个方法。

toString()

从字面上的意思就可以看出它可以将数据转换为字符串,但将各类型的数据转换为字符串的方式又不一样,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var num = 123
num.toString() // '123'

var str = 'hello'
str.toString() // 'hello'

var bool = false
bool.toString() // 'false'

var arr = [1, 2, 3]
arr.toString() // '1,2,3'

var obj = {lang:'zh'}
obj.toString() // '[object Object]'

var fn = function(){}
fn.toString() // 'function(){}'

null.toString() // Cannot read property 'toString' of null

undefined.toString() // Cannot read property 'toString' of undefined

以上是各数据类型使用toString()方法转换为字符串的结果,除了nullundefined不能转换以外,其他数据都有自己的方式变为字符串。

其实toString是对象上的方法,每一个对象上都有这个方法,那就意味着数字字符串布尔值这些基本数据类型不能使用toString()方法,但上例中的基本数据类型却是可以使用,这要归功于javascript中的包装类,即NumberStringBoolean。原始值不能有属性和方法,当要使用toString()方法时,会先将原始值包装成对象再使用。

所以现在可以知道,上例中使用到的toString()方法分别属于NumberStringBooleanArrayObjectFunction这些类。我们又知道在JavaScript中,所有类都继承自Object,既然是继承,那么toString()方法理应也被继承了,但看上例中的结果,显然toString()并没有被继承,不然所有的输出结果应该都类似于'[object Object]'这样。

其实各数据类型使用toString()后的结果表现不一的原因在于:所有类在继承Object的时候,改写了toString()方法。原始Object上的toString()方法是可以输出数据类型的,如上例中的'[object Object]'这个结果,所以当我们想要判断数据类型时,必须使用Object上的toString()方法。

验证:上面解释了为什么要使用Object上的toString()方法来判断数据类型,是因为其他类上的toString()方法被改写了。为了加深理解,我们可以举个例子验证一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 定义一个数组
var arr = [1, 2, 3]

// 数组原型上是否具有 toString() 方法
console.log(Array.prototype.hasOwnProperty('toString')) //true

// 数组直接使用自身的 toString() 方法
console.log(arr.toString()) // '1,2,3'

// delete操作符删除数组原型上的 toString()
delete Array.prototype.toString

// 删除后,数组原型上是否还具有 toString() 方法
console.log(Array.prototype.hasOwnProperty('toString')) //false

// 删除后的数组再次使用 toString() 时,会向上层访问这个方法,即 Object 的 toString()
console.log(arr.toString()) // '[object Array]'

当我们删除了Array自己的toString()方法后,再次使用时会向上查找这个方法,即ObjecttoString(),这是原型链的知识。

//第一个Object表示这是一个复杂数据类型Object,然后细分出来就是第二个,比如Object、Number、Array、Function……

hasOwnProperty

1
2
3
4
5
6
7
8
9
// Poisoning Object.prototype
Object.prototype.bar = 1;
var foo = {goo: undefined};

foo.bar; // 1
'bar' in foo; // true

foo.hasOwnProperty('bar'); // false
foo.hasOwnProperty('goo'); // true

判断一个属性是定义在对象本身而不是继承自原型链,我们需要使用从 Object.prototype 继承而来的 hasOwnProperty 方法。
hasOwnProperty 方法是 Javascript 中唯一一个处理对象属性而不会往上遍历原型链的。

typeof返回几种

JS中typeof返回结果有七种如下:

number (数字)

1
2
3
4
typeof(10);
typeof(NaN);
//NaNJavaScript中代表的是特殊非数字值,它本身是一个数字类型。
typeof(Infinity);

boolean(布尔)

string(字符串)

object(对象)

1
2
3
对象,数组,null返回object
typeof(null);
typeof(window);

function(函数)

1
2
typeof(Array);
typeof(Date);

undefined(未定义)

1
2
typeof(undefined);
typeof(a);//不存在的变量

symbol

1
typeof Symbol() // ES6提供的新的类型

注意类型的话分为基本类型跟复杂类型

基本类型:

  • undefined

  • null

  • number

  • string

  • Boolean

  • Symbol

    通过 Symbol 方法创建值的时候不用使用 new 操作符,原因是通过 new 实例化的结果是一个 object 对象,而不是原始类型的 symbol

    1
    2
    3
    4
    5
    // 即使是传入相同的参数,生成的 symbol 值也是不相等的,因为 Symbol 本来就是独一无二的意思

    const foo = Symbol('foo');
    const bar = Symbol('foo');
    console.log(foo === bar); // false

复杂类型: Object

Object.prototype.toString.call()与Object.prototype.constructor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var number = 1;          // [object Number]
var string = '123'; // [object String]
var boolean = true; // [object Boolean]
var und = undefined; // [object Undefined]
var nul = null; // [object Null]
var obj = {a: 1} // [object Object]
var array = [1, 2, 3]; // [object Array]
var date = new Date(); // [object Date]
var error = new Error(); // [object Error]
var reg = /a/g; // [object RegExp]
var func = function a(){}; // [object Function]
function checkType() {
for (var i = 0; i < arguments.length; i++) {
console.log(Object.prototype.toString.call(arguments[i]))
}
}
checkType(number, string, boolean, und, nul, obj, array, date, error, reg, func)
console.log(Object.prototype.toString.call(Math)); // [object Math]
console.log(Object.prototype.toString.call(JSON)); // [object JSON]

返回创建实例对象的 Object 构造函数的引用。注意,此属性的值是对函数本身的引用,而不是一个包含函数名称的字符串。对原始类型来说,如1true"test",该值只可读。

所有对象都会从它的原型上继承一个 constructor 属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var o = {};
o.constructor === Object; // true

var o = new Object;
o.constructor === Object; // true

var a = [];
a.constructor === Array; // true

var a = new Array;
a.constructor === Array // true

var n = new Number(3);
n.constructor === Number; // true

基本数据类型和复杂数据类型区别

内存的分配不同

基本数据类型存储于栈中;

复杂数据类型存储在堆中,栈中存放指向堆中的地址。

值的结构不同

基本数据类型值指的是简单的数据段。

复杂的数据类型值是由一个或多个值构成的对象。

复制变量时不同

基本数据类型拷贝时,拷贝的是值,并且原变量和拷贝后的变量是相互独立的,这两个变量可以参与任何操作而不会相互影响;

复杂数据类型拷贝时,也就是所说的引用类型的值拷贝时,副本拷贝的是指针,而这个指针指向存储在堆中的一个对象。复制操作结束后,两个变量实际上引用的是同一个对象,因此,改变其中一个变量,就会影响另一个变量。这里关系到深克隆与浅克隆

传递参数时不同

道理和拷贝一样。

在向参数传递基本类型的值时,被传递的值会被赋值给一个arguments对象中的一个元素(即局部变量);

在向参数传递引用类型的值时,会把对应值的地址复制给一个局部变量,因此局部变量的变化会反映到函数的外部。