浅谈JavaScript数据类型与深浅拷贝的实现
一.基本数据类型与引用数据类型
JavaScript的数据类型分为基本数据类型和引用数据类型。基本数据类型有六种,分别是Number、String、Boolean、Null、 Undefined以及es6新增的Symbol。基本数据类型是直接存放在栈中的简单数据段,单独分配内存空间,可以按值访问。引用数据类型(Object)的值由于大小不固定且由于堆结构的存储空间比较灵活,因此存放在堆内存(相当于一棵完全二叉树)中,按引用访问。所谓按引用访问就是我们不能直接操作堆内存中的值,在操作对象时,实际操作的是对象的引用而不是存在堆中的值。所谓引用,可以理解为一个指针。该指针存储了与堆中的每个值相对应的一个地址,通过该地址我们可以找到这个值。
二.变量的复制
基本类型的复制,系统会为新声明的变量分配内存,这意味着赋值完成后,复制与被复制的变量除了值一样外,毫无关系。 而引用类型则不同,其复制只是引用的复制,即新的值也是一个指针,它同样指向堆内存中的值。两个指针尽管相互独立,但他们指向的值却是一样的。因此复制与被复制的对象会产生关联,即当通过其中一个指针改变堆内存中的值,另一个指针的值也会发生变化。下面通过一个例子印证一下。
let a = 2
let b = a
b = 3
console.log(a,b) //2,3
let obj1 = {
name:'小明',
age:18
}
let obj2 = obj1
obj2.name = '小红'
console.log(obj1) // { name: '小红', age: 18 } */
可以看到,对引用类型直接进行复制,复制与被复制的对象会产生关联。那如何能让他们彼此独立呢?可以通过拷贝来完成。
三.浅拷贝与深拷贝
1 浅拷贝
1.1 什么是浅拷贝
创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。如果属性是基本类型,拷贝的就是基本类型的值,如果属性是引用类型,拷贝的就是内存地址,所以如果其中一个对象改变了这个地址,就会影响到另一个对象。什么意思呢?可以概括为两点:
1.浅拷贝会在内存中创建一个新对象,这就区别于对象的直接赋值。直接赋值只是引用的赋值,不会创建新对象。
2.如果被浅拷贝的对象的属性全是基本类型,那么拷贝与被拷贝的对象之间不会产生关联。如果属性中有引用类型,则会产生关联。即浅拷贝只会对对象的第一层级进行拷贝。后续层级则还是引用的复制。
1.2 浅拷贝的实现方法
常见的方法有
- Object.assign()
- 展开运算符
- Array.slice()
- Array.concat()
下面以Object.assign()为例说明浅拷贝
const obj = {
a:1,
b:{
c:2
}
}
let target = {}
Object.assign(target,obj)
console.log(target) // { a: 1, b: { c: 2 } }
obj.a = 11
obj.b.c = 3
console.log(target) // { a: 1, b: { c: 3 } }
需要注意的是Object.assign()不会拷贝对象继承的属性,不会拷贝不可枚举的属性(Object.defineProperty()中设置enumerable为false),可以拷贝Symbol类型。
1.3 手动实现浅拷贝
思路:1.判断传入的对象类型 2.创建对象 3.循环赋值
// 判断是不是object类型
function isObject(obj) {
// null、对象、数组返回的都是object类型
return typeof obj === "object" && obj !== null;
}
function shallowClone (obj){
if (!isObject(obj)) { // 该方法判断是不是引用类型
throw new Error('obj 不是一个对象!')
}
const _obj = Array.isArray(obj)? []:{}
// 使用Reflect.ownKeys可以访问symbol类型
Reflect.ownKeys(obj).forEach(key => {
_obj[key] = obj[key]
})
return _obj
}
let obj1 = {
a: 1,
b: {
c: 2,
},
f: function () {
console.log("hello");
},
};
let sym = Symbol('Symbol')
obj1[sym] = 111
let obj2 = shallowClone(obj1);
obj2.a = 3
obj2.b.c = 4
console.log(obj2[sym]) // 111
console.log(obj2.a,obj1.a) // 3,1
obj2.f() // hello
console.log(obj1.b.c) // 4 修改了obj2的该属性,obj1该属性也一起变化
可以看到,经过浅拷贝得到的对象的第一层级与原对象不会产生关联。
且Symbol类型,函数类型的属性都可以拷贝。但无法拷贝引用类型
2 深拷贝
2.1 什么是深拷贝
顾名思义,无论对象的属性是基本类型还是引用类型,他都会将这个对象从堆内存中完整的拷贝出来。并在堆内存中开辟一个新的区域来存放拷贝出来的对象。
2.2 深拷贝的实现方法
2.2.1 序列化+反序列化法。
丐版深拷贝,使用JSON对象的parse和stringify方法来实现深拷贝。这也是开发中经常使用的拷贝方法。思路和实现过程比较简单。
function DeepClone(obj) {
return JSON.parse(JSON.stringify(obj))
}
let obj1 = {
a:1,
b:{
c:2
}
}
let obj2 = DeepClone(obj1)
obj1.b.c = 4
console.log(obj2) // { a: 1, b: { c: 2 } }
这种方法缺陷比较明显,具体如下
1.只能拷贝对象和数组。拷贝Date引用类型会变成字符串,拷贝RegExp引用类型会变成空对象。
2.拷贝的对象的值中如果有函数,undefined,symbol则经过序列化后的JSON字符串中这个键值对会消失。
3.无法拷贝对象的循环引用。
2.2.2.循环对象+递归拷贝
前面说到,浅拷贝会在内存中创建一个新对象,并拷贝对象的第一层级(基本类型)。那么深拷贝无非就是在遇到引用类型时进行递归拷贝即可,同时要在函数开头进行判断,基本类型直接返回,引用类型则进行递归拷贝。
function DeepClone(obj) {
if (!isObject(obj)) {
// 非引用类型 直接返回
return obj;
}
const _obj = Array.isArray(obj) ? [] : {};
Reflect.ownKeys(obj).forEach((key) => {
// 引用类型,递归拷贝
_obj[key] = DeepClone(obj[key]);
});
return _obj;
}
let obj1 = {
a: 1,
b: {
c: 2,
},
f: function () {
console.log("hello");
},
};
let obj2 = DeepClone(obj1);
obj2.a = 3;
obj2.b.c = 4;
console.log(obj2.a, obj1.a); // 3,1
console.log(obj1.b.c); // 2
可以看到通过这种方法拷贝得到的对象,与原对象无论是基本类型还是引用类型,都不会产生关联。但此时的拷贝方法还不够完善,它仍然无法解决循环引用的问题。举个例子:
let obj1 = {
a: 1,
b:[1,2],
c:{
d:3
}
};
obj1.b.push(obj1.c)
obj1.c.e = obj1.b
let obj2 = DeepClone(obj1);
console.log(obj2)
上面例子中,obj1的属性b和c相互引用。我们对其进行深拷贝,来看一下控制台输出。
栈溢出,显然这是由于无限递归造成的。仔细分析一下上述深拷贝代码不难找出原因。每遇到一个引用类型就会递归执行函数,而两个引用类型又相互引用,因此递归会在两个引用类型之间无限执行。
清楚了原因,问题也就迎刃而解。我们只需记住已经拷贝过的属性,当再次遇到该属性时,直接返回该属性而不进行递归。这种思路类似于去重,因此我们可以用字典解决该问题。
// 默认传入一个空字典
function DeepClone(obj, map = new Map()) {
if (!isObject(obj)) {
return obj;
}
const _obj = Array.isArray(obj) ? [] : {};
// 之前已经拷贝过该属性 直接返回 避免循环递归
if (map.has(obj)) return map.get(obj);
// 未拷贝过 添加到字典中
map.set(obj, _obj);
Reflect.ownKeys(obj).forEach((key) => {
// 每次递归调用时传入该字典
_obj[key] = DeepClone(obj[key], map);
});
return _obj;
}
解决循环引用.jpg
通过控制台打印结果看到,成功对该对象进行了拷贝且实现了引用类型属性的循环引用。
此时的深拷贝仍然有待完善,我们无法拷贝原型链上的属性,这是因为Reflect.ownKeys方法不能获取对象原型链上的属性,因此也就无法对其拷贝。我们知道for..in循环能获取原型链的属性。但又不能拷贝Symbol。这里我们可以使用Object.getOwnPropertySymbols()方法来获取对象的Symbol类型的属性。
接下来继续完善。
function DeepClone(obj, map = new Map()) {
if (!isObject(obj)) {
return obj;
}
let _obj = Array.isArray(obj) ? [] : {};
if (map.has(obj)) return map.get(obj);
map.set(obj, _obj);
// 获取源对象所有的 Symbol 类型键
let symKeys = Object.getOwnPropertySymbols(obj);
// 拷贝 Symbol 类型键对应的属性
if (symKeys.length) {
symKeys.forEach((symKey) => {
_obj[symKey] = DeepClone(obj[symKey], map);
});
}
// 拷贝可枚举属性(包括原型链上的)
for (let key in obj) {
_obj[key] = DeepClone(obj[key], map);
}
return _obj;
}
let sym = Symbol("Symbol");
let obj1 = {
name: "xiaom",
};
let obj2 = {
a: 1,
};
obj2[sym] = "aaa";
// 将obj1接入obj2的原型链
Object.setPrototypeOf(obj2, obj1);
let cloneObj = DeepClone(obj2);
console.log(cloneObj)// { a: 1, name: 'xiaom', [Symbol(Symbol)]: 'aaa' }
console.log(cloneObj.name); // xiaom
可以看到,无论是Symbol类型还是原型链上的属性均可以实现拷贝。但仍然要说明,这种方法也只能实现对象和数组的深拷贝。对于其他引用类型如RegExp等,由于他们的构造函数比较特殊,该方法无法拷贝。
2.2.3 Lodash
Lodash是一个强大的JS工具函数库,其cloneDeep深拷贝方法支持多种引用类型的拷贝。
源码地址,欢迎star😀:
https://github.com/eyzqdm/Javascript-basics/blob/master/deepClone.js
参考:
https://www.bilibili.com/video/BV1qE411K7tS?p=5