JS笔记——浅拷贝和深拷贝

171 阅读9分钟

基本数据类型和引用数据类型

JavaScript的基本数据类型有这几种:Boolean,Null,Undefined,Number,String,Symbol(ES6)。

以上6中数据类型通过赋值(一个变量赋值到另一个变量),不会互相影响。

let a = 1;
let b = a;
a = 2;
console.log(a)// 2
console.log(b)// 1

引用数据类型:引用类型值,它是保存在堆内存中的一个对象,引用类型是一种数据结构,最常用的就是Object,Array,Function类型,另外还有Date,RegExp,Error等,es6也提供了Set,Map两种新的数据结构。

引用数据类型的赋值与基本数据类型的赋值时不同的,如下所示

let obj1 = {a:1};
let obj2 = obj1;
obj2.a = 2;
console.log(obj1) //{a:2}
console.log(obj2) //{a:2}

可见,这里只修改了obj2的a属性,但是obj1也发生了改变。

当变量赋值引用类型的时候,本质上时将一个新的引用类型变量变量压入栈中,栈中变量存放的是堆内存中存放的是该对象的地址,然后,这些变量相当与指针指向堆内存中的地址,去访问这些变量。

因此上述的例子,可以见栈中obj1和obj2变量都是指向堆内存中同一个地址,这样obj2中的属性发生变化,obj1也会跟着发生变化。

深浅拷贝的出现,就和这些数据类型有关。

比如,如果用对象A直接赋值给对象B,若修改了对象A属性的值,则会导致B对象的属性也会修改。

这个时候就引出了浅拷贝的概念。

浅拷贝

定义:创建一个新的对象,把原有的对象属性值,完整地拷贝过来,其中包括了原始类型的值,还有引用类型的内存地址。

对象浅拷贝

Object.assign

我们可以用Object.assign()方法来改变上面的例子

语法:Object.assign(target,...sources)

const obj = {a:1, b:2};
const obj2 = Object.assign({}, obj);
obj2.a = 3;
console.log(obj.a); // 1

首先通过Object.assgin()将source拷贝到target对象中,这样可以看见改变obj2的a属性,但是obj的a并没有发生变化。

但是这种拷贝有缺陷,对于多层引用数据类型会出现这样的问题。

let target = {};
let source = { a: { b: 2 } };
Object.assign(target, source);
console.log(target); // { a: { b: 10 } };
source.a.b = 10;
console.log(source); // { a: { b: 10 } };
console.log(target); // { a: { b: 10 } };

这样可以看到,当修改来源对象中b对象的a属性时,会出现目标对象中b对象的a属性也发生了变化了,可以见

Object.assign()就是一个浅拷贝。

可以,浅拷贝也可以这样定义:只拷贝了第一层的原始类型值,和第一层的引用类型地址。

此外,Object.asssign()还有一些需要注意的点是Object.assign方法只会拷贝源对象可枚举的和自身的属性(排除了继承的户型)到目标对象,包括了Symbol属性。

Object.assign会从左到右遍历源对象的所有属性,将所有数据赋值到目标对象

let obj = {
  a:{
    b:1
  },
  [Symbol('foo')]:2
}
Object.defineProperty(obj,'c',{
  value:'不可枚举的属性',
  enumerable:false
})
let obj1 = {};
Object.assign(obj,obj1)
obj.a.b=2;
console.log('obj',obj)
console.log('obj1',obj1)

可见Symbol类型的属性被正确拷贝了,不可枚举的属性c被忽略,obj.a.b属性被修改,obj1.a.b也跟一起修改,说明第二层引用数据类型依然访问的是堆内存中的同一个对象。

此外,Object.assgin()方法会把基本类型包装成对象,如下mdn的例子

const v1 = 'abc';
const v2 = true;
const v3 = 10;
const v4 = Symbol('foo');

const obj = Object.assign({}, v1, null, v2, undefined, v3, v4);
// Primitives will be wrapped, null and undefined will be ignored.
// Note, only string wrappers can have own enumerable properties.
console.log(obj); // { "0": "a", "1": "b", "2": "c" }

可见,Object.assgin()方法会忽略null和undefined两个数据类型,同时只有可枚举的对象,会通过访问内部的遍历器将他转换为一个类数组的对象。

扩展运算符

使用扩展运算符可以在构造字面量对象的时候,进行属性拷贝。用法如下

let cloneObj = {...obj};

使用实例:

let obj1 = {a:1,b:{c:1}}
let obj2 = {...obj1};
obj1.a = 2;
console.log(obj1); //{a:2,b:{c:1}}
console.log(obj2); //{a:1,b:{c:1}}
obj1.b.c = 2;
console.log(obj1); //{a:2,b:{c:2}}
console.log(obj2); //{a:1,b:{c:2}}

扩展运算符和Object.assign()实现浅拷贝的功能差不多,如果属性都是基本类型的值,使用扩展运算符进行浅拷贝的话会更方便。

数组浅拷贝

Array.prototype.slice()

slice()方法是JavaScript数组方法,该方法可以从已有数组中返回选定的元素,不会改变原来的数组

用法:

array.slice(start,end)

该方法有两个参数:

第一个参数start:规定从何处开始选取。如果是负数,那么从数组的末尾开始算起。

第二个参数end:规定从何处选取结束。如果是负数,也是从数组的末尾开始算起。

这样,两个参数都不写,就可以实现一个数组的浅拷贝

let arr1 = [1,2,3,4]
let arr2 = arr1.slice()
console.log(arr1 === arr2)// false

可见,slice方法不会修改原数组,只会返回一个浅拷贝了 原数组的元素的一个新数组,相当于在堆内存中开辟了一个新的内存空间。

因此,它的缺陷和对象的浅拷贝一样,如果拷贝的数据类型是一个引用类型,修改该值的时候,会影响另一个相同的元素。

Array.prototype.concat()

concat()方法用于合并两个或多个数组,此方法不会改变原来的数组,而是返回一个新数组。

使用方法如下:

arrayObject.concat(array1, array2,...,arrayN)

该方式的参数是arrayX是一个数组或值,将被合并到arrayObject数组中。如果concat()里面没有参数,那么将实现一个浅拷贝。

let arr1 = [1,2,3,4]
let arr2 = arr1.concat()
console.log(arr1 === arr2)// false

如上的Array.prototype.slice()方法实现浅拷贝的原理一样,也是在堆内存空间中开辟一个块新的空间,当然也会产生浅拷贝出现的问题

深拷贝

因此为了也能解决浅拷贝中,有些元素依然是引用类型还是会实现互相影响的效果,所以产生了深拷贝这个概念。

深拷贝的定义:对于复杂数据类型在堆内存中开辟一内存地址用于存在复制的对象并把原有的对象复制过来,这2个对象是互相独立的,也就是两个不同的地址。

也就是说,当遇到对象时,就再新开一个对象,然后将第二层源对象的属性值,完整地拷贝到这个新开的对象中。

很容易就想到通过递归浅拷贝来实现深拷贝,思路是使用for...in循环来遍历传入参数的属性值,如果值是基本类型就直接复制,如果是引用类型则继续解析递归调用该函数。

递归浅拷贝

 function deepClone(obj){
        if(!obj && typeof obj !== 'object'){
            return
        }
        var result = Array.isArray(obj) ? [] : {}
        for(let key in obj){
            if(obj[key] && typeof obj[key] === 'object'){
                result[key] = deepClone(obj[key])
            }else{
                result[key] = obj[key]
            }
        }
        return result
    }
    let arr = [{a:1,b:2},{a:3,b:4}];
    let newArr = deepClone(arr);
    newArr=newArr.slice(0,1)
    console.log(newArr);
    console.log(arr);

    newArr[0].a = 123;
    console.log(arr[0])

这样就实现了一个比较简单的深拷贝。

但是还是存在一些问题的。对于很多问题有如下解决方法

1.使用Reflect.ownKeys()方法来解决不能复制不可枚举属性以及Symbol类型的问题。

2.当参数值为Date, RegExp类型时,直接生成一个新的实例并返回。

3.利用Object.getOwnPropertyDescriptors()方法来获得对象的所有属性以及对应的特性。简单来说,这个方法返回了给定对象的所有属性的信息,包括getter和setter的信息。

4.使用Object.create()方法创建一个新对象,并继承传入源对象的原型链。

5.使用WeakMap类型座位Hash表,WeakMap是弱引用类型,可以防止内存泄漏(这个是关键,如果WeakMap保存的结点,在其他地方都没有被引用到会自动删除),因为是用WeakMap主要是为了解决循环引用的问题,这个数据结构就是用来记录每个引用对象有没有被使用过。

进阶版深拷贝实现方法如下:

function deepClone (obj, hash = new WeakMap()) {
  // 日期对象直接返回一个新的日期对象
  if (obj instanceof Date){
  	return new Date(obj);
  } 
  //正则对象直接返回一个新的正则对象     
  if (obj instanceof RegExp){
  	return new RegExp(obj);     
  }
  //如果循环引用,就用 weakMap 来解决,如果weakMap中有一样的值,那么就返回weakMap中的值
  if (hash.has(obj)){
  	return hash.get(obj);
  }
  // 获取对象所有自身属性的描述
  let allDesc = Object.getOwnPropertyDescriptors(obj);
  // 遍历传入参数所有键的特性,继承原型链
  let cloneObj = Object.create(Object.getPrototypeOf(obj), allDesc)
  
  hash.set(obj, cloneObj)
  
  for (let key of Reflect.ownKeys(obj)) { 
    if(typeof obj[key] === 'object' && obj[key] !== null){
    	cloneObj[key] = deepClone(obj[key], hash);
    } else {
    	cloneObj[key] = obj[key];
    }
  }
  return cloneObj
}

JSON.stringify()

另一种,简单粗暴的深浅拷贝方法是使用JSON方法了。

它的原理就是利用JSON.stringify将JS对象序列化为JSON字符串,并将对象里面的内容转换为字符串,再使用JSON.parse来反序列化,将字符串生成一个新的JS对象。

let obj1={
  a: 0,
  b: {
    c: 0
  }
};
let obj2 = JSON.parse(JSON.stringify(obj1));
obj1.a = 1;
obj1.b.c = 1;
console.log(obj1);//{a:1,b:{c:1}}
console.log(obj2);//{a:0,b:{c:0}}

但是这个方法有如下几个问题:

  1. 拷贝的对象中如果有函数,undefined,symbol,当使用过JSON.stringify()进行处理之后,都会消失。
  2. 无法拷贝不可枚举的属性。
  3. 无法拷贝对象的原型链
  4. 拷贝Date引用类型会变成字符串
  5. 拷贝RegExp引用类型会变成空对象;
  6. 对于对象中含有NaN, Infinity以及-Infinity, JSON序列化的结果会变成null
  7. 无法拷贝对象循环应用,即对象成环
  8. 爆栈的情况,即存储的信息量大于系统栈的内存

函数库lodash

可以直接使用函数库lodash中提供的_.cloneDeep用来做深拷贝,可以直接引入并使用:

var _ = require('lodash');
var obj1 = {
    a: 1,
    b: { f: { g: 1 } },
    c: [1, 2, 3]
};
var obj2 = _.cloneDeep(obj1);
console.log(obj1.b.f === obj2.b.f);// false

总结

  1. 对于只有引用数据类型只有一层的情况下,可以使用浅拷贝
  2. 深拷贝是在浅拷贝的基础上,进行递归并对一些复杂数据类型和特殊情况进行判断
  3. 对于复杂的情况数据结构进行拷贝,建议使用lodash函数库更好一些
  4. 日常的开发任务中,JSON.stringify()序列化,已经可以满足大部分深拷贝的需求了

参考资料

聊聊对象的深拷贝和浅拷贝

如何实现一个深浅拷贝