深拷贝和浅拷贝是什么?这一次带你彻底搞懂JS变量在内存中的存储方式

890 阅读9分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

前言

当看到这篇文章的时候,说明你在学习或者工作中已经遇到了js的 深拷贝浅拷贝 问题了。

之所以会出现深拷贝和浅拷贝的问题,就是因为变量的存储有堆栈之分。

本文会详尽讲解:

  • 什么是栈和堆
  • 为什么有 堆 栈 之分
  • 变量在内存中到底是怎么存储的:哪些变量放在栈里?堆里放的又是什么?
  • 简单赋值和对象引用的区别
  • 什么是深拷贝和浅拷贝
  • 如何写一个深拷贝

JS 的原始值和引用值

在ECMAScript中,变量可以存放两种类型的值,即原始值引用值

原始值(primitive value)

原始值是固定而简单的值,是存放在栈(stack)中的简单数据段,也就是说,它们的值直接存储在变量访问的位置。

最新的 ECMAScript 标准定义了7 种原始值:undefinedBooleanNumberStringBigIntSymbolnull

其实在今年的早些时候,null 还不属于原始类型,这个会在下文中贴图展示。

引用值(reference value):

引用值则是比较大的对象,存放在堆(heap)中的对象,也就是说,存储在变量处的值是一个指针(pointer),指向存储对象的内存处。

所有引用类型都集成自Object

如果一个值是引用类型的,那么它的存储空间将从堆中分配。由于引用值的大小会改变,所以不能把它放在栈中,否则会降低查询速度。相反,存放变量的栈空间中的值是该对象存储在堆中的地址。地址的大小是固定的,所以把它存放在栈中对变量性能无任何负面影响。如图:

在这里插入图片描述

原文:Javascript--原始值和引用值

我们看一下MDN对原始值的定义:

原始值( primitive values )

Object 以外的所有类型都是不可变的(值本身无法被改变)。例如,与 C 语言不同,JavaScript 中字符串是不可变的(译注:如,JavaScript 中对字符串的操作一定返回了一个新字符串,原始字符串并没有被改变)。我们称这些类型的值为“原始值”

从一个例子入手

注意:下面这个例子是angular框架的例子,代码中一些涉及到框架的用法如果不清楚的话,可以不用理会,直接看概念就好,或者可以跳过这个例子

例子中有两个页面:列表页和编辑页。 列表页: *ngFor="let item of addressList"遍历一个数组渲染一个列表:

在这里插入图片描述

右侧编辑按钮(click)="goEditAddress(item),在方法中传入列表中的元素item(方法如下,item是一个对象)。

 async goEditAddress(address) {
 	// 创建模态页面,ionic创建一个模态页就直接是一个页面
   const modal = await this.modalController.create({
     component: EditAddressComponent,
     enterAnimation: ModalFromRightEnter,
     leaveAnimation: ModalFromRightLeave,
     componentProps: {
       option: 'edit',
       address
     },
     mode: 'ios',
   });
   await modal.present();
 }

该方法在调用模态框(编辑页)的时候将元素item作为参数传递到编辑页中。

编辑页: 编辑页@Input() address: any;拿到列表页传过来的item。 页面初始化的时候执行:

 ionViewDidEnter() {
    this.addressModel = this.address;
 }

该页面在某个方法中执行下面语句:

 // 删除了this.addressModel里的两个属性
 delete this.addressModel.phoneShow;// 电话
 delete this.addressModel.addressShow;// 地址信息

该语句执行完之后会跳转到列表页,此时列表页的电话和地址信息没有了

在这里插入图片描述

正常来说编辑页里只是把传过来的参数address赋值给addressModel,方法里删除的只是addressModel的属性,怎么会跟列表页有关系呢?渲染列表的数组里的属性怎么也被删除了?

这就涉及到js的简单赋值和引用

可以先把结论放在这里: 以上步骤走了两个对象引用, 第一个是在列表页跳转编辑页时传递参数 编辑按钮(click)="goEditAddress(item)绑定item,并在方法里传给编辑页

 async goEditAddress(address) {
 	// 创建模态页面,ionic创建一个模态页就直接是一个页面
   const modal = await this.modalController.create({
     component: EditAddressComponent,
     enterAnimation: ModalFromRightEnter,
     leaveAnimation: ModalFromRightLeave,
     componentProps: {
       option: 'edit',
       address
       // address相当于address:address
     },
     mode: 'ios',
   });
   await modal.present();
 }

也就是说传递参数时address:address这个赋值是一个对象引用。

 componentProps: {
   option: 'edit',
   address
   // address相当于address:address
 },

第二个是在页面初始化赋值的时候

 ionViewDidEnter() {
    this.addressModel = this.address;
 }

这两个对象引用的结果就是,this.addressModel指向的还是列表页的数组addressList的元素item。也就是说this.addressModel引用的是列表页数组addressList元素item的地址,所以删addressModel里的属性就是删数组addressList元素item里的属性。(为什么会这样呢?接着往下走吧)


JS 的变量存储类型

变量存储类型分两类

  1. 基本类型:直接存储在中的数据。(undefinedBooleanNumberStringBigIntSymbolnull)

  2. 引用类型:将该对象引用地址存储在中,然后对象里面的数据存放在中。(数组、对象、Date、RegExp、函数、特殊的基本包装类型以及单体内置对象)

特殊的基本包装类型(String、Number、Boolean)以及单体内置对象(Global、Math)

变量在内存中到底是怎么存储的

变量在内存中到底是怎么存储的:哪些变量放在栈里?堆里放的又是什么?

基本数据类型和引用数据类型的存储方式如下:

  • 基本类型的变量是存放在栈区的(栈区指内存里的栈内存)
  • 引用类型的变量将对象引用地址存储在栈中,对象里面的数据存放在堆中

基本类型变量的存储方式

基本类型的变量是存放在栈区的(栈区指内存里的栈内存)。

假如有以下几个基本类型的变量:

var name = 'jozo';
var city = 'guangzhou';
var age = 22;

那么它的存储结构如下图:

在这里插入图片描述

栈区包括了 变量的标识符和变量的值。

引用类型变量的存储方式

引用类型的变量将对象引用地址存储在栈中,对象里面的数据存放在堆中

javascript和其他语言不同,其不允许直接访问内存中的位置,也就是说不能直接操作对象的内存空间,那我们操作啥呢? 实际上,是操作对象的引用,所以引用类型的值是按引用访问的。

准确地说,引用类型的存储需要内存的栈区和堆区(堆区是指内存里的堆内存)共同完成,栈区内存保存变量标识符和指向堆内存中该对象的指针,也可以说是该对象在堆内存的地址。

假如有以下几个对象:

var person1 = {name:'jozo'};
var person2 = {name:'xiaom'};
var person3 = {name:'xiaoq'};

则这三个对象的在内存中保存的情况如下图:

在这里插入图片描述


简单赋值

在从一个变量向另一个变量赋值基本类型时,会在该变量上创建一个新值,然后再把该值复制到为新变量分配的位置上:

var a = 10;
var b = a;

a ++ ;
console.log(a); // 11
console.log(b); // 10

此时,a中保存的值为 10 ,当使用 a 来初始化 b 时,b 中保存的值也为10,但b中的10与a中的是完全独立的,该值只是a中的值的一个副本,此后,这两个变量可以参加任何操作而相互不受影响。

在这里插入图片描述

也就是说基本类型在赋值操作后,两个变量是相互不受影响的。

对象引用

当从一个变量向另一个变量赋值引用类型的值时,同样也会将存储在变量中的对象的值复制一份放到为新变量分配的空间中。

前面讲引用类型的时候提到,保存在变量中的是对象在堆内存中的地址,所以,与简单赋值不同,这个值的副本实际上是一个指针,而这个指针指向存储在堆内存的一个对象。那么赋值操作后,两个变量都保存了同一个对象地址,则这两个变量指向了同一个对象。因此,改变其中任何一个变量,都会相互影响:

var a = {}; // a保存了一个空对象的实例
var b = a;  // a和b都指向了这个空对象

a.name = 'jozo';
console.log(a.name); // 'jozo'
console.log(b.name); // 'jozo'

b.age = 22;
console.log(b.age);// 22
console.log(a.age);// 22

console.log(a == b);// true

它们的关系如下图:

在这里插入图片描述

因此,引用类型的赋值其实是对象保存在栈区地址指针的赋值,因此两个变量指向同一个对象,任何的操作都会相互影响。


深拷贝和浅拷贝

最后再来看深拷贝和浅拷贝还有赋值的区别,这样就好理解多了

浅拷贝:是指只复制第一层对象,但是当对象的属性是引用类型时,实质复制的是其引用,当引用指向的值改变时也会跟着变化。

深拷贝:会克隆出一个对象,数据相同,但是引用地址不同(就是拷贝A对象里面的数据,而且拷贝它里面的子对象)。深拷贝后的对象与原来的对象是完全隔离的,互不影响,对一个对象的修改并不会影响另一个对象。

赋值:简单赋值和对象引用,对象引用获得该对象的引用地址

实现一个深拷贝

最简单的深拷贝:

JSON.parse(JSON.stringify(obj)): 性能最快

实现一个深拷贝:

function deepClone(obj) { //递归拷贝
    if (typeof obj !== 'object' || obj === null) {
        //如果不是复杂数据类型 或者为null,直接返回
        return obj;
    }

    if (obj instanceof RegExp) return new RegExp(obj);
    if (obj instanceof Date) return new Date(obj);

    let result;

    if (obj instanceof Array) {
        result = [];
    } else {
        result = {}
    }

    for (let key in obj) {
    	// 判断是否是对象自身的属性,筛掉对象原型链上继承的属性
        if (obj.hasOwnProperty(key)) {
            //如果 obj[key] 是复杂数据类型,递归
            result[key] = deepClone(obj[key]);
        }
    }

    return result;
}

我们应该拷贝要拷贝对象自身的属性,对象原型上的属性我们不应该拷贝,这里我们用到hasOwnProperty() 方法来解决。

hasOwnProperty() 方法会返回一个布尔值,这个方法可以用来检测一个对象是否含有特定的自身属性;该方法会忽略掉那些从原型链上继承到的属性。

重要参考及原文: