一、栈内存与堆内存
栈内存与堆内存 、浅拷贝与深拷贝,可以说是前端程序员的内功,要知其然,知其所以然。
1、栈与栈内存
栈的定义
- 后进者先出,先进者后出,简称 后进先出(LIFO),这就是典型的
栈
结构。 - 新添加的或待删除的元素都保存在栈的末尾,称作
栈顶
,另一端就叫栈底
。 - 在栈里,新元素都靠近栈顶,旧元素都接近栈底。
- 从栈的操作特性来看,是一种
操作受限
的线性表,只允许在一端插入和删除数据。 - 不包含任何元素的栈称为
空栈
。 - 栈也被用在编程语言的编译器和内存中保存变量、方法调用等,比如函数的调用栈。
栈内存
- 它们存储的值都有固定的大小,保存在栈内存空间,通过按值访问,并由系统自动分配和自动释放。
- 这样带来的好处就是,内存可以及时得到回收,相对于堆来说,更加容易管理内存空间。
- 主要用于存储各种基本数据类型的变量,以及对象变量的指针。
- JavaScript 中的
Boolean
、Null
、Undefined
、Number
、String
、Symbol
、BigInt
都是基本数据类型。
2、堆与堆内存
堆的定义
- 堆数据结构是一种树状结构。
- 它的存取数据的方式,与书架与书非常相似。我们不关心书的放置顺序是怎样的,只需知道书的名字就可以取出我们想要的书了。
- 好似在
JSON
格式的数据中,我们存储的key-value
是可以无序的,只要知道key
,就能取出这个key
对应的value
。
堆与栈比较
- 堆是动态分配内存,内存大小不一,也不会自动释放。
- 栈是自动分配相对固定大小的内存空间,并由系统自动释放。
- 栈,线性结构,后进先出,便于管理。
- 堆,一个混沌,杂乱无章,方便存储和开辟内存空间。
堆内存
引用类型(如对象、数组、函数等)是保存在堆内存中的对象,值大小不固定,栈内存中存放的该对象的访问地址指向堆内存中的对象,JavaScript 不允许直接访问堆内存中的位置,因此操作对象时,实际操作对象的引用。
JavaScript 中的 Object、Array、Function、RegExp、Date
是引用类型。
堆内存与栈内存比较
栈内存 | 堆内存 |
---|---|
存储基础数据类型 | 存储引用数据类型 |
按值访问 | 按引用访问 |
存储的值大小固定 | 存储的值大小不定,可动态调整 |
由系统自动分配内存空间 | 由代码进行指定分配 |
空间小,运行效率高 | 空间大,运行效率相对较低 |
先进后出,后进先出 | 无序存储,可根据引用直接获取 |
栈内存中的变量一般都是已知大小或者有范围上限的,算作一种简单存储。而堆内存存储的对象类型数据对于大小这方面,一般是未知的。这就是为什么null作为一个object类型的变量却存储在栈内存中的原因吧。
当我们定义一个const obj ={name:'king'}
对象的时候,我们说的常量obj
其实是指针,就是变量obj
的值不允许改变,也就是变量obj
指向的堆内存的地址不能改变,但是,该堆内存地址内数据本身的大小或属性是可以改变的。
既然知道了const
在内存中的存储,那么const
、let
定义的变量不能二次定义的流程也就比较容易猜出来了,每次使用const
或者let
去初始化一个变量的时候,会首先遍历当前的内存栈,看看有没有重名变量,有的话就返回错误。
二、数据类型
在JS
中,数据分为基本数据类型(String
、Number
、Boolean
、Null
、Undefined
、Symbol
、BigInt
)和引用数据类型(Object
、Function
、class
)。
- 基本数据类型是直接存储在栈(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 === null
为true
。
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方法不修改原数组,只会返回一个浅复制了原数组中的元素的一个新数组。
原数组的元素会按照下述规则拷贝:
- 如果该元素是个对象引用(不是实际的对象),slice 会拷贝这个对象引用到新的数组里。两个对象引用都引用了同一个对象。如果被引用的对象发生改变,则新的和原来的数组中的这个元素也会发生改变。
- 对于字符串、数字及布尔值来说(不是 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、递归写法的步骤
- 假设递归函数已经写好了。
- 寻找递推关系。
- 将递推关系的结构转换为递归体
- 将临界条件加入到递归体中(一定要加临界条件,某则陷入死循环,内存泄漏)
3、递归示例
求1-100的和,用递归该怎么写呢?按照递归的步骤来即可
1、假设递归函数已经写好了,即为sum(100)
,就是求1-100
的和。
2、寻找递推关系,就是n
和n-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