JS中的赋值与深浅拷贝

本文首发于个人博客:www.wyb.plus

0. 前言

JS中的深浅拷贝是一个十分难以理清的概念 , 很多人其实都对这个概念都没有真正理解。

  • 作者:王雨波
  • 时间:2020年5月4日
  • qq:760478684
  • 博客:www.wyb.plus

1. 数据类型

首先要了解数据类型,在JS中数据类型分为两大类型:分别是原始数据类型引用数据类型。原始数据类型又叫基本数据类型。

  • 原始数据类型包括:NumberStringBooleanNullundefinedSymbol(ES6引入)
  • 引入数据类型包括:ObjectArrayFunction

两种数据类型的不同:

  • 基本数据类型的特点:直接存储在栈(stack)中的数据
  • 引用数据类型的特点:栈中存储的是该对象的引用,真实的数据存放在堆内存里。

栈与堆还有队列这里不深入展开。但是要知道的是,引用数据类型的查找,是先从栈内存中取得该数据的引用(也就是取得该数据的地址,可以理解为指针,但是JS中没有指针的概念),然后根据该地址去堆内存空间中取得该值;而原始数据类型的查找是直接从栈内存中读取值。

2. 深拷贝与浅拷贝的不同

过程的不同:

  • 深拷贝是在堆内存中开辟一块新的内存空间,将原数据完整地复制一份,放到新的堆内存中。

深拷贝原理

  • 浅拷贝将原数据的 “指针” 拷贝一份,新“指针”也指向原来的指针所指向的空间,但是堆内存的空间只有一个。

浅拷贝原理

结果的不同:

  • 深拷贝在拷贝后是有两个堆内存空间,原数据与拷贝后的数据互不影响,两个数据独立不关联
  • 浅拷贝在拷贝后只有一个堆内存空间,指向这个空间的两个 “ 指针” 都能够操作这个数据

3. 赋值与深浅拷贝的不同

条件 >>>

基本类型简单对象复杂对象这三个条件出发,比较赋值与拷贝的不同。

基本数据类型的赋值 >>>

let a = 10;
let b = a;
console.log("a=" + a, "b=" + b);
b = 20;
console.log("a=" + a, "b=" + b);

在这里插入图片描述

基本数据类型的赋值是在栈空间完成的,不存在引用,所以a就是a,b就是b,完全独立。

简单对象的赋值 >>>

// 定义一个简单对象a
let a = {
   name: "wyb",
      age: 18,
      sex: null,
      handsome: undefined,
      student: true
  }
  // 把a赋值给b
  let b = a;
  // 此时第一次打印a和b
  console.log(a, b);
  // 修改b的属性
  b.name = "wangyubo";
  b.age = 23;
  b.sex = null;
  b.handsome = true;
  b.student = undefined
  // 第二次打印a和b
  console.log(a, b);

第一次打印a和b的结果:

 [

发现a和b完全一样,但是也不能说明他们指向同一个对象。

第二次打印a和b的结果:

在这里插入图片描述

第二次打印是在b被修改之后,发现a和b一模一样,所以修改b时,a也被修改了,所以可以得出结论:简单对象的赋值,是复制对象的指针,并不生成一个新对象,跟“浅拷贝”是一样的效果。

复杂对象的赋值>>>

// 定义一个复杂对象a
let a = {
name: "wyb",
hobby: ["唱", "跳", "rap", "篮球"],
girlfriends: {
first: "桥本环奈",
second: "斋藤飞鸟"
	}
};
// 把a赋值给b
let b = a;
//第一次打印a和b
console.log(a, b);
// 修改b的属性
b.name = "wangyubo";
b.hobby[0] = "多人运动打篮球";
b.girlfriends.first = "呆头鹅";
//第二次打印a和b
console.log(a, b);

两次打印的结果:

在这里插入图片描述

可以看出:

修改b后,a变的和b一样了,所以可以判断出a和b指向同一个对象。也就是说,复杂对象的赋值,也是复制一份指针,两个变量同时指向同一个对象。相当于“浅拷贝”的效果。

并且可以发现,复杂对象的赋值是完全引用,也就是复杂对象里面的属性的类型为引用类型时,也是复制指针。

小结:当赋值的类型是基本数据类型时,被赋值的对象与赋值对象是两个独立的存在。当赋值的类型为引用数据类型时(无论是简单对象还是复杂对象),被赋值的对象与赋值对象指向同一个对象,两者相互关联,一方的改变会引起另一方的相应改变。

4. 补充

不知道细心的你有没有发现:

在这里插入图片描述

谷歌控制台在第一次打印的时候,name这个属性,展开前和展开后不一样。这是什么原因呢?一开始我是真没搞明白,问了很多人,他们也都说不清楚原理,有同学建议我用其他浏览器试试,于是我又用了火狐,IE11,edge,最后在vscode的终端,以及Cmder上,通过node环境去运行,结果让我更加懵逼了。主流浏览器,IE系,以及node环境中,给了我三个不同的结果。

让我们关注两点:第一点是第一次打印的ab没展开的name和展开的name,

第二点是,两次打印的属性值的不同。

首先是主流浏览器,chrome和Firefox >>>

chrome

在这里插入图片描述

firefox

在这里插入图片描述

在这里插入图片描述

这两个第一次打印的时候,没展开的name都是wyb,展开的name变成了wangyubo,并且修改前和修改后的两次打印的结果完全一致也就是说在修改前打印a和b,输出的却是修改后的值。

IE系 >>>

IE11

在这里插入图片描述

edge:

在这里插入图片描述

IE11和edge里面可以看到展开的name和没展开的name都是wyb, 而更具体地属性,ie11是undefined看不出来有没有被修改,从edge里面可看到,第一次打印的时候,也就是修改前输出,除了name是被修改前的值外,girlfriendshobby的属性都是修改后的值。

在node环境中 >>>

vs code终端:

在这里插入图片描述

Cmder:

在这里插入图片描述

从这两张图中我们可以看出,在node环境下,第一次输出的结果为完全的没修改的值,第二次输出的结果是被修改后的值

以上就是我发现的同一段代码,在这三种环境下给我返回的三个结果。

经过一番查找和求证以后,得出以下结论 >>>

  • Webkit的console.log很多时候不能真实的反应js时序,因为是异步的内存快照;也就是说它在输出一个引用类型的值时,并不会立即拍摄对象快照,相反,它只存储了一个指向对象的引用,然后再代码返回时间队列时才去拍照。

  • 在谷歌控制台中,点击我们输出的对象旁边的蓝色i , 提示我们value below was evaluated just now 译为:刚才评估了下面的值。这也说明了展开的属性是评估后的值,也就是被修改了的值。

  • node的console.log是另一回事,它是严格同步的。

在这里插入图片描述

根据这三个结论对三个结果进行分析:

  • 首先是主流浏览器,没展开的里面存储的是同步的快照,也就是修改前的值,展开后显示的是评估后的值,这就是因为此时的console.log是异步的。这种情况下,效果相当于浅拷贝,并且是完全的浅拷贝。
  • 在edge中,第一次打印展开前和展开后都是一样的,但是,它的复杂对象中的值为原始数据类型时,是同步的,因为输出修改前的name时就是输出的修改前的name,没有被修改,而复杂对象中的值为引用类型时,它又是异步的,因为修改前的hobbygirlfriends里面的属性输出的是修改后的属性。
  • 在node环境中,那就很简单了,因为他是严格同步的,修改前输出的就是修改前的值,修改后输出的就是修改后的值。

最后,无论是在这三种环境的哪一种中,修改后的a和b都是保持一致的。所以无论哪种环境,对我们上一节中的复杂对象的赋值是完全引用的结论都没有影响的。

5.深浅拷贝

回到正题深浅拷贝上来,当我们在谈论深前拷贝时,一定要清楚深浅拷贝的使用前----复杂对象。只有复杂对象才会有深拷贝与浅拷贝的说法。

一些能够实现拷贝的API(包括但不限于)

5.1 Object.assign()

Object.assign() 方法可以把任意多个的源对象自身的可枚举属性拷贝给目标对象,然后返回目标对象。

语法:

Object.assign(target, ...sources)

其中 target 是目标对象,sources 是源对象,可以有多个,返回修改后的目标对象 target

如果目标对象中的属性具有相同的键,则属性将被源对象中的属性覆盖。后来的源对象的属性将类似地覆盖早先的属性。

示例:

//定义一个复杂对象
let a = {
         name: "wyb",
         hobby: ["唱", "跳", "rap", "篮球"],
         girlfriends: {
             first: "桥本环奈",
             second: ["新垣结衣", "斋藤飞鸟", "迪丽热巴", "马尔扎哈"]
         }
     };
//通过Object.assign方法把a对象中所有可枚举的对象添加给一个新对象,用变量b保存
     let b = Object.assign方法把a对象中所有可枚举的对象添加给一个新对象({}, a)
     console.log(a, b);
     console.log(a === b);

很多人都说Object.assign是浅拷贝的方法,可是我想问,如果他是浅拷贝,那么拷贝后的对象和原对象必然是同一个对象。可是,

在这里插入图片描述

从输出结果来看,a和b长的完全一样,但是,判断结果为false。而众所周知,判断两个对象是否相等,是看他们的堆内存的空间是否是同一个地址,如果是浅拷贝,那么就拥有同一地址。现在的结果为false说明通过Object.assign拷贝后的数据拥有新的内存地址,而这是深拷贝才有的特性,浅拷贝是不生成新的内存地址的。

// let b = Object.assign({}, a)
     let b = a;
		console.log(a, b);
     console.log(a === b);

在这里插入图片描述

如果我们不使用Object.assign的方法,而是直接通过的赋值的方法,那么得到的结果才为true,这才能证明是浅拷贝。

所以我觉得Object.assign不能算是严格的浅拷贝的方法

继续往下:修改b的属性

// 定义一个复杂对象a
     let a = {
         name: "wyb",
         hobby: ["唱", "跳", "rap", "篮球"],
         girlfriends: {
             first: "桥本环奈",
             second: ["新垣结衣", "斋藤飞鸟", "迪丽热巴", "马尔扎哈"]
         }
     };
     let b = Object.assign({}, a)
     // let b = a;
     // console.log(a, b);
     // console.log(a === b);
     b.name = "wangyubo";
     b.hobby[0] = "多人运动打篮球";
     b.girlfriends.second[0] = "呆头鹅";
     console.log(a, b);

在这里插入图片描述

继续往下,我们发现,通过修改b的属性,a中的某些属性相应地改变了,而有些属性又没有被改变。

没有被改变的是复杂对象中的属性的类型为原始类型的值,也就是这里的name它的值是字符串类型,被改变了的有数组和对象,以及对象中的数组,这些是引用类型的值。

所以我们可以判断出,Object.assign在拷贝时,如果复杂对象的属性值的类型为原始数据类型时,是深拷贝的;如果复杂对象的属性值的类型为引用类型时,是浅拷贝的。

5.2 Array.prototype.concat()

Array.prototype.concat()是数组的内置方法,用于合并多个数组,不会改变原数组,返回新数组。

示例:

// 定义一个复杂数组
     let a = [1, [2, 3], {
         name: "wyb"
     }]
     let b = a.concat([])
     console.log(a, b);
     console.log(a === b);

在这里插入图片描述

concat()方法和上述一样,不是一个纯粹的浅拷贝方法。

修改:

     // 定义一个复杂数组
     let a = [1, [2, 3], {
         name: "wyb"
     }]
     let b = a.concat([])
         // console.log(a, b);
         // console.log(a === b);
     b[0] = 9;
     b[1][0] = 8;
     b[2].name = "wangyubo";
     console.log(a, b);

在这里插入图片描述

结果仍然和上述一样,当复杂对象中的属性的值类型是原始数据类型时,是深拷贝,为引用类型时,是浅拷贝。

5.3 Array.prototype.concat()

slice() 也是数组的一个内置方法,该方法会返回一个新的对象,不会改变原数组

示例:

// 定义一个复杂数组
     let a = [1, [2, 3], {
         name: "wyb"
     }]
     let b = a.slice()
     console.log(a, b);
     console.log(a === b);

同样的代码模式,同样的返回值

在这里插入图片描述

同样的修改方法

在这里插入图片描述

同样的结果。

5.4 …扩展运算符
     let a = [1, [2, 3], {
         name: "wyb"
     }]
     let b = [...a]
         // console.log(a, b);
         // console.log(a === b);
     b[0] = 9;
     b[1][0] = 8;
     b[2].name = "wangyubo";
     console.log(a, b);

在这里插入图片描述

扩展运算符也是一样的结果。

5.5 JSON.parse(JSON.stringify())
  • JSON.stringify():将对象转成 JSON 字符串。
  • JSON.parse():将字符串解析成对象。

先把一个对象转成字符串,再将字符串解析成对象,通过这种方法来拷贝。

示例:

 let a = {
            name: "wyb",
            hobby: ["唱", "跳", "rap", "篮球"],
            girlfriends: {
                first: "桥本环奈",
                second: ["新垣结衣", "斋藤飞鸟", "迪丽热巴", "马尔扎哈"]
            }
        };
        let b = JSON.parse(JSON.stringify(a))
            //     // let b = a;
        console.log(a, b);
        console.log(a === b);

在这里插入图片描述

从结果中我们可以看到,虽然这两个对象一模一样,但是并不相等。

修改:

  // 定义一个复杂对象a
        let a = {
            name: "wyb",
            hobby: ["唱", "跳", "rap", "篮球"],
            girlfriends: {
                first: "桥本环奈",
                second: ["新垣结衣", "斋藤飞鸟", "迪丽热巴", "马尔扎哈"]
            }
        };
        let b = JSON.parse(JSON.stringify(a))
            //     // let b = a;
            // console.log(a, b);
            // console.log(a === b);
        b.name = "wangyubo";
        b.hobby[0] = "多人运动打篮球";
        b.girlfriends.second[0] = "呆头鹅";
        console.log(a, b);

在这里插入图片描述

从我标记的几个地方来看,修改b之后a没有跟着改变,无论这个复杂对象的值是原始数据类型还是引用数据类型,这说明了,经过这两个方法的转换之后,确实是深拷贝。

但是这个方法是有一定缺陷的

  1. 不能存放函数或者 Undefined,否则会丢失函数或者 Undefined;
  2. 不要存放时间对象,否则会变成字符串形式;
  3. 不能存放 RegExp、Error 对象,否则会变成空对象;
  4. 不能存放 NaN、Infinity、-Infinity,否则会变成 null;
  5. 总之就是JavaScript和JSON存在差异,两者不兼容就会出问题

小结:

从网上可以看到很多文章把这前四种方法归为浅拷贝,把最后一种方法归为深拷贝,

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

但是在我看来,除了最后一种方法确实算的上是纯粹的深拷贝之外,前四种都算不上纯粹的浅拷贝。

他们只是遵守了复杂对象拷贝时的一般原则:如果复杂对象的值的类型为原始数据类型时,这个值就会被深拷贝;如果复杂对象的值的类型是引用数据类型的话,这个值就会被浅拷贝。

前4个方法都遵守这个原则。之所以会被有些人归纳为浅拷贝,是因为他们直接忽视原始数据类型,如果只是这样认为,那么就称得上是浅拷贝。

还有些人认为,不完全的深拷贝就是浅拷贝。emmmm,也行吧。

6.手写深拷贝

深拷贝:

const deepClone = obj => {
 // 类型控制,传入的所有非对象,都转换成对象
 if (obj === null || typeof obj !== 'object') {
     return obj
 }
 // 再次判断传入的对象是数组还是对象,分别返回一个对应的新容器
 let newObj = Array.isArray(obj) ? [] : {}
     // 遍历这个对象上所有的属性
 for (let key in obj) {
     // 只要这个对象的自有属性,忽略继承属性
     if (obj.hasOwnProperty(key)) {
         // 继续判断obj的子元素是否是object
         if (obj[key] && typeof obj[key] === "object") {
             newObj[key] = deepClone(obj[key]) //处理引用类型
         } else {
             newObj[key] = obj[key]; //处理原始类型
         }
     }
 }
 return newObj
}
//定义一个复杂对象
let a = {
 name: "wyb",
 age: 18,
 girlfriends: null,
 hobby: undefined,
 man: true,
 car: ["宝马", "奔驰", "保时捷"],
 school: {
     first: "one",
     second: "two",
 },
 drive: function() {}
}
//调用自定义的方法
let b = deepClone(a);
console.log(a, b);

在这里插入图片描述

此时的b已经完成了对a的拷贝,并且函数和undefined也已经拷贝过来了。现在测试修改b后a的情况

// console.log(a, b);
b.name = "wangyubo";
b.age = "28";
b.girlfriends = undefined;
b.hobby = null;
b.man = false;
b.car[0] = "凯迪拉克";
b.school.first = "zero";
b.drive = 1;
console.log(a, b);

在这里插入图片描述

b的修改,无论是原始类型还是引用类型,a都不会变。这说明b实现了对a的深拷贝。

7. 参考文章

  • 《浅拷贝和深拷贝(较为完整的探索)》

  • 作者:jsliang

  • 链接:https://juejin.im/post/5da7c76a6fb9a04ddc625014

  • 来源:掘金

  • 《【进阶4-2期】Object.assign 原理及其实现》

  • 作者:木易杨说

  • 链接:https://juejin.im/post/5c31e5c4e51d45524975d05a

  • 来源:掘金

  • 《JavaScript系列: 一、手撕JS中的深浅拷贝》

  • 作者:lmran

  • 链接:https://juejin.im/post/5e906ab8518825738e217414

  • 来源:掘金

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

你是时光 轻轻呵唱

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值