JavaScript深拷贝与浅拷贝
1 什么是深/浅拷贝
浅拷贝👇
浅拷贝就是创建一个新对象,该对象有着原始对象属性值的一份精确拷贝。如果属性是基本类型,拷贝的就是基本类型的值,如果属性是引用类型,拷贝的就是内存地址 ,所以如果其中一个对象改变了这个地址,就会影响到另一个对象。
深拷贝👇
深拷贝是将一个对象从内存中完整的拷贝一份出来,从堆内存中开辟一个新的区域存放新对象,且修改新对象不会影响原对象。
例如,当一个对象people
中包含了另一个对象children
,对people
进行浅拷贝得到了people2
,如下所示:
let people = { // 原对象
name: "张三",
children: {
name: "李四",
age: 2,
},
};
let people2 = Object.assign({}, people); // 对people进行浅拷贝
如果修改people2
中children
的name
和age
,根据浅拷贝特性,我们可以推断出people
的children
也会被修改,因为people2
的children
是引用类型,拷贝的是内存地址,修改people2
会影响people
,如下所示:
people2.children.name = "王五";
people2.children.age = 5;
people // { name: '张三', children: { name: '王五', age: 5 } }
people2 // { name: '张三', children: { name: '王五', age: 5 } }
如果修改people2
的name
,根据浅拷贝特性,我们可以推断出people
的name
不会收到影响,因为people2
的name
是基本类型,拷贝的是people
的name
的值,如下所示:
people2.name = "橘猫吃不胖";
people // { name: '张三', children: { name: '李四', age: 2 } }
people2 // { name: '橘猫吃不胖', children: { name: '李四', age: 2 } }
这时出现了一个特殊情况:如果浅拷贝的对象只有一层的时候,其实就是深拷贝了,示例代码如下:
let people = {
name: "橘猫吃不胖",
age: 2,
};
let people2 = Object.assign({}, people); // 浅拷贝
people2.name = "张三";
people2.age = 4;
people // { name: '橘猫吃不胖', age: 2 }
people2 // { name: '张三', age: 4 }
如果对people
进行深拷贝得到people3
,如下所示:
let people = { // 原对象
name: "张三",
children: {
name: "李四",
age: 2,
},
};
let people3 = JSON.parse(JSON.stringify(people)); // 深拷贝
无论修改people3
的name
还是children
,根据深拷贝特性,我们可以推断都不会影响到people
,因为深拷贝后,people
和people3
使用的是不同的内存地址,如下所示:
people3.name = "赵六";
people3.children.name = "赵六的儿子";
people3.children.age = 8;
people // { name: '张三', children: { name: '李四', age: 2 } }
people3 // { name: '赵六', children: { name: '赵六的儿子', age: 8 } }
2 浅拷贝与赋值的区别
根据上面的描述,我们可能会想,浅拷贝和赋值好像没有什么区别。
其实不然,当我们把一个对象赋值给一个新的变量时,赋的其实是该对象的在栈中的地址,而不是堆中的数据。也就是说两个对象指向了同一个存储空间,无论哪个对象发生改变,都会影响另一个对象。
例如,还是people
对象,将它赋值给people4
,如下所示:
let people = {
name: "张三",
children: {
name: "李四",
age: 2,
},
};
let people4 = people; // 直接赋值
当我们修改people4
的name
或者children
时,根据赋值的特点,我们可以推断people
的name
或者children
都会受到影响:
people4.name = "people4";
people4.children.name = "people4的孩子";
people4.children.age = 1;
people // { name: 'people4', children: { name: 'people4的孩子', age: 1 } }
people4 // { name: 'people4', children: { name: 'people4的孩子', age: 1 } }
与在前面进行浅拷贝得到的people2
相比,people2
修改name
之后并没有影响people
的值,因为浅拷贝对基本类型拷贝了值,这就是两者的区别。
3 浅拷贝的实现
3.1 Object.assign()
Object.assign()
方法在开始就已经说明过了,它本身用于将所有可枚举属性的值从一个或多个源对象分配到目标对象,最后返回目标对象。基本语法如下:
Object.assign(target, ...sources) // target:目标对象;sources:源对象。
如果目标对象target
中的属性与源对象sources
具有相同的键,则相同的属性会被源对象中的属性覆盖。后面的源对象的属性将类似地覆盖前面的源对象的属性。如下所示:
// 目标对象和源对象具有相同的属性b
const target = { a: 1, b: 2 };
const source = { b: 4, c: 5 };
// 浅拷贝,目标对象的b属性值会被源对象覆盖
const returnedTarget = Object.assign(target, source);
target // { a: 1, b: 4, c: 5 }
returnedTarget // { a: 1, b: 4, c: 5 }
使用Object.assign()
进行浅拷贝,示例如下:
let obj1 = {
a: 1,
b: {
c: 1,
d: 1,
},
};
let obj2 = Object.assign({}, obj1); // 进行浅拷贝
obj2.a = 2; // 修改拷贝后的对象
obj2.b.c = 2;
obj2.b.d = 2;
obj1 // { a: 1, b: { c: 2, d: 2 } }
obj2 // { a: 2, b: { c: 2, d: 2 } }
3.2 Array.prototype.concat()
concat()
方法用于合并两个或多个数组。此方法不会更改现有数组,返回了一个新数组。基本语法如下:
var new_array = old_array.concat(value1[, value2[, ...[, valueN]]])
// valueN可选,数组和/或值,将被合并到一个新的数组中。
// 如果省略了所有valueN参数,则concat会返回调用此方法的现存数组的一个浅拷贝。
使用Array.prototype.concat()
进行浅拷贝,示例代码如下:
let arr1 = [1, 2, { name: "橘猫吃不胖" }];
let arr2 = arr1.concat(); // 进行浅拷贝
// 修改arr2
arr2[1] = 99999999;
arr2[2].name = "张三";
arr1 // [ 1, 2, { name: '张三' } ]
arr2 // [ 1, 99999999, { name: '张三' } ]
3.3 Array.prototype.slice()
slice()
方法返回一个新的数组对象,这一对象是一个由begin
和end
决定的原数组的浅拷贝(包括begin
,不包括end
),原始数组不会被改变。
使用Array.prototype.slice()
进行浅拷贝,示例代码如下:
let arr1 = [1, 2, { name: "橘猫吃不胖" }];
let arr2 = arr1.slice(); // 进行浅拷贝
// 修改arr2
arr2[1] = 99999999;
arr2[2].name = "张三";
arr1 // [ 1, 2, { name: '张三' } ]
arr2 // [ 1, 99999999, { name: '张三' } ]
3.4 …扩展运算符
使用...
扩展运算符可以实现浅拷贝,示例代码如下:
let obj1 = {
a: 1,
b: {
c: 1,
d: 1,
},
};
let obj2 = { ...obj1 }; // 进行浅拷贝
obj2.a = 2; // 修改拷贝后的对象
obj2.b.c = 2;
obj2.b.d = 2;
obj1; // { a: 1, b: { c: 2, d: 2 } }
obj2; // { a: 2, b: { c: 2, d: 2 } }
4 深拷贝的实现
4.1 JSON.parse(JSON.stringify())
JSON
是一种语法,用来序列化对象、数组、数值、字符串、布尔值和 null 。
JSON
对象包含两个方法:
- 解析JSON的
parse()
方法JSON.parse()
- 将对象/值转换为
JSON
字符串的stringify()
方法,JSON.stringify()
JSON.parse()
方法用来解析JSON
字符串,构造由字符串描述的JavaScript
值或对象。提供可选的reviver
函数用以在返回之前对所得到的对象执行变换。基本语法如下:
JSON.parse(text[, reviver])
// text:要被解析成 JavaScript 值的字符串
// reviver,可选,转换器, 如果传入该参数(函数),可以用来修改解析生成的原始值,调用时机在 parse 函数返回之前。
JSON.stringify()
方法将一个JavaScript
对象或值转换为JSON
字符串,如果指定了一个replacer
函数,则可以选择性地替换值,或者指定的replacer
是数组,则可选择性地仅包含数组指定的属性。基本语法如下:
JSON.stringify(value[, replacer [, space]])
// value:将要序列化成 一个 JSON 字符串的值。
// replacer,可选,如果该参数是一个函数,则在序列化过程中,被序列化的值的每个属性都会经过该函数的转换和处理;如果该参数是一个数组,则只有包含在这个数组中的属性名才会被序列化到最终的 JSON 字符串中;如果该参数为 null 或者未提供,则对象所有的属性都会被序列化。
// space,可选,指定缩进用的空白字符串,用于美化输出(pretty-print);如果参数是个数字,它代表有多少的空格;上限为10。该值若小于1,则意味着没有空格;如果该参数为字符串(当字符串长度超过10个字母,取其前10个字母),该字符串将被作为空格;如果该参数没有提供(或者为 null),将没有空格。
使用JSON.parse(JSON.stringify())
进行深拷贝,示例代码如下:
let obj1 = {
a: 1,
b: {
c: 1,
d: 1,
},
};
let obj2 = JSON.parse(JSON.stringify(obj1)); // 进行深拷贝
obj2.a = 2; // 修改拷贝后的对象
obj2.b.c = 2;
obj2.b.d = 2;
obj1; // { a: 1, b: { c: 1, d: 1 } }
obj2; // { a: 2, b: { c: 2, d: 2 } }
用JSON.stringify
将对象转成JSON
字符串,再用JSON.parse()
把字符串解析成对象,这样新的对象产生了,实现深拷贝。这种方法虽然可以实现数组或对象深拷贝,但有一些缺点:
- 无法拷贝循环引用
let obj1 = { a: 2 };
obj1.target = obj1; // 循环引用
let obj2 = JSON.parse(JSON.stringify(obj1));
obj2 // TypeError: Converting circular structure to JSON
- 无法拷贝函数
let arr1 = [1, 2, { name: "橘猫吃不胖" }, function () {}];
let arr2 = JSON.parse(JSON.stringify(arr1)); // 进行深拷贝
arr1 // [ 1, 2, { name: '橘猫吃不胖' }, [Function (anonymous)] ]
arr2 // [ 1, 2, { name: '橘猫吃不胖' }, null ]
4.2 函数库lodash
Lodash是一个JavaScript
库,提供了多个实用程序功能,而Lodash
库中最常用的功能之一是cloneDeep()方法。此方法有助于深度克隆对象,还可以打破JSON.parse(JSON.stringify())
方法的局限性,即拷贝不可序列化的属性。
使用cloneDeep()
进行深拷贝,示例代码如下:
const lodash = require("lodash");
let arr1 = [1, 2, { name: "橘猫吃不胖" }, function () {}];
let arr2 = lodash.cloneDeep(arr1); // 进行深拷贝
arr2[0] = 99999;
arr2[3].name = "张三";
arr1; // [ 1, 2, { name: '橘猫吃不胖' }, [Function (anonymous)] ]
arr2; // [ 99999, 2, { name: '橘猫吃不胖' }, [Function (anonymous)] ]
5 实现一个深拷贝
根据深拷贝的特点,我们首先可以实现一个简易版本:
- 如果是基本数据类型,直接返回
- 如果是引用数据类型,就要递归调用深拷贝函数,直到拷贝到最后一层
基础版本如下:
function deepClone(origin) {
// 如果原对象是基本数据类型,直接返回即可
if (!origin || typeof origin !== "object") return origin;
// 根据原对象是数组还是对象,创建一个新的数组或者对象
let target = Array.isArray(origin) ? [] : {};
// 遍历当前对象,放在target中,如果对象中包含下一层,通过递归来遍历
for (let i in origin) {
target[i] = deepClone(origin[i]);
}
// 返回深拷贝对象
return target;
}
这个函数目前还存在一些问题,如果对象存在循环引用,会进入死循环导致栈内存溢出:
let obj1 = { a: 1 };
obj1.target = obj1; // 自己指向自己
deepClone(obj1); // RangeError: Maximum call stack size exceeded
想要解决循环引用,我们可以额外开辟一个可以存储key: value
形式的存储空间,来存储当前对象和拷贝对象的对应关系,当需要拷贝当前对象时,先去存储空间中找,有没有拷贝过这个对象,如果有的话直接返回,如果没有的话继续拷贝。这里我们使用Map
来实现。
- 检查
map
中有无克隆过的对象 - 如果有,直接返回
map
中存储的值,如果没有,在map
中存储
function deepClone(origin, map = new Map()) {
// 如果原对象是基本数据类型,直接返回即可
if (!origin || typeof origin !== "object") return origin;
// 根据原对象是数组还是对象,创建一个新的数组或者对象
let target = Array.isArray(origin) ? [] : {};
// 如果Map中已经存储了当前的对象,直接返回Map存储的结果即可,否则存放origin
if (map.get(origin)) return map.get(origin);
map.set(origin, target);
// 遍历当前对象,放在target中,如果对象中包含下一层,通过递归来遍历
for (let i in origin) {
target[i] = deepClone(origin[i], map);
}
// 返回深拷贝对象
return target;
}
再执行一下上面的例子,可以看到成功拷贝了:
let obj1 = { a: 1 };
obj1.target = obj1;
console.log(deepClone(obj1)); // <ref *1> { a: 1, target: [Circular *1] }