浅谈Javascript数据类型与深浅拷贝的实现

我心飞翔 分类: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 浅拷贝的实现方法

常见的方法有

  1. Object.assign()
  2. 展开运算符
  3. Array.slice()
  4. 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相互引用。我们对其进行深拷贝,来看一下控制台输出。
栈溢出.jpg
栈溢出,显然这是由于无限递归造成的。仔细分析一下上述深拷贝代码不难找出原因。每遇到一个引用类型就会递归执行函数,而两个引用类型又相互引用,因此递归会在两个引用类型之间无限执行。
清楚了原因,问题也就迎刃而解。我们只需记住已经拷贝过的属性,当再次遇到该属性时,直接返回该属性而不进行递归。这种思路类似于去重,因此我们可以用字典解决该问题。

// 默认传入一个空字典
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😀:
github.com/eyzqdm/Java…

参考:
www.bilibili.com/video/BV1qE…

回复

我来回复
  • 暂无回复内容