深入理解JS中的浅拷贝与深拷贝

一、栈内存与堆内存

栈内存与堆内存 、浅拷贝与深拷贝,可以说是前端程序员的内功,要知其然,知其所以然。

1、栈与栈内存

栈的定义
  • 后进者先出,先进者后出,简称 后进先出(LIFO),这就是典型的结构。
  • 新添加的或待删除的元素都保存在栈的末尾,称作栈顶,另一端就叫栈底
  • 在栈里,新元素都靠近栈顶,旧元素都接近栈底。
  • 从栈的操作特性来看,是一种 操作受限 的线性表,只允许在一端插入和删除数据。
  • 不包含任何元素的栈称为空栈
  • 栈也被用在编程语言的编译器和内存中保存变量、方法调用等,比如函数的调用栈。
栈内存
  • 它们存储的值都有固定的大小,保存在栈内存空间,通过按值访问,并由系统自动分配和自动释放。
  • 这样带来的好处就是,内存可以及时得到回收,相对于堆来说,更加容易管理内存空间。
  • 主要用于存储各种基本数据类型的变量,以及对象变量的指针。
  • JavaScript 中的 BooleanNullUndefinedNumberStringSymbolBigInt 都是基本数据类型。

2、堆与堆内存

堆的定义
  • 堆数据结构是一种树状结构。
  • 它的存取数据的方式,与书架与书非常相似。我们不关心书的放置顺序是怎样的,只需知道书的名字就可以取出我们想要的书了。
  • 好似在 JSON 格式的数据中,我们存储的 key-value 是可以无序的,只要知道 key,就能取出这个 key 对应的 value

堆与栈比较

  • 堆是动态分配内存,内存大小不一,也不会自动释放。
  • 栈是自动分配相对固定大小的内存空间,并由系统自动释放。
  • 栈,线性结构,后进先出,便于管理。
  • 堆,一个混沌,杂乱无章,方便存储和开辟内存空间。
堆内存

引用类型(如对象、数组、函数等)是保存在堆内存中的对象,值大小不固定,栈内存中存放的该对象的访问地址指向堆内存中的对象,JavaScript 不允许直接访问堆内存中的位置,因此操作对象时,实际操作对象的引用。
JavaScript 中的 Object、Array、Function、RegExp、Date 是引用类型。

堆内存与栈内存比较

栈内存 堆内存
存储基础数据类型 存储引用数据类型
按值访问 按引用访问
存储的值大小固定 存储的值大小不定,可动态调整
由系统自动分配内存空间 由代码进行指定分配
空间小,运行效率高 空间大,运行效率相对较低
先进后出,后进先出 无序存储,可根据引用直接获取
栈内存堆内存

栈内存中的变量一般都是已知大小或者有范围上限的,算作一种简单存储。而堆内存存储的对象类型数据对于大小这方面,一般是未知的。这就是为什么null作为一个object类型的变量却存储在栈内存中的原因吧。

当我们定义一个const obj ={name:'king'}对象的时候,我们说的常量obj其实是指针,就是变量obj的值不允许改变,也就是变量obj指向的堆内存的地址不能改变,但是,该堆内存地址内数据本身的大小或属性是可以改变的。

既然知道了const在内存中的存储,那么constlet定义的变量不能二次定义的流程也就比较容易猜出来了,每次使用const或者let去初始化一个变量的时候,会首先遍历当前的内存栈,看看有没有重名变量,有的话就返回错误。

二、数据类型

JS中,数据分为基本数据类型(StringNumberBooleanNullUndefinedSymbolBigInt)和引用数据类型(ObjectFunctionclass)。

  • 基本数据类型是直接存储在栈(Stack)内存中的数据
  • 引用数据类型栈内存存储的是该对象的引用地址,对象的真实数据存储在堆内存中
    引用数据类型在栈内存中存储了引用地址(也叫指针),该引用地址或指针指向了堆内存中实际数据的地址。


    引用数据类型存储

1、基本数据类型的存储

变量名和值都储存在栈内存中

let num = 10;
let newnum = num;

那么该num变量在栈内存中的存储如下

基本数据类型存储

变量num和值10都存储在栈内存中,num =10;让变量num指向数字10;而newnum =num是将变量newnum存储在栈内存中,并且让该变量指向新的值数字10。此时,变量num和变量newnum没有任何关系。注意 栈内存的赋值都是赋值,newnum = num是指将变量num赋值给新变量newnum;而不是把变量num赋值给变量newnum

基本数据类型值是不可变的
let a = 1;
console.log(++a);

其实这个时候并不是变量a 指向的 1 直接加了 1,而是 新建了一个 1+1 = 2 的值,再将 a 指向这个新建出来的 2,原来的那个 1 并没有发生改变,留由垃圾回收机制处理。也就是说不是 a 指向的值发生了改变,而是 a 变量指针指向了一个新的值,这就是基本类型的值是不可变的

2、引用数据类型的存储

变量名储存在栈内存中,值储存在堆内存中,但是堆内存中会提供一个引用地址指向堆内存中的值,而这个地址是储存在栈内存中的。

let ary = [1, 2, 3];
let newary = ary;

那么该ary变量在内存中的存储如下

引用数据类型存储

引用数据类型值是可变的
let obj = { name: 'king' };
obj.name = '偶尔平凡';
console.log(obj)
//{ name: '偶尔平凡' }

引用数据类型占据空间大,大小不固定,储存在堆内存,但是指向该引用数据类型的变量指针「a」是储存在栈内存中。 当解释器寻找引用值时,会首先检索其在栈中的地址,取得地址后从堆中获得实体。

var a = new String("123");
var b = String("123");
var c = "123";
console.log(a == b, a === b, b == c, b === c, a == c, a === c);
//           true     false    true    true     true    false

我们可以看到new一个String,出来的是对象,而直接字面量赋值和工厂模式出来的都是字符串。

let a = new String("123");
let b = new String("123");
console.log(a == b, a === b);
console.log(null === null);
//false false
//true

我们知道,变量a 和变量b都是存储在栈内存中的,但是变量a指向一个对象,假设该对象的堆内存地址AA00,变量b执行一个新的对象,该新对象的堆内存地址BB00,也就是变量a的值为AA00,变量b的值为BB00,所以变量a和变量b的值不同。而null是存储在栈内存的,所以null === nulltrue

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

3、基本数据类型和引用数据类型的销毁

基本类型在当前执行环境结束时销毁,而引用类型不会随执行环境结束而销毁,只有当所有引用它的变量不存在时这个对象才被垃圾回收机制回收。

三、浅拷贝和深拷贝的区别

基本数据类型都是深拷贝,无需讨论。引用数据类中的浅拷贝只复制指向某个对象的指针,而不复制对象本身,新旧对象还是共享同一块堆内存。但深拷贝会另外创造一个一模一样的对象,新对象跟原对象不共享堆内存,修改新对象不会改到原对象。浅拷贝和深拷贝,都是围绕堆栈内存展开的,一个是处理值,一个是处理指针。

四、浅拷贝的实现方式

1、直接赋值

let A = {
  name: "king",
  data: {
    age: 20,
  },
};
let B = {};
B = A;
B.name = "偶尔平凡";
console.log(A);
//{ name: '偶尔平凡', data: { age: 20 } }

2、Object.assign()

这是ES6中新增的对象方法,对它不了解的见ES6对象新增方法,它可以实现第一层的“深拷贝”,但无法实现多层的深拷贝。

第一层“深拷贝”:就是对于A对象下所有的属性和方法都进行了深拷贝,但是当A对象下的属性如data是对象时,它拷贝的是地址,也就是浅拷贝,这种拷贝方式还是属于浅拷贝。

多层深拷贝:能将A对象下所有的属性,及时属性是对象,也能够深拷贝出来,让A和B相互独立,这种叫才叫深拷贝。

let A = {
  name: "king",
  data: {
    age: 20,
  },
};
let B = {};
Object.assign(B, A);
B.name = "偶尔平凡";
B.data.age = 15;
console.log(A);
console.log(B);
//{ name: 'king', data: { age: 15 } }
//{ name: '偶尔平凡', data: { age: 15 } }

3、扩展运算符

let obj = { name: "king", age: 10, se: { sex: 0 } };
let newobj = { ...obj };
newobj.name = "偶尔平凡";
newobj.se.sex = 1;
console.log(obj);
console.log(newobj);
//{ name: 'king', age: 10, se: { sex: 1 } }
//{ name: '偶尔平凡', age: 10, se: { sex: 1 } }

4、Array.prototype.concat()

let arr = [
  1,
  3,
  {
    name: "king",
  },
];
let arr2 = arr.concat();
arr2[1] = 10;
arr2[2].name = "偶尔平凡";
console.log(arr);
console.log(arr2);
//[ 1, 3, { name: '偶尔平凡' } ]
//[ 1, 10, { name: '偶尔平凡' } ]

5、Array.prototype.slice()

let arr = [
  1,
  3,
  {
    name: "king",
  },
];
let arr2 = arr.slice();
arr2[1] = 10;
arr2[2].name = "偶尔平凡";
console.log(arr);
console.log(arr2);
//[ 1, 3, { name: '偶尔平凡' } ]
//[ 1, 10, { name: '偶尔平凡' } ]

关于Array的slice和concat方法的补充说明:Array的slice和concat方法不修改原数组,只会返回一个浅复制了原数组中的元素的一个新数组。

原数组的元素会按照下述规则拷贝:

  1. 如果该元素是个对象引用(不是实际的对象),slice 会拷贝这个对象引用到新的数组里。两个对象引用都引用了同一个对象。如果被引用的对象发生改变,则新的和原来的数组中的这个元素也会发生改变。
  2. 对于字符串、数字及布尔值来说(不是 String、Number 或者 Boolean 对象),slice 会拷贝这些值到新的数组里。在别的数组里修改这些字符串或数字或是布尔值,将不会影响另一个数组。

五、深拷贝的实现方式

1、JSON.parse(JSON.stringify())

let arr = [1, 2, { name: "king" }];
let arr2 = JSON.parse(JSON.stringify(arr));
arr2[1] = 10;
arr2[2].name = "偶尔平凡";
console.log(arr);
console.log(arr2);
//[ 1, 2, { name: 'king' } ]
//[ 1, 10, { name: '偶尔平凡' } ]

这种方法虽然可以实现数组或对象深拷贝,但不能处理函数。这是因为 JSON.stringify() 方法是将一个JavaScript值(对象或者数组)转换为一个JSON字符串,不能接受函数。

let arr = [1, 2, { name: "king", fn: function() {} }];
let arr2 = JSON.parse(JSON.stringify(arr));
console.log(arr);
console.log(arr2);
//[ 1, 2, { name: 'king', fn: [Function: fn] } ]
//[ 1, 2, { name: 'king' } ]

2、手写递归函数实现

第1步:假设递归函数已写好。

const deepClone = (value) => {
}

第2步:处理第一层的情况,也就是一般的情况。比如对方传递了一个null或者undefined进来可以处理的。

const deepClone = (value) => {
  if (value == null) {
    return value;
  }
};
let result = deepClone(null);
let result1 = deepClone(undefined);
console.log(result,result1);//null undefined

第3步:还是处理一般的情况,比如对方传递了一个普通数据类型或许函数,直接返回该值即可搞定。

const deepClone = (value) => {
  if (value == null) {
    return value;
  }
  if (typeof value !== "object") {
    return value;
  }
};
let result = deepClone('abc');
console.log(result);//abc

第4步:还是处理一般的情况,比如对方传递了一个正则类型或许时间类型的值,那么返回一个新正则或时间即可。

const deepClone = (value) => {
  if (value == null) {
    return value;
  }
  if (typeof value !== "object") {
    return value;
  }
  if (value instanceof RegExp) {
    return new RegExp(value);
  }
  if (value instanceof Date) {
    return new Date(value);
  }
};

第5步:处理传进来的是对象或者数组的情况了。

const deepClone = (value) => {
  if (value == null) {
    return value;
  }
  if (typeof value !== "object") {
    return value;
  }
  if (value instanceof RegExp) {
    return new RegExp(value);
  }
  if (value instanceof Date) {
    return new Date(value);
  }
  let instance = new value.constructor(); //创建一个新的空对象或数组
  for (let key in value) {
    instance[key] = value[key];
  }
  return instance;
};
let obj = { name: "king", age: 10 };
let result = deepClone(obj);
result.name = "偶尔平凡";
console.log(result);//{ name: '偶尔平凡', age: 10 }
console.log(obj);//{ name: 'king', age: 10 }

第6步:我们上面处理的对象是单层对象,如果对象内的值 value[key] 又是一个对象怎么办呢,这时候,才开始考虑递归即可。因为第一层的情况我们已经处理好了,第二层重复第一层就OK了。

//深拷贝
const deepClone = (value) => {
  if (value == null) {
    //排除null 和 undefine的情况,直接返回
    return value;
  }
  if (typeof value !== "object") {
    //基本数据类型和函数的情况直接返回即可
    return value;
  }
  if (value instanceof RegExp) {
    //正则的情况,返回新的正则即可
    return new RegExp(value);
  }
  if (value instanceof Date) {
    return new Date(value);
  }
  //处理对象或者数组的情况,new 创建新的空对象或数组
  let instance = new value.constructor();
  for (let key in value) {
    if (value.hasOwnProperty(key)) {
      //排除原型链上的属性或方法
      instance[key] = deepClone(value[key]);
    }
  }
  return instance;
};
let obj = { name: "king", age: 10, se: { sex: 0 }, fn: () => {} };
let newobj = deepClone(obj);
newobj.name = '偶尔平凡';
newobj.se.sex = 1;
console.log(newobj);
console.log(obj);
//{ name: '偶尔平凡', age: 10, se: { sex: 1 }, fn: [Function: fn] }
//{ name: 'king', age: 10, se: { sex: 0 }, fn: [Function: fn] }

确实,实现了深拷贝,感觉很完美,但是,遇到下面这种情况,会直接跑死,进入死循环了。

let obj = { a: 1 };
obj.b = obj;
let newobj =deepClone(obj);
//RangeError: Maximum call stack size exceeded 内存爆裂而亡

最终修改后的内容

const deepClone = (value, hash = new WeakMap => {
  if (value == null) {
    //排除null 和 undefine的情况,直接返回
    return value;
  }
  if (typeof value !== "object") {
    //基本数据类型和函数的情况直接返回即可
    return value;
  }
  if (value instanceof RegExp) {
    //正则的情况,返回新的正则即可
    return new RegExp(value);
  }
  if (value instanceof Date) {
    return new Date(value);
  }
  //处理对象或者数组的情况,new 创建新的空对象或数组
  let instance = new value.constructor();
  if (hash.has(value)) {
    //在hash 中查询一下是否存在过,如果存在就把以前拷贝的返回
    return hash.get(value);
  }
  hash.set(value, instance); //没有存过就放进去
  for (let key in value) {
    if (value.hasOwnProperty(key)) {
      //排除原型链上的属性或方法
      instance[key] = deepClone(value[key], hash);
      //将hash继续传递下去,保证每次能拿到以前拷贝的结果
    }
  }
  return instance;
};

let obj = { a: 1 };
obj.b = obj;
let newobj = deepClone(obj);
console.log(newobj);
//{ a: 1, b: [Circular] }

注意小知识点:

//字典 Map 和 WeakMap 区别
//Map 中的key为对象,如果该对象外部人工销毁了,该对象在Map中并没有销毁
//堆内存存放的 { name: "king" } 的地址为 OXFF00
// 地址OXFF00 分别赋值给了 obj以及map中去
//变量obj重新赋值了null;但是map中的地址还在,所以堆内存中的对象不会销毁
let obj = { name: "king" };
let map = new Map();
map.set(obj, 0);
obj = null;
console.log(map);
console.log(obj);
//Map { { name: 'king' } => 0 }
//null
//obj 重新赋值后,WeakMap中的obj也改变,也就是若引用
let obj = { name: "king" };
let map = new WeakMap();
map.set(obj, 0);
obj = null;
console.log(map);
console.log(obj);
//WeakMap {  }
//null

六、递归基础知识

1、什么是递归

在JavaScript程序中,函数直接或间接调用自己。通过某个条件判断跳出结构,有了跳出才有结果。

2、递归写法的步骤

  1. 假设递归函数已经写好了。
  2. 寻找递推关系。
  3. 将递推关系的结构转换为递归体
  4. 将临界条件加入到递归体中(一定要加临界条件,某则陷入死循环,内存泄漏)

3、递归示例

求1-100的和,用递归该怎么写呢?按照递归的步骤来即可
1、假设递归函数已经写好了,即为sum(100),就是求1-100的和。
2、寻找递推关系,就是nn-1的关系 sum(n) ==sum(n-1) +n

let result = sum(100);
let result = sum(99) +100;

3、将递归结构转换为递归体。

function sum(n) {
  return sum(n - 1) + n;
}

4、加入临界条件,防止死循环。求100 转换为 求99 求99 转换为 求98 求98 转换为 求97 … 求2 转换为 求1 求1 转换为 求1 即 sum(1) = 1

function sum(n) {
  if (n == 1) return 1;
  return sum(n - 1) + n;
}
console.log(sum(100));//5050

你可能感兴趣的:(深入理解JS中的浅拷贝与深拷贝)