javascritp 双精度浮点数 精度问题

遇到的问题

今天遇到一个问题,场景如下:
后端返回一个 uuid 为 1763118905011392500 ,我带 1763118905011392500 去查数据时,接口报错提示: uuid 不存在,我问后端怎么回事,后端查了一下这个 uuid,发现数据库确实不存在值为 1763118905011392500 的 uuid。

谷歌了一下,发现是 javascript 使用双精度浮点数表示数字,最大安全整数是 2^53 - 1 ,即 9007199254740991

浏览器中可以接收的值的大小取决于浏览器的 javascript 引擎的实现。大多数浏览器都遵循 javascript 标准。

当浏览器接收到大于最大安全整数的值时,超出这个范围的整数值将会失去精度,可能会被舍入。

也就是说,后端给我的值大于 2^53-1 ,浏览器将丢失精度的值保存了下来,前端将丢失精度的值传递给后端去查询数据时,那必然找不到该值。

找到了原因后,接下来了解下 双精度浮点数 ,以及如何解决这个问题。

双精度浮点数

浮点数是什么?

在 JavaScript 中,浮点数是一种用于表示小数的数据类型。浮点数采用 IEEE 754 标准表示,通常被称为双精度浮点数,即使用 64 位(8 字节)来表示一个浮点数。这意味着无论数字的大小如何,它们在内存中占用的空间都是相同的,即 64 位或者 8 个字节。
这种格式可以表示小数(小数是指非整数的有理数,其中包含了小数点及其后面的数字部分)部分,而不仅仅是整数部分,因此可以用来存储小数值。

双精度浮点数是什么?

双精度浮点数是一种用于表示实数的数字表示方法,它在计算机科学中被广泛使用。它的名称中的“双精度”表示它的存储空间是单精度浮点数(32 位)的两倍,因此可以存储更大范围和更高精度的数字。
具体来说,双精度浮点数使用 64 位(8 字节)来存储一个数字,这 64 位被划分为三部分:

  1. 符号位(1 位):用于表示数字的正负。
  2. 指数部分(11 位):用于表示数字的指数部分,决定了数字的数量级。
  3. 尾数部分(52 位):用于表示数字的小数部分,决定了数字的精度。

在科学计数法中,一个数字通常表示为 M × 10^E 的形式,其中 M 是尾数(即小数部分),E 是指数(表示这个数需要移动的小数点位数)

64 位(8 字节)是什么意思?

64 位(8 字节)是指在计算机中用来表示数据的一种存储方式。它表示一个数据单元占用的存储空间大小。具体来说,64 位意味着这个数据单元可以存储 64 位二进制数字,即包含了 64 个二进制位。

1
2
3
4
5
6
7
Bit-比特 Byte-字节 KB-千字节 MB-兆字节 GB-吉字节 TB-太字节

1 Byte = 8 Bits
1 GM = 1024 MB
1 TB = 1024 GB

一个二进制位的大小称为一比特,也就是最小单位

解决方法

js 有没有什么办法可以表示大于 2^53 - 1 的数呢?有的,就是 BigInt 类型,它可以表示任意大的整数。

要表示 2^53,可以直接使用 BigInt 类型的字面量赋值,如下所示:

1
2
3
4
const bigIntNumber = 2n ** 53n;
console.log(bigIntNumber); // 输出:9007199254740992n

console.log(typeof bigIntNumber); // 'bigint'

这里的 2n 表示一个 BigInt 数字 2,** 表示幂运算符,53n 表示一个 BigInt 数字 53。

但在因为 Java 原生不支持 BitInt 类型,所以不能使用 BitInt 来处理。

所以解决方案有以下两种:

  1. 将 uuid 改为 2^53-1 下的值,也就是小于等于 15 位数
  2. 或者将 uuid 字符串化,如'1763118905011392500'

小结

js 标准中,使用双精度浮点数表示数字,最大安全整数为 2^53 - 1。js number 类型无法精准表示超出最大安全数的值,可用 BigInt 类型表示。如果后端给的数值超出最大安全值,可以采用”字符串化”方案,解决前后端交互时精度丢失的问题。

思考:
3 和 99 占用的存储空间大小一样吗?

Map和Set、Map和Object的区别

Map 和 Object 的区别

特性上的区别:

Map 更适合”键值对”这种数据结构。Object 的键只能是 string、整数、Symbol 类型,Map 的键可以是任意类型。

Map 实现了迭代协议,可用 for…of 遍历,而 Object 不行, Object 是不可迭代的。

Map 可用 size 属性获取长度,而 Object 没有获取长度的属性。

Map 遍历顺序可以保证,按插入时输出。
而 Object 无法保证,Object 的遍历顺序为:

  • 当 key 类型为Number,按 key 从小到大排序
  • 当 key 类型为String,会被转为Number类型,按 key 从小到大排序
  • 当 key 类型为Symbol,按创建的时间升序排序
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let obj = new Object();
obj['jack'] = 1;
obj[0] = 2;
obj['4'] = 3;
obj[5] = 3;
obj['2'] = 3;
obj[1] = 3;
obj['tom'] = 4;
obj['toni'] = 2;

//填入Object的元素key是自动按照字符串排序的,数字排在前面,且升序拍戏,字符串在后
for (let k in obj) {
console.log(k); // 0 1 2 5 jack tom toni
// console.log(typeof k); // 都是string,因为都会调用对象的toString()方法, key.toString()
}

当数据量大(长度为十几万以上)的时候,Map 的读入、写入、删除会比 Object 更快,数据量小的时候差别不多,object 稍微好一点点点

Map 的使用场景:

  • 聊天内容与聊天列表
    • 数据量大、频繁的写入、更新、对写入顺序有要求
  • 策略模式
    • 用于储存多个键值对应一个规则的情况

Map 和 Set 的应用

set:

  1. 去重
1
2
3
4
5
// 写法一
Array.from(new Set([1, 2, 3, 3, 4])) // [1,2,3,4]

// 写法二
[...new Set([1, 2, 3, 3, 4])] // [1,2,3,4]
  1. 取并集、交集、差集
1
2
3
4
5
6
7
8
9
10
11
const arr1 = new Set([1, 2, 3, 4, 5]);
const arr2 = new Set([1, 5, 6]);

//并集
new Set([...arr1, ...arr2]);

// 交集
new Set([...arr1].filter((item) => arr2.has(item)));

// 差集
new Set([...arr1].filter((item) => !arr2.has(item)));

map:
可以使用任意的数据类型作为键,比对象更合适

Array.from

可以将类数组和可遍历(iterator)对象转换为真正的数组。

1
Array.from(new Set([1, 2])); // [1, 2]

Map 和 Set 的区别

不同点

Set 结构类似于数组,成员唯一,没有排序的概念。Map 结构它保存的是键值对的集合。

添加成员的方法不同:
Set 使用 add 方法 ,如:set.add(6),Map 使用 set 方法,如 map.set(‘key’, ‘value’)

获取成员的方法不同:
Set 结构没有排序的概念,无法获取单个成员。Map 结构使用 map.get(‘key’) 获取成员的值。

相同点

Map 和 Set 都是 es 的新增的数据结构。

拥有同样的遍历方法:
keys()、values()、entries()、forEach()

Promise

同步与异步

同步是指发起一个请求时,如果未得到请求结果,代码逻辑将会等待,直到结果出来才会继续执行之后的代码。

异步是指当发起一个请求时,不会等待请求结果,直接继续执行后面的代码。请求结果的处理逻辑,会添加一个监听,等到反馈结果出来后,在回调函数中处理对应的逻辑。

使用 Promise 模拟一个发起请求的函数,该函数在 1s 之后返回数值 30。

1
2
3
4
5
6
7
8
9
function fn() {
return new Promise(function (resolve, reject) {
setTimeout(function () {
resolve(30);
}, 1000);
});
}

fn().then((res) => console.log(res)); // 输出 30

在该函数的基础上,我们可以使用 async/await 来模拟同步的效果。

1
2
3
4
5
6
7
8
9
10
var foo = async function () {
var res = await fn();
console.log(res);
console.log('next code');
};
foo();

// 输出结果
// 30
// next code

而异步效果则会有不同的输出

1
2
3
4
5
6
7
8
9
10
var foo = function () {
fn().then((res) => {
console.log(res);
});
console.log('next code');
};

// 输出结果
// next code
// 30

ajax

ajax 是网页与服务端进行数据交互的一种技术。
我们可以通过服务端提供的接口,利用 ajax 向服务端请求需要的数据

整个过程的简单实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var url = 'http: www.demo.com/user/info';

var result;

var XHR = new XMLHttpRequest();
XHR.open('GET', url, true);
XHR.send();

XHR.onreadystatechange = function () {
if (XHR.readyState == 4 && XHR.state == 200) {
result = XHP.reponse;
console.log(result);
}
};

这看起来没什么麻烦的,但是这时候,如果我们还需要做另一个 ajax 请求,这个请求的参数是从上一个 ajax 请求中获取的,那么我们就不得不如下这么做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var url = "http: www.demo.com/user/info"

var result;

var XHR = new XMLHttpRequest()
XHR.open('GET', url, true)
XHR.send()

XHR.onreadystatechange = function () {
if (XHR.readyState == 4 && XHR.state == 200) {
result = XHP.reponse
console.log(result)

// 伪代码2
var url2 = 'http:xxx.yyy.com/zzz?ddd=' + result.someParams;
var XHR2 = new XMLHttpRequest();
XHR2.open('GET', url, true);
XHR2.send();
XHR2.onreadystatechange = function() {
...
}
}
}

当出现第三个(甚至更多)仍然依赖上一个请求的时候,代码就变成了一场灾难。

我们需要不停的嵌套回调函数。这样的灾难,我们称之为 回调地狱。

**Promise **可以帮助我们解决这个问题。

Promise

我们知道,如果要确保代码在谁之后执行,可以利用函数调用栈,将想要执行的代码放入回调函数中。

1
2
3
4
5
6
7
8
9
10
11
function want() {
console.log('这是你想要执行的代码');
}

function fn(want) {
console.log('这里表示执行了一大堆其他代码');

// 其他代码执行完后,最后执行回调函数
want && want();
}
fn(want);

或者可以利用任务队列

1
2
3
4
5
6
7
8
9
10
11
function want() {
console.log('这是你想要执行的代码');
}

function fn(want) {
// 根据事件循环的机制,我们就不用非得将代码放在最后面了,由你自由选择
want && setTimeout(want, 0);

console.log('这里表示执行了一大堆其他代码');
}
fn(want);

与 setTimeout 类似, Promise 也可以认为是一种任务分发器,它将任务分发到 PromiseJobs 执行队列中。通常的用法是,我们发起一个请求,然后等待并处理请求结果。

简单用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var tag = true;
var p = new Promise(function (resolve, reject) {
if (tag) {
resolve();
} else {
reject();
}
});

p.then(function (result) {
console.log(result);
}).catch(function (err) {
console.log(err);
});

定义

Promise 是一个对象,保存着未来某一时刻才会执行的事件。

我们常使用 Promise 来解决反馈结果需要等待的场景。

例如

  • 前端向服务端发送一个接口请求。请求结果不会马上返回,而是需要等待一段时间。
  • 加载图片,需要等待一段时间
  • 弹窗中,等待用户点击确认或者取消

基础知识

创建 Promise 实例

1
const p = new Promise();

Promise 函数中的第一个参数为一个回调函数,我们可以称之为 executor 。通常情况下,在这个函数中,我们将会执行发起请求操作,并修改结果的状态值。

1
2
3
4
5
6
7
8
const p = new Promise((resolve, reject) => {
if (true) {
resolve();
}
if (false) {
reject();
}
});

状态

状态有三种

  1. pending:等待结果状态
  2. fulfilled:已出结果,结果符合预期完成状态
  3. rejected:已出结果,结果未符合预期完成状态

promise 表达的就是从发起请求开始,从没有结果 padding 到有结果 fulfilled/rejected 的一个过程。

在 executor 函数中,我们可以分别使用 resolve 与 reject 将状态修改为对应的 fulfilled 与 rejected.

resolve/reject 是 executor 函数的两个参数。他们能够将请求结果的具体数据传递出去。

  1. Promise 实例拥有 then 方法,用来处理请求结果变为 fulfilled 状态时的逻辑。then的第一个参数也是一个回调函数,该函数的参数则是 resolve 传递出来的数据。第二个参数用来处理 rejected 状态时的逻辑。
  2. Promise 实例拥有 catch方法,用来处理请求结果变为 rejected 时的逻辑。catch的第一个参数也是一个回调函数,该函数的参数则是 reject 传递出来的数据。

基本使用

写个例子感受一下 Promise 的用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function fn(num) {
return new Promise(function (resolve, reject) {
// 模拟一个请求,2s 后得到结果
setTimeout(function () {
if (typeof num == 'number') {
resolve(num);
} else {
var err = num + ' is not a number';
reject(err);
}
}, 2000);
});
}

fn('abc')
.then(function (resp) {
console.log(resp);
})
.catch(function (err) {
console.log(err);
});

// 注意观察该语句的执行顺序
console.log('next code');

then方法可以接收两个参数,第一个参数用来接收 fulfilled 状态的逻辑,第二个参数用来处理 rejected 状态的逻辑。

1
2
3
4
5
6
7
8
fn('abc').then(
function (resp) {
console.log(resp);
},
function (err) {
console.log(err);
},
);

因此 catch 方法其实与下面的写法等价。

1
2
3
fn('abc').then(null, function (err) {
console.log(er);
});

then方法返回的仍然是一个 Promise 实例对象,因此 then 方法可以链式调用,通过在内部 return 的方式,能够将数据持续往后传递。

封装 ajax

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
var url = 'http://www.demo.com/user/info';

// 封装一个get请求的方法
function getJSON(url) {
return new Promise(function (resolve, reject) {
// 利用ajax发送一个请求
var XHR = new XMLHttpRequest();
XHR.open('GET', url, true);
XHR.send();

// 等待结果
XHR.onreadystatechange = function () {
if (XHR.readyState == 4) {
if (XHR.status == 200) {
try {
var response = JSON.parse(XHR.responseText);
// 得到正确的结果修改状态并将数据传递出去
resolve(response);
} catch (e) {
reject(e);
}
} else {
// 得到错误结果并抛出异常
reject(new Error(XHR.statusText));
}
}
};
});
}

// 封装好之后,使用就很简单了
getJSON(url).then(function (resp) {
console.log(resp);
// 之后就是处理数据的具体逻辑
});

Promise.all

当有一个 ajax 请求,它的参数需要另外 2 个甚至更多请求都有了结果之后才能确定,那么这个时候,就需要 Promise.all 来帮助我们应该这个场景。

1
var p = Promise.all([p1, p2, p3]);

Promise.all 接收一个由 Promise 对象组成的数组作为参数,当 Promise 对象状态都变成 fulfilled 的时候,才会去调用 then 方法。

如果其中一个 Promise 对象状态变成 rejected,那么 p 的状态就会变成 rejected,第一个被 reject 的实例的返回值会传递给回调函数。

如果作为参数的 Promise 实例,自己定义了 catch 方法,那么它一旦被 rejected,并不会触发 Promise.all() 的 catch() 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const p5 = new Promise(function (resolve) {
resolve('hello');
});

const p6 = new Promise(function (resolve, reject) {
throw new Error('报错了');
}).catch((e) => e);

Promise.all([p5, p6])
.then((res) => console.log(res))
.catch((e) => console.log(e));

// 输出结果
// ["hello", Error: 报错了]

Promise.race

与 Promise.all 相似的是,Promise.race 也是接收一个 Promise 对象组成的数组作为参数,不同的是,只要当数组中的一个 Promise 状态变为 fulfilled 或者 rejected 时,就可以调用 .then 方法了。

1
var p = Promise.race([p1, p2, p3]);

Promise.race 可以理解为 Promise 实例赛跑,哪个实例有了状态就返回哪个,通常用于处理规定时间内请求超时的情况。

如果 5 秒之内请求无法返回结果,变量 p 的状态就会变为 rejected,从而触发 catch 方法指定的回调函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Promise.race([
new Promise((resolve, reject) => {
// 模拟请求,10秒后返回数据
setTimeout(resolve, 10000, 'my name is a');
}),
new Promise(function (resolve, reject) {
// 处理请求超时
setTimeout(reject, 5000, new Error('超时了'));
}),
])
.then((res) => {
console.log(res);
})
.catch((err) => {
console.log('err: ', err);
alert(err);
});

all 和 race 的区别

all 接受一个数组,数组成员是 promise 实例对象,如果 promise 的状态都为成功,则返回一个数组,如果其中一个失败,那么就会执行 catch

race 表示赛跑,参数和 all 一样,race 返回的是状态最先完成的 promise 的结果,不管成功还是失败
我们一般用 race 处理接口超时的情况。

以下代码,如果 5 秒之内请求无法返回结果,promise 状态就会变为 rejected,从而触发 catch 方法指定的回调函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Promise.race([
new Promise((resolve, reject) => {
// 模拟请求,10秒后返回数据
setTimeout(resolve, 10000, 'my name is a');
}),
new Promise(function (resolve, reject) {
// 处理请求超时
setTimeout(reject, 5000, new Error('超时了'));
}),
])
.then((res) => {
console.log(res);
})
.catch((err) => {
console.log('err: ', err); // err: Error: 超时了
});

async/await

异步问题除了使用 Promise 来解决之外,还可以使用 ES7 中新增的语法 async/await 来搞定。

在函数声明前面,加上关键字 async,这就是 async 的具体使用了。

1
2
3
4
5
6
7
8
async function fn() {
return 30;
}

// 或者
const fn = async () => {
return 30;
};

然后我们查看一下 fn 的运行结果

1
2
3
4
5
6
7
8
console.log(fn());

// result
Promise = {
__proto__: Promise,
[[PromiseStatus]]: 'resolved',
[[PromiseValue]]: 30,
};

发现 fn 函数运行返回的是一个标准的 Promise 对象。也就是说 async 其实就是 Promise 的一个语法糖,目的是为了让写法更加简单。于是,我们可以使用 Promise 的相关语法来处理后续的逻辑

1
2
3
fn().then((res) => {
console.log(res); // 30
});

await 含义为等待,意思就是需要等待 await 后面函数运行完了,并且有了返回结果,才能继续执行下面的代码。这正是同步的效果。

需要注意的是,await 关键字只能在 async 函数中使用。并且 await 后面的函数运行后必须返回一个 Promise 对象才能实现同步的效果。

当我们使用一个变量去接收 await 的返回值时,该返回值为 Promise 中 resolve 传递出来的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function fn() {
return new Promise((resolve, reject) => {
setTimeout(resolve, 1000, 30);
});
}

const foo = async () => {
const t = await fn();
console.log('t', t);
console.log('next code');
};

foo();

// 输出结果
// t: 30
// next code

从例子中我们可以看出,在 async 函数中,遇到 await 时,就会等待 await 后面的函数运行完毕,而不会直接执行 next code。

如果我们直接使用 then 方法,就不得不把后续的逻辑写在 then 方法中。

1
2
3
4
5
6
7
const foo = () => {
return fn().then((t) => {
console.log('t: ', t);
console.log('next doce');
});
};
foo();

很明显,如果使用 async/await 的话,代码会更简洁,逻辑也更清晰。

异常处理

在 Promise 中,我们使用 catch 方法来捕获异常,而不是使用 then 的第二个参数。因为:

  1. 更接近同步的语法(try/catch)
  2. then 中的错误也会被 catch 捕获

使用 then 的第二个参数,并不能捕获到第一个参数内部抛出的错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const p3 = new Promise(function (resolve, reject) {
setTimeout(() => {
resolve(123);
}, 1000);
});
p3.then(
(result) => {
console.log(result);
throw new Error('is err');
},
(error) => {
console.log('error', error);
},
);

// 输出结果
// 123
// Uncaught (in promise) Error: is err

使用 catch ,则可以捕获到上一个 then 内部抛出的错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const p3 = new Promise(function (resolve, reject) {
setTimeout(() => {
resolve(123);
}, 1000);
});
p3.then((result) => {
console.log(result);
throw new Error('is err');
}).catch((error) => {
console.log('error', error);
});

// 输出结果
// 123
// error Error: is err

而使用 async 时,我们使用 try/catch 来捕获异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function fn() {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject('some error');
}, 1000);
});
}

const foo = async () => {
try {
await fn();
} catch (e) {
console.log('e: ', e);
}
};
foo();

如果有多个 await ,只会捕获到第一个异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function fn1() {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject('some error fn1');
}, 1000);
});
}
function fn2() {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject('some error fn2');
}, 1000);
});
}

const foo = async () => {
try {
await fn1();
await fn2();
} catch (e) {
console.log('e: ', e); // some error fn1
}
};
foo();

封装

如何封装与使用息息相关。

加载图片

封装一个加载图片的函数,Promise 的使用如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function imageLoad(url) {
const img = new Image();
img.src = url;

return new Promise(function (resolve, reject) {
img.onload = function () {
resolve('图片加载成功');
};

img.onerror = function () {
reject('图片加载失败');
};
});
}

然后我们就可以使用 imageLoad 来执行图片加载完成之后的逻辑。

1
2
3
4
5
6
7
imageLoad('xxx.png')
.then((res) => {
alert(res);
})
.catch((err) => {
alert(err);
});

封装的核心关键是: Promise 的最终目的是,为了执行 then 中的回调函数,我们称它为 then_cb

所以在封装的时候,我们就应该思考如何在 Promise 内部,让 then_cb执行。

简易版 MyPromise

显而易见,Promise 包含原型方法 then,构造函数需要传递回调函数 executor,该回调函数包含两个参数,resolve 与 reject 。

根据这些特点,我们得出:

1
2
3
4
5
6
7
8
9
10
11
12
class MyPromise {
construcotr (executor) {
executor(this._resolve.bind(this), this._reject.bind(this)
}

_resolve (value) {}

_reject () {}

then (then_cb) {}
}

目的是为了调用 then_cb ,通过封装加载图片可以发现,调用 resolve 时,then_cb 才会执行,所以可以得出结论, then_cb 的执行需要被 resolve 触发

我们可以通过保存 then_cb 引用的方式来解决。所以代码就变成了这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MyPromise {
constructor(executor) {
this.thenCallback = null;

executor(this._resolve.bind(this), this._reject.bind(this));
}

_resolve(value) {
this.thenCallback(value);
}

_reject(value) {}

then(then_cb) {
this.thenCallback = then_cb;
}
}

同理,再解决 catch 回调函数的执行问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class MyPromise {
constructor(executor) {
this.thenCallback = null;
this.rejectCallback = null;
executor(this._resolve.bind(this), this._reject.bind(this));
}

_resolve(value) {
this.thenCallback(value);
}

_reject(value) {
this.rejectCallback(value);
}

then(then_cb, onRejected) {
this.thenCallback = then_cb;
this.rejectCallback = onRejected;
}

catch(onRejected) {
this.then(null, onRejected);
}
}

如果不追求别的特性,我们的 Promise 对象就已经封装好了,并且可以使用了。

1
2
3
4
5
6
7
8
9
10
11
12
const p = new MyPromise((resolve, reject) => {
setTimeout(() => {
// resolve('123')
reject('some err');
}, 1000);
});

// p.then(res => {
// console.log(res);
// })

p.catch((err) => [console.log('err', err)]);

再模拟将 then_cb 放入队列中执行,简单调整如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class MyPromise {
constructor(executor) {
this.thenCallback = null;
this.rejectCallback = null;
executor(this._resolve.bind(this), this._reject.bind(this));
}

_resolve(value) {
// this.thenCallback(value)
setTimeout(() => {
this.thenCallback(value);
}, 0);
}

_reject(value) {
// this.rejectCallback(value)
setTimeout(() => {
this.rejectCallback(value);
}, 0);
}

then(then_cb, onRejected) {
this.thenCallback = then_cb;
this.rejectCallback = onRejected;
}

catch(onRejected) {
this.then(null, onRejected);
}
}

加入队列机制后,就可以在 executor 中直接执行 resolve ,否则会报错 this.thenCallback is not a function

1
2
3
4
5
6
7
const p = new MyPromise((resolve, reject) => {
resolve('123');
});

p.then((res) => {
console.log(res);
});

Promise.all

Promise.all 返回的是一个数组,我们要将参数 array 内的每个 Promise 的执行结果放在一个数组 result 里,并且 result 数组成员的顺序要与传入时的 array 成员顺序保持一一对应。

实现 Promise.all 的重点在于对 all 参数内 Promise 实例全部执行完毕时机的判断。

因为 Promise 是异步的,我们不能保证 Promise 实例完成的时机与数组顺序一样。也就是说我们不能使用数组的 length 属性来表达 Promise 全都执行完毕。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const delay = (data) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(data);
}, Math.random() * 1000);
});
};

Promise._all = (array) => {
return new Promise((resolve, reject) => {
let count = 0;
const result = [];
for (let i = 0, len = array.length; i < len; i++) {
array[i].then((data) => {
result[i] = data;
count++;
// 因为 array[i] 的执行是异步的,所以这种判断是错误的
// 如果 i===2 的 promise 先执行完毕,result[2] 导致 result.length === 3
if (result.length === array.length) {
resolve(result);
}
}, reject);
}
});
};

const p1 = delay(1);
const p2 = delay(2);
const p3 = delay(3);
Promise._all([delay(2), delay(1), delay(3)]).then(
(res) => {
console.log('res: ', res); // [2, 1, 3]
},
(err) => {
console.log('err: ', err);
},
);

上面的代码,如果最后一个 Promise 先执行完毕,赋值时 result[2] = data,那么 result.length等于 3, 满足 result.length === array.length的判断条件,就会提前执行 resolve 。

所以,我们可以参考垃圾回收机制的引用计数法,在内部添加一个计数变量 count 来判断是否所有 Promise 都已执行完毕。当 count 等于传入的 Promise 数组长度时,表示所有 Promise 都有了结果,然后我们再执行 resolve 将结果传递出去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Promise._all = (array) => {
return new Promise((resolve, reject) => {
let count = 0;
const result = [];
for (let i = 0, len = array.length; i < len; i++) {
array[i].then((data) => {
result[i] = data;
count++;
// 因为array[i]的执行是异步的,所以这种判断是错误的
// 如果i===2的promise先执行完毕,result[2]导致result.length === 3
// if (result.length === array.length) {
// resolve(result)
// }
if (count === array.length) {
resolve(result);
}
}, reject);
}
});
};

V8引擎如何工作的

v8 是谷歌开源的 JS 引擎,用于执行 JS 代码。清楚 JS 代码的执行顺序,有助于我们了解函数调用栈、事件循环等概念。

V8 的工作流程图

pic.1708406473535
主要了解其中 4 个重要的概念

1.Scanner

scanner 表示扫描器,用于对纯文本 JS 代码进行词法分析。它会将代码分析为 tokens。tokens 表示不能再分割的最小单位,可能是单是字符,可能是一串字符串。
例如

1
const a = 20;

会被转为 token 集合,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[
{
type: 'Keyword',
value: 'const',
},
{
type: 'Identifier',
value: 'a',
},
{
type: 'Punctuator',
value: '=',
},
{
type: 'Numeric',
value: '20',
},
];

2.Parser

parser 表示解析器。解析过程是一个语法分析的过程,它会将 tokens 转换为抽象语法树「Abstract Syntax Tree」,同时验证语法,有问题就抛出错误。

继续上个例子,tokens 被解析为 AST 后的样子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"type": "Program",
"body": [
{
"type": "VariableDeclaration",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "a"
},
"init": {
"type": "Literal",
"value": 20,
"raw": "20"
}
}
],
"kind": "const"
}
],
"sourceType": "script"
}

解析分为两种情况,预解析与全量解析。

预解析

在实际应用中,有大量代码,声明了函数,但未被执行。因此,如果全部都做全量解析的话,就会产生很多无用功。

预解析有以下特点

  • 预解析会跳过未被使用的代码
  • 不会生成 AST,会产生 scopes 信息
  • 解析速度快
  • 根据规范抛出特点的错误
1
2
3
4
5
6
7
8
function foo1() {
console.log('foo1');
}
function foo2() {
console.log('foo2');
}

foo2();

对于 foo1 来说,函数并没有声明,那么生成 ATS 并没有意义。所以 foo1 采用的就是预解析,可以观察到 foo1 函数作用域的信息已经生成了。也就是说,作用域的范围信息,在预解析阶段就已经确定了。
pic.1708406487925

全量解析

全量解析会解析当前作用域的所有代码。会生成 AST,并且进一步明确更多的信息。

  • 解析给使用的代码
  • 生成 AST
  • 构建具体的 scopes 信息,变量引用,声明等。
  • 抛出所有的语法错误

需要区分的是,作用域和作用域链的信息在预解析阶段就确定了

1
2
3
4
5
6
7
8
9
10
11
12
13
// 声明时未调用,因此会被认为是不被执行的代码,进行预解析
function foo() {
console.log('foo');
}

// 声明时未调用,因此会被认为是不被执行的代码,进行预解析
function fn() {}

// 函数立即执行,只进行一次全量解析
(function bar() {})();

// 执行 foo,那么需要重新对 foo 函数进行全量解析,此时 foo 函数被解析了两次
foo();

3.Lgnition

Lgnition 是 V8 提供的一个解释器。它会将 AST 转为字节码「bytecode」。我们可以把这个过程理解为预编译。

4.TurboFan

TurboFan 是 V8 引擎的编译器模块。他会将 lgnition 收到的信息转为汇编代码。汇编语言可以理解为,表达了对寄存器的一个交互过程。

汇编代码就是对机器代码的封装,让人勉强能读懂。比如计算机一条加法指令为 10001010,汇编语言可用 add 表示。

汇编入门:

寄存器 概述
eax 累加器,可用于加减乘除等操作,使用频率高
1
2
mov eax, 5; // 将数字5,传送到寄存器 eax 中
add eax, 6; // eax 寄存器加6,此时 eax 得到新的结果

汇编就是使用约定好的指令,对寄存器进行各种操作。

lgnition + turboFan ,也就是「边解释边执行」。

5.Orinoco

Orinoco 是 V8 的垃圾回收器。

垃圾回收器会定期执行以下任务

  1. 标记活动对象和非活动对象「标记阶段」
  2. 回收/重用非活动对象所占用的空间「清理阶段」
  3. 整理内存「整理阶段」

es6常用特性

默认参数

1
2
3
4
var fn = function (width, height) {
var width = width || 50;
var height = height || 100;
};

没有默认参数之前,一切没有问题,知道遇到参数 0 时,就有问题了。0 为假值,就无法变为参数本身的值。在 es6 中,修复了这一缺陷。

1
2
3
var fn = function (width = 50, height = 100) {
...
}

模板字符串

模板字符串是增强版的字符串,用反引号````标识。
如果要在模板字符串中嵌入变量,将变量名写在${}中。
模板字符串会保留所有的空格和换行。

解构赋值

es5 写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 对象
var obj = {
age: 18,
name: 'yy',
};

var age = obj.age;
var name = obj.name;

// 数组
var arr = [1, 2];
var item1 = arr[0];
var item2 = arr[1];

解构赋值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 对象
var obj = {
age: 18,
name: 'yy',
};

var { age, name } = obj;

// 如果你想自定义变量名,可用冒号重命名,如myage
var { age: myage, name } = obj;

// 数组
var arr = [1, 2];
var [item1, item2] = arr;

箭头函数

箭头函数内部的 this 是词法作用域,由上下文确定

1
2
3
4
var _this = this;
$('.btn').click(function (event) {
_this.sendData();
});

有了箭头函数,就不必使用 that=this、_this=this、.bind(this)…,它总是可以按我们预期使用。

1
2
3
$('.btn').click((event) => {
this.sendData();
});

更简洁的语法:

1
2
3
4
5
const fn = () => {
dosomething;

return true;
};

如果返回结果是单个语句,可以省了{}return

1
2
3
4
const fn = () => true;

// 如果返回的是对象,需要用()包裹
const fn = () => ({ a: 1 });

let 和 const

使用 letconst 生命的变量,只在代码块里有效。代码块可以理解为大括号{}

如果你在 if,for 里面使用 letconst,那么只能再块里使用,在外部访问会报错。

1
2
3
4
5
if (true) {
const a = 1;
}

console.log(a); // Uncaught ReferenceError: a is not defined

暂时性死区

在使用let声明变量之前,对变量进行访问会报错

1
2
3
4
5
6
if (true) {
tmp = 'abc'
console.log(tem) Uncaught ReferenceError: Cannot access 'tmp' before initialization

let tmp
}

不存在变量提升

1
2
3
4
5
console.log(foo); // undefined
var foo = 2;

console.log(bar); // ReferenceError: bar is net defined
let bar = 2;

不能重复声明

不能在布局相同作用域重复声明

1
2
3
4
5
6
7
8
9
10
11
12
13
function fn() {
let x = 1;
let x = 2; // SyntaxError: Identifier 'x' has already been declared
}

if (true) {
let x = 1;
let x = 2; // SyntaxError: Identifier 'x' has already been declared
}

//全局作用域可以,后者覆盖前者
let x = 1;
let x = 2;

const声明一个只读的常量,一旦声明,常量的值就不能改变。

1
2
3
const x = 1;

x = 2; // TypeError: Assignment to constant variable.

如果const声明的是一个对象,可对这个对象下的属性进行增删改操作

1
2
3
4
5
6
7
8
9
10
11
12
13
const o = {
x: 1,
y: 2,
};

// 添加
o.z = 3;

// 删除
delete o['z'];

// 修改
o.x = 11; // o = { x: 11, y: 1 }

如果不希望 const 生命的对象被修改,可用 defineProperty定义属性的权限。

1
2
3
4
5
6
7
8
9
const o = { x: 1 };

Object.defineProperty(o, 'x', {
writable: false,
});

o.x = 2;

console.log(o); // { x: 1 }

Class 类

为了语法更接近传统的面向对象语言(java 和 c++),引入了 Class 类的概念,作为对象的模板。

通过 class 关键字定义类:

1
2
3
4
5
6
7
8
9
10
11
12
class Student {
// 构造函数
constructor(name, age) {
this.name = age; // this指向实例对象
this.age = age;
}

// 定义方法,方法名前面不需要function,挂载在原型上
printName() {
return this.name;
}
}

extends

extends 关键字用于继承父类

super

super 可当做函数使用,也可当做对象使用。

作为函数时,只能在子类构造函数里调用。

1
2
3
4
5
class B extends A {
constructor() {
super();
}
}

super代表的是父类的构造函数,但是返回的是子类的实例。 相当于 A.prototype.construcor.call(this)。也就是说,super()内部的 this 指向的是子类 B。

作为对象时,在普通方法中,指向的是父类的原型对象;在静态方法中,指向父类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Parent {
static myMethod(msg) {
console.log('static', msg);
}

myMethod(msg) {
console.log('instance', msg);
}
}

class Child extends Parent {
static myMethod(msg) {
super.myMethod(msg);
}

myMethod(msg) {
super.myMethod(msg);
}
}

// super在静态方法中,指向父类
Child.myMethod(1); // static 1

// super在普通方法中,指向父类的原型
var child = new Child();
child.myMethod(2); // instance 2

链判断运算符 ?.

如果一个对象为undefinednull,我们试着去访问该对象下的属性时就会抛出异常

1
2
const obj = undefined;
console.log(obj.name); // Uncaught TypeError: Cannot read properties of undefined (reading 'name')

使用链判断运算符,就不会抛出异常

1
2
3
console.log(obj?.name); // undefined
等价于;
console.log(obj == undefined ? undefined : obj.name);

还有一个常用的错误使用,链运算不能应用于赋值运算符左侧

1
obj?.name = 'tom'; // The left-hand side of an assignment expression may not be an optional property access.ts(2779

ts 非空断言
在有些情况下,你如果明确 obj 对象不为nullundefined,你可以使用非空断言符

1
obj!.name = 'tom';

这是 TypeScript 中的非空断言操作符,用来告诉编译器您已经检查过属性是否为非空,并且可以放心地访问它。

js中的重要概念

在当前函数中,要寻找到变量的值是从哪里来的,就首先会从当前执行上下文中查找,如果没有找到,则会去作用域链中查找。这里需要注意的是,作用域链本身就是存在于函数对象中的一个属性 [[Scopes]],因此不是一层一层的往上查找「这里经常理解有误」,该属性是在代码预解析阶段就已经确认好的。

作用域:作用域是规定变量与函数可访问范围的一套规则。

作用域的范围信息,是在预解析阶段就已经确定的

执行上下文:执行上下文包含了跟踪对应可执行代码执行进度所需要的所有状态,每个执行上下文中都有特定的实体对象用于记录这些特定状态。

函数调用栈:管理所有的执行上下文

运行时上下文:只能有一个,且处于栈顶。当代码执行过程中,有新的函数调用,新的执行上下文会被创建,入栈,并且成为新的运行时上下文

完整的作用域链:

都具备一个属性 [[Scopes]],该属性中存储了当前函数可访问的所有变量对象

  • Global 全局对象:不会做任何优化,会包含全局对象中的所有属性与方法
  • Script 对象:在全局环境下,由 let 和 const 声明的变量对象
  • Closure 对象:我们讨论比较多的闭包对象,嵌套函数生成,仅会保存当前作用域能够访问的变量属性
  • Local 对象:以上的几种变量对象,都会存在于函数的 [[Scopes]]属性之中,因为他们都能够在函数解析时确认,而 Local 对象则不行,需要在函数的执行过程中才能确定,并且在执行过程中,该对象中的属性是随时会发生变化的,该对象除了会存储当前函数上下文中所有的变量与函数声明,还会额外记录 this 的指向。

Local: 活动对象

函数参数,var 声明的变量,let/const 声明的变量,function 声明的变量,class 声明的变量,this 指向等共同组成。
仅仅只有处于栈顶的执行上下文,才会生成 Local 对象。并且 Local 对象的具体内容会在执行上下文的生命周期中不断变化。也就意味着,在执行上下文的创建阶段,只有函数参数、function 声明的变量、this 指向 能够明确具体的值,其他变量的初始值都为 undefined,然后在代码执行过程中逐步明确赋值。

事件循环机制

同步于异步

同步:按函数调用栈执行

异步:分发器分发一个任务,被通知后执行

定义

负责 JS 执行环境的代码执行顺序的问题。如果没有异步事件,函数调用栈几乎可以解决所有执行顺序问题。而事件循环机制,就是异步事件代码执行顺序的解决方案。

那么,浏览器中有哪些异步事件,以及它们各自有哪些特点呢?

线程

许多异步事件,都是由线程负责处理。
pic.1708327092141
JS 是单线程的,但是 JS 的执行环境是由多个线程协同工作的。不同的线程,对应着不同的异步事件。

五个线程

GUI 线程

负责 HTML 的解析与渲染。DOM 结构的修改是同步的,但是 DOM 的渲染过程是异步的

JS 引擎线程

负责 JS 代码的运行。
每一个网页,只会启动一个 JS 线程来配合完成页面的交互。

定时器线程

专门负责 setTimeout/setInterval 的逻辑。
回调函数中的逻辑并不会马上执行,即使将时间设置为 0,这也是异步的。

I/O 时间触发线程

当我们鼠标点击与滑动、键盘的输入等都会触发一些事件,而这些事件的触发逻辑的处理,就是依靠事件触发线程来帮助浏览器完成。

该线程也会把事件的逻辑放入队列中,等待 JS 引擎的处理。

http 线程

使用无状态短链接的 http 请求,在应用层基于 http 协议的基础之上,达到与服务端进行通信的目的。

该线程的触发逻辑,不是在 JS 引擎线程中,这个过程是异步的。

小结:除了 JS 引擎线程,其他四个线程分别处理与之对应的异步任务。比如定时器线程,由任务分发器 setTimeout /setInterval 分发异步任务进入定时器执行队列。

与 UI render 紧密相关的 raf/ric

requestAnimationFrame 简称「raf」,它是动画的重要实现手段。他跟前面介绍的异步方式大有不同,跟 UI render 紧密相关。

结合浏览器的渲染机制共同理解。
常规的显示器的刷新率为 60 Hz,也就是说,1 秒钟把页面刷新了 60 次,是最合理的频率。

这也是浏览器在进行 UI render 的合理频率。因此,每一次的渲染时间,控制在 1000 / 60 ms 以内。对浏览器来说才不会负荷工作。

requestAnimationFrame 是完成符合浏览器刷新频率的回调方式。

1
2
3
4
5
6
如果单次执行时间大于 16.67ms,也就是刷新率低于 60 Hz,会表现出卡顿;

卡顿的理解:
网络直播时,如果网络不好,会丢包导致卡帧。也就是说,下一秒的包还没发给你,自动刷新,刷新的还是上一次的老包,所以表现为卡顿,看起来就像刷新率低一样。

会用掉帧策略(放弃某一帧的任务)来解决。而不使用任务堆积。
1
如果高于 60hz,浏览器会超负荷工作。raf 的回调函数为 1000 / 60 执行一次,刚刚好符合浏览器刷新频率

我们通常喜欢将一次 UI render 描述为 一帧 「frame」。requsetAnimationFrame 只会在每一帧开始渲染之前执行。
pic.1708327251065

requestIdleCallback 简称「ric」,图中的idle,通常将优先级不高的任务放在 ric 中执行。

它的执行频率也跟 UI render 的频率一样。但是它会在每一帧的最后执行。

Promise

在浏览器中,线程对应的事件,并不能覆盖所有的异步事件类型。Promise 就是一个特例。

Promise 是 JS 的内部逻辑。并非由浏览器额外的线程来处理。因此,Promise 的异步逻辑与线程对应的异步逻辑是不一样的。

在 JS 引擎的处理逻辑中,Promise 有自己的事件队列,并且该队列在所有 JS 代码执行完成后执行。

1
2
3
const p = new Promise((resolve, reject) => {});

p.then(f, r);

job

then 中的回调函数,就是一个 job。此处为 f 与 r。catch 同理。

PromiseJobs 执行队列

用于存储 Promise 异步逻辑。该队列在所有 JS 代码执行完之后执行。也可以说在 call stach 清空之后执行。

状态

一个 promise 有三种状态

  • pending: 等待结果状态
  • fulfilled: 已出结果,结果符合预期完成状态
  • rejected: 已出结果,结果未符合预期完成状态

当一个 promise 实例在创建时,处于 pending 状态。
当 resolve 函数调用时, padding -> fulfilled
当 reject 函数调动是, padding -> rejected

当 promise 有了状态,不再是 padding ,那么我们称该 promise 的状态被固定: settled

何时进入 PromiseJobs

调用 p.then(f, r)时,会将 f 放入 [[PromiseFulfillReactions]]队列尾部,将 r放入 [[PromiseRejectReactions]]队列尾部。

这两个队列,是临时中间队列(准确的说,应该叫临时存储地方,出队时是无序的)。该队列中的 job 只会移入到 PromiseJobs 队列中而不会有自己的执行过程。 PromiseJobs 才有执行 job 的逻辑。

resolve/reject函数调用时,promise 产生了结果。此时,根据不同的结果,p.then(f, r)将不同的 job 「f / r」加入到 PromiseJobs 队列中。

如果在创建时,就已经 settled ,那么 job 会直接进入 PromiseJobs 队列中。

1
2
3
const p = new Promise((resolve, reject) => {
resolve(); // 直接敲定状态
});

链式调用时,后续的 then 如何将 job 加入到 PromiseJobs 队列,需要根据上一个 then 的返回结果来决定。

  • 当不确定返回结果时,且 then 已调用,对应的 job 进入临时队列中。
  • 确定了返回结果之后,才会将 job 移入到 PromiseJobs 队列中

PromiseJobs 队列如何执行

先进入的先执行。但是有一点要注意,当 job 执行时,可能会产生新的 job 进入到该队列。因此 PromiseJobs 在执行过程中会动态变化

PromiseJobs 的执行规则

  • 当所有 JS 代码执行完毕,PromiseJobs 队列开始出队执行
  • PromiseJobs 处于动态变化中,只有当 PromiseJobs 队列为空时,才会结束执行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const PromiseJobs = [];

// 加入 job1
PromiseJobs.push(job1);
// 加入 job2
PromiseJobs.push(job2);

PromiseJobs = [job1, job2];

let job;
// 将先进入队列的 job 移除队列,并执行
// PromiseJobs 是处于动态变化的,所以只能用 while 来处理这种动态循环的场景。不能用 for 循环
while ((job = PromiseJobs.shift())) {
job();
}

通过两例子感受一下
例子一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const p1 = new Promise((resolve) => {
resolve();
})
.then(function f1() {
console.log(1);
const p2 = new Promise((resolve) => {
resolve();
})
.then(function f3() {
console.log(2);
})
.then(function f4() {
console.log(4);
});
})
.then(function f2() {
console.log(3);
});
console.log(0);

// 输出: 0 1 2 3 4

Promise 在创建时,直接调用了 resolve , Promise 有了结果,因此, f1 马上被加入 PromiseJobs 队列。f2 要等待 f1 的结果,所以只能被加入临时队列。

1
2
PromiseJobs = [f1];
PromiseFulfillReactions = [f2];

输出 0, JS 所有代码执行结束, 开始执行 PromiseJobs 列队中的逻辑

** f1 出队执行**, 输出 1, 遇到一个新的 Promise 对象, 且直接调用了 resolve , f3 进入 PromiseJobs 队列,f4 需要等到 f3 的执行结果, 所以进入临时队列

1
2
PromiseJobs = [f3];
PromiseFulfillReactions = [f2, f4];

f1 执行结束, 可以得知 f1 返回了 undefined, 等价于 resolve(undefined). f1 有了结果, 所以 f2 进入 PromiseJobs 队列.

1
2
PromiseJobs = [f3, f2];
PromiseFulfillReactions = [f4];

又开始执行 PromiseJobs 中的任务。

f3 出队执行, 输出 2
f3 执行完毕, 返回 undefined, 因此 f4 进入 PromiseJobs 队列

1
2
PromiseJobs = [f2, f4];
PromiseFulfillReactions = [];

开始执行 PromiseJobs 中的任务

依次执行 f2 f4, 没有产生新的 job, PromiseJobs 变为空, 当前循环结束。

例子二:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
new Promise((resolve) => {
resolve();
})
.then(function f1() {
new Promise((resolve) => {
resolve();
})
.then(function f3() {
console.log(1);
})
.then(function f4() {
console.log(2);
})
.then(function f5() {
console.log(3.1);
});
})
.then(function f2() {
console.log(1.1);
new Promise((resolve) => {
resolve();
})
.then(function f6() {
new Promise((resolve) => {
resolve();
})
.then(function f7() {
console.log(4);
})
.then(function f8() {
console.log(6);
});
})
.then(function f9() {
console.log(5);
});
})
.then(function f10() {
console.log(3);
});
console.log(0);

// 输出: 0 1 1.1 2 3 3.1 4 5 6

Promise 在创建时, 直接调用了 resolve, 所以 f1 进入 PromiseJobs 队列. PromiseJobs = [f1]
f2 需要等待 f1 的执行结果才能进入 PromiseJobs 队列中, f10 需要等待 f2, 所以 f2 f10 都进入临时队列. PromiseFulfillReactions = [f2, f10]
输出 0, js 所有代码执行结束. 开始执行 PromiseJobs 队列中的逻辑

f1 出队并执行, f1 执行过程中, 遇到了新的 Promise, 并直接调用了 resolve, 所以 f3 的状态直接被固定, 进入 PromiseJobs 队列. PromiseJobs = [f3]
f4 和 f5 需要等待 f3 的结果才能入 PromiseJobs 队列. 所以只能进入临时队列. PromiseFulfillReactions = [f2, f10, f4, f5]
f1 逻辑代码执行完毕, 相当于 resolve(undefined) , f2 进入 PromiseJobs 队列.  PromiseJobs = [f3, f2], PromiseFulfillReactions = [f10, f4, f5]. 开始执行 PromiseJobs 队列中的逻辑

f3 出队并执行, 输出 1, 逻辑代码执行完毕, f3 返回 undefined, 因此 f4 进入 PromiseJobs 队列.  PromiseJobs = [f2, f4], PromiseFulfillReactions = [f10, f5]. 开始执行 PromiseJobs 队列中的逻辑

f2 出队并执行, 输出 1.1, 遇到新的 Promise, 新的 Promise 直接调用了 resolve, f6 直接进入 PromiseJobs 队列.  PromiseJobs = [f4, f6], f9 进入临时队列, PromiseFulfillReactions = [f10, f5, f9]
f2 代码执行完毕, 返回 undefined, f10 进入 PromiseJobs , PromiseJobs = [f4, f6, f10], PromiseFulfillReactions = [f5, f9]. 开始执行 PromiseJobs 队列中的逻辑

f4 出队并执行, 输入 2, 返回 undefined, f5 进入 PromiseJobs, PromiseJobs = [f6, f10, f5], PromiseFulfillReactions = [f9]. 开始执行 PromiseJobs 队列中的逻辑

f6 出队并执行, 遇到了新的 Promise, 新的 Promise 直接调用了 resolve, f7 进入 PromiseJobs 队列, PromiseJobs = [f10, f5, f7], f8 进入临时队列.  PromiseFulfillReactions = [f9, f8].
f6 执行完毕, 返回 undefined, f9 进入 PromiseJobs . PromiseJobs = [f10, f5, f7, f9].  PromiseFulfillReactions = [f8]. 开始执行 PromiseJobs 队列中的逻辑

f10 出队并执行, 输出 3, PromiseJobs = [f5, f7, f9], PromiseFulfillReactions = [f8].  开始执行 PromiseJobs 队列中的逻辑

f5 出队并执行, 输出 3.1,  PromiseJobs = [f7, f9], PromiseFulfillReactions = [f8].  开始执行 PromiseJobs 队列中的逻辑

f7 出队并执行, 输入 4, 返回了 undefined, f8 进入 PromiseJobs 队列.  PromiseJobs = [f9, f8], PromiseFulfillReactions = [].  开始执行 PromiseJobs 队列中的逻辑

f9 出队并执行, 输出 5, PromiseJobs = [f8], PromiseFulfillReactions = [], 开始执行 PromiseJobs 队列中的逻辑

f8 出队并执行, 输出 6, PromiseJobs = [], PromiseFulfillReactions = []. 循环结束

内循环的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const queue = [];

// 定义一个事件分发器
function rafx(cb) {
queue.push(cb);
}

rafx(() => {
console.log(0.1);
});

rafx(() => {
console.log(0.2);

rafx(() => {
console.log(0.21);
});
});

rafx(() => {
console.log(0.3);
});

rafx(() => {
console.log(0.4);
});

rafx(() => {
console.log(0.5);
});

let cb;
while ((cb = queue.shift())) {
cb();
}

// 输出: 0.1 0.2 0.3 0.4 0.5 0.21

queue 队列为空时,才结束执行。

事件循环到底是怎么回事

我们已经知道,单纯依靠 call stask 不能完全覆盖所有代码的执行逻辑,call stask 的代码执行顺序永远都是同步的逻辑。对于许多线程引发的异步逻辑,需要依靠队列机制。
pic.1708327269186
每一个异步行为,都有对应的执行队列。

执行队列

会在一轮循环中,直接执行的队列。如 PromiseJobs 队列。

临时队列

不会马上执行,处于等待状态的队列。在 promise 中,有两个临时队列, PromiseFulfilledReactions 与 PromiseRejectedReactions 。在满足条件后,才会将该队列中的任务,移入到执行队列中。有的临时队列又被称为事件表 Event Table,或注册表。

关于临时队列的理解
通常,在代码中,setTimeout,事件 I/O,http 请求,都会通过回调的方式编写代码的执行逻辑。
例如

1
2
3
4
function foo() {
// 点击之后执行的逻辑
}
d.onclick = foo;

foo 就是回调函数。函调函数里的逻辑不会马上执行,而是要等到条件满足之后才会执行。

执行队列有哪些

  • scriptJobs:指的是 script 标签,更顶层的供任务,S 代码执行的起点
  • rafs:requestAnimationFrame 对应的队列
  • UI render:渲染 UI 的任务队列
  • ric:requestIdleCallback 对应的队列
  • event queue:I/O 事件列队
  • timer queue:定时器队列,由 setTimeout/setInterval 分发
  • http queue:http 队列
  • PromiseJobs:Promise 队列,由 p.then 分发

宏任务队列与微任务队列

每个队列都有各自鲜明的特点,如果非要区分这些异步队列是宏任务队列还是微任务队列,那么,除了 PromiseJobs ,其它的都可以理解为宏任务队列。

一轮循环的起点

同一时间,不会存在两个任务同时执行的情况。
UI 渲染也是一个任务,也不会与其它任务同时执行。所以我们常说,UI 渲染与 JS 代码时互斥的关系。

  • 我们知道,PromiseJobs 是内循环,所以永远不可能作为一轮循环的起点
  • UI render 的渲染,由 GUI 线程执行。所以 UI render 队列也不会是一轮循环的起点。

UI render 队列,是纯粹的渲染任务队列。既然要渲染任务,那必须有分发任务的指令才知道如何渲染。发起一个 http 请求也算是一个指令。

除了 PromiseJobs 和 UI render,其他所有任务队列中的任务「称之为 task」,都有可能是一轮循环的起点。

一轮循环完毕的标志

当次循环所有的执行队列都清空之后,一轮循环完毕。一轮循环完毕的标志是最后一个 task 中的内循环 PromiseJobs 队列清空

事件循环的顺序

从 script 开始第一次循环

  1. 所有能作为起点的队列中的任务,都是进入主线程执行,借助函数调用栈依次执行,等调用栈清空,并且 PromiseJobs 为空,当次任务结束。PromiseJobs 是 task 的内循环
  2. 当次任务执行过程中,可能会产生新的任务,这些任务会放入临时队列或者下一次循环(比如定时器任务)的执行队列,当次循环的执行队列是执行一个少一个,直到清空为止。
  3. 任务分发时,多半都是进入临时队列,满足条件后,进入执行队列。
  4. 下一轮循环从执行队列中的第一个任务开始执行,直到当次执行队列清空为止
  5. 多个执行队列之间存在先后关系。raf -> ui render -> [event,http,timer] -> ric
  6. raf | ui render | ric 三个队列中的 task 不会每次循环都执行,他们的执行频率要和刷新率保持一致。因此多次循环中,他们都不会执行「执行队列为空」

任务进入执行队列的时机

以 setTimeout 为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
document.onclick = () => {
console.log('s');
setTimeout(() => {
console.log(0);
}, 0);

setTimeout(() => {
console.log(1);
}, 1000);

setTimeout(() => {
console.log(2);
}, 2000);

for (var i = 0; i < 5000000000; i++);
console.log('e');
};

// 点击之后的输出结果
// 先立刻输出 s
// 10s 之后,依次快速输出
// e 0 1 2

分析一下
setTimeout 执行时,三个 task 进入了临时队列。

但是 for 循环的执行时间非常长,超过了 3 秒,因此,在 for 循环执行的过程当中,定时器线程发现 timer 临时队列中的任务满足了条件,就之间放入到了 timer 执行队列。

等 for 循环结束时,就依次快速输出 e 1 2 3。

再说 setInterval

1
2
3
setInterval(function f1() {
func();
}, 100);

当 f1 的执行之间,会将 f1 放入临时队列。然后每隔 100 ms,会将 f1 这个任务重复的放入执行队列中。

这时候会有个问题,如果任务 f1 的执行时间超过 100 ms, 那么一轮循环里,执行队列里必然会多出一个 f1 任务,这就会让本次循环的时间拉长,后面队列的任务就会等待更多时间。

所以在 chrome 中,为了弱化这种情况的影响,timer 队列往往放在最后执行「仅比 ric 早」。

也正是这个原因,我们应该避免使用 setInterval, 不合理的使用可能会造成页面的严重卡顿。

所以我们常常使用 setTimeout 的递归调用方式来取代 setInterval .

1
2
3
4
5
6
7
function fn() {
console.log(2);
// 这句代码一定要放在最后
setTimeout(fn, 100);
}

fn();

这种递归的方式,每次在 fn 执行完之后,才会 push 一个任务到临时队列中。然后临时队列满足 100 ms 的时间之后推入执行队列。也就是说,执行队列中的任务始终只会有一个,即使 fn 的执行时间超过了 100 ms,那它的影响也仅此而已,不会像 setInterval 那样累加。

举个例子

最有,再通过一个例子分析下事件循环的执行顺序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
setTimeout(function s1() {
console.log(5);
}, 1);

setTimeout(function s2() {
console.log(6);
}, 0);

new Promise(function (resolve) {
console.log(1);
for (var i = 0; i < 1000; i++) {
i == 99 && resolve();
}
console.log(2);
}).then(function p1() {
console.log(4);
});

console.log(3);

// 输出 1 2 3 4 5 6

事件循环从 script 开始, 在主线程, 此时只有 scriptJobs 队列中有任务

遇到第一个 setTimeout 函数, setTimeout 进入函数调用栈, setTimeout 分发一个 task s1 进入临时队列, 但是因为 1ms 时间太短, 因此 s1 直接进入 timer 执行队列, timer = [s1], setTimeout 出栈

我们可能决定不立刻执行一个函数,而是在某时间之后执行,一般我们成为“调度执行”
因为调度的执行时间太短了,只有 1ms , 因此会立刻进入到执行队列。如果改成 2ms ,就可能不会这么快,就会先进入临时队列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
setTimeout(function s1() {
console.log(5);
}, 1);

setTimeout(function s2() {
console.log(6);
}, 0);
// 输出: 5 6

setTimeout(function s1() {
console.log(5);
}, 2);

setTimeout(function s2() {
console.log(6);
}, 0);
// 输出: 6 5
又遇到 setTimeout, setTimeout 进入函数调用栈执行, setTimeout 分发一个 task s2, 因为延迟时间为 0, s2 直接进入 timer 执行队列队列, timer = [s1, s2] setTimeout 出栈

接下来遇到 Promise 的创建, Promise 构造函数进入函数调用栈, Promise 的第一个参数我们称为 executor, executor 会在 Promise 内部直接执行, 所以 executor 进栈,
又遇到 log 函数, log 进栈,输出 1, log 出栈. 在 for 循环时 遇到了 resolve 函数, resolve 进栈执行, Promise 状态被固定. log 进栈, 输出 2, log 出栈, resolve 出栈, executor 出栈, Promise 出栈

Promise 执行完, 接着执行 then 方法, 因此 then 进栈. then 方法时 Promise 的异步分发器. 本来 p1 应该直接进入 Promise 的临时队列, 但是 resolve 已经直接执行过了, 状态被固定, 因此已经知道了该如何执行, 所以 p1 直接进入 PromiseJobs 执行队列.
p1 进入执行队列后, then 执行完毕, 出栈.

然后遇到 log, log 进栈, 输出 3, log 出栈.

到这里, 主线程中的代码执行完毕, 调用栈也被清空了, 因此接下来就要执行 PromiseJobs 内循环. 发现 PromiseJos 队列中有任务, 开始执行 PromiseJobs 队列中的逻辑

p1 出队, 进入调用栈, p1 开始执行, log 进栈, 输出 4, log 出栈. p1 逻辑执行完毕, 出栈. 至此, PromiseJobs 执行队列已经清空, 内循环执行完毕. 当轮产生的 setTimeout 的任务, 应该放在下一轮执行. 至此第一轮循环结束

开始第二轮循环, 发现 timer 执行队列中有任务, s1 出队并进入函数调用栈, log 进栈, 输出 5, log 出栈, s1 出栈, timer = [s2]

s2 出队并进入函数调用栈, log 进栈, 输出 6, log 出栈, s2 出栈. s2 执行完之后, timer 队列也被清空了, 检查发现 PromiseJobs 队列中也没有任务, 所以可执行代码执行完毕, 循环结束

模拟一个没有临时队列的外循环事件分发器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 用数组模拟一个队列
var tasks = [];

// 模拟一个事件分发器
var addFn1 = function (task) {
tasks.push(task);
};

// 执行所有的任务
var flush = function () {
tasks.map(function (task) {
task();
});
};

// 最后利用setTimeout/或者其他你认为合适的方式丢入事件循环中
setTimeout(function () {
flush();
});

// 分发任务
addFn1(function add() {
console.log('my name is add fn');
});
addFn1(function foo() {
console.log('my name is foo fn');
});

也可以不利用事件循环,而是手动在适当的时机去执行对应的某一个方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 用数组模拟一个队列
var tasks = [];

// 模拟一个事件分发器
var addFn1 = function (task) {
tasks.push(task);
};

// 去执行对应的某一个方法
var dispatch = function (name) {
tasks.map(function (item) {
if (item.name === name) {
item.handle();
}
});
};

// task 只需要多保存一个 name 即可。格式如下
demoTask = {
name: 'demo',
handle: function () {
console.log('my name is demoTack');
},
};

addFn1(demoTask);
dispatch('demo');

于是,一个订阅-通知的设计模式就这样实现了。

思考

为什么要有一轮两轮
相当于给一个浏览器一个缓存的时间。告诉浏览器下一轮该执行什么,而不是打他个措手不及。如果没有异步任务,那其实一轮就够了。
再回到之前的例子

1
2
3
4
function foo() {
// 点击之后执行的逻辑
}
d.onclick = foo;

从 script 标签开始第一轮循环,给 I/O 事件临时队列分发一个 task foo,主线程代码执行完毕,第一次循环结束。

当满足 onclick 条件时,foo 移入 I/O 事件队列, 并开始出队执行。

作用域和作用域链

作用域

作用域是 规定变量与函数可访问范围的一套规则

最常见的两种作用域,分别是全局作用域和函数作用域。

词法作用域

与词法作用域对立的是动态作用域,所有也可以成为静态作用域。因为我们前端领域没有动态作用域的概率,所以可以统称为作用域。

全局作用域

全局作用域中声明的代码,可以在任何地方被访问。以下三种情形属于全局作用域

  1. 在全局对象(window)下的属性与方法
  2. 在最外层声明的变量与方法
  3. 在非严格模式中,函数作用域中未申明直接赋值的属性和方法

我们应该尽量少将变量或方法定义为全局,因为

  1. 我们可能无意间修改了全局变量的值,但其他场景不知道
  2. 容易造成命名冲突
  3. 在应用程序的执行过程中,全局变量的内存无法被释放

知识体系关联:

css 没有作用域的概念,那么也就意味着,每一个组件的样式都有可能影响别的组件布局,因为我们常常会通过一些方式让当前组件具备方为约束,以达到作用域的效果

1
2
3
4
.sidebar .container {
}
.sidebar .content {
}

在 vue 中,也专门提供了 css 作用域的语法,以达到类似效果

1
2
3
4
5
<style scoped>
.example{
color: red;
}
</style>

会被编译为

1
2
3
4
5
6
7
8
9
<style>
.example[data-v-f3f3eg9] {
color: red;
}
</style>

<template>
<div class="example" data-v-f3f3eg9>hi</div>
</template>

有一个细节需要关注一下,在全局声明的变量还存在一些差异。
使用 var 声明的变量,会被挂载到 window 对象下
而使用 const/let 的变量,会被挂载到 script 对象下

函数作用域

函数表达式或函数声明让花括号具备作用域,我们称之为函数作用域。函数作用域中的变量与方法,只能在下层子作用域中被访问,不能被其他不相关的作用域访问。

1
2
3
4
5
6
7
var arr = [1, 2, 3, 4, 5];

for (var i = 0; i < arr.length; i++) {
console.log('do something by ', i);
}

console.log(i); // i == 5

这种方式并不会有作用域的限制,i 挂载在全局对象下,循环结束后,i 还能被访问。
pic.1708405619882

模拟块级作用域

在没有块级作用域之前,我们可以使用自执行函数模拟块级作用域

1
2
3
4
5
6
7
(function () {
for (var i = 0; i < arr.length; i++) {
console.log('do something by ', i);
}
})();

console.log(i); // i is not defined

块级作用域

es6 引入了块级作用域。使用letconst声明的变量,能被任何花括号约束

let

1
2
3
4
{
let a = 10;
}
console.log(a); // Uncaught ReferenceError: a is not defined
1
2
3
for (let i = 1; i <= 5; i++) {
console.log(i); // 1 2 3 4 5
}

不能重复声明

1
2
let a = 10;
let a = 20; // Uncaught SyntaxError: Identifier 'a' has already been declared

不能在声明前访问,因为存在暂时性死区

1
2
console.log(a); // Uncaught ReferenceError: Cannot access 'a' before initialization
let a = 10;

const

const 与 let 差不多,唯一不同的是,const 声明的变量的值不能被修改。

如果是基础数据类型,则值不能被修改。

1
2
const a = 1;
a = 2; // Uncaught TypeError: Assignment to constant variable.

如果是引用数据类型,则地址不能被修改。

1
2
3
4
const a = { n: 1 };
// a = {} // 这样不行
a.n = 2; // 这样可以修改
console.log(a);

let 和 const 的使用原则是,优先使用 const,不能用 const 再用 let。

作用域链

在一个函数内,能够访问哪些变量与函数,都要有一个具体的方式体现出来。因此有了作用域链。

每一个函数都有一个 [[scopes]] 属性,它是由一系列父作用域对象组成的数组。

当一个函数要寻找变量的值是从哪来的,就首先会在当前执行上下文中查找,如果没有找到,就回去作用域链中查找。作用域链存在于函数对象的一个属性 [[scopes]],该属性在代码预解析阶段就已经确定了。

完整作用域链的组成部分

  • Global 全局对象
  • Script 对象:全局环境下 let/const 声明的变量对象(变量和函数)
  • Closure 对象:闭包对象,嵌套函数生成
  • Local 对象:可称为活动对象。只有这个对象是在函数执行过程中才会确定,其它在函数声明是就确定了。local 对象会记录当前函数上下文的变量与函数声明,还会额外记录 this

内存与数据结构

把一个页面看做一个完整的独立应用。

那么,哪些个体参与了应用,个体以什么形式存在的,个体存放在哪里,个体在内存中如何存放?

个体 -> 数据类型 -> 内存 -> 数据结构

个体

个体可以理解为角色,这些角色会参与应用运行
有变量、函数、对象

1
2
3
4
5
6
// 申明变量
var a = 10;
// 申明函数
function add() {}
// 申明对象
class M {}

a、add、M 表示个体的名字。我们可以通过名字,访问到具体的值。

数据类型

7 种基础数据类型和一种引用类型

基础数据类型

基础数据类型:Boolean、Null、Undefined、Number、String、BigInt、Symbol

重点理解 基础数据类型的值,是不可变的

1
2
3
let a = 1;
let b = a;
b++;

将 a 赋予 b,这时候 a 和 b 是等价的。随后 b++,b 的值被改变了,a 的值却没有改变。这意味着 a b 的等价,并不表示他们是同一个值。也就是说,a 赋予 b 的时候,重新给 b 分配了一块内存空间。因此我们说,基础数据类型,是按值访问的。用图表示
pic.1708326860642
基础数据类型的比较,是值在比较

1
2
3
const a = 1;
const b = 1;
a === b; // true

当 a 和 b 在比较的时候,本质上是值在比较。

还有一点要注意,当我们使用字符串时,竟然可以使用方法,这是因为访问字符串时,实际上依然是在访问一个对象。使用字符串调用方法时,经历了以下 3 步骤

1
2
3
4
5
6
7
8
// 首先使用包装对象创建对象
var _str = new String('hello world');

// 然后使用包装对象的实例去访问方法
_str.charAt(0);

// 最后销毁该对象
_str = null;

引用数据类型

与基础数据类型不同,引用类型的值是可以被改变的

1
2
3
4
5
6
7
8
9
const book = {
title: 'JavaScript 核心进阶',
author: '这波能反杀',
date: '2020.08.02',
};
const b2 = book;
b2.author = '反杀';
console.log(book); // {title: "JavaScript 核心进阶", author: "反杀", date: "2020.08.02"}
console.log(b2); // {title: "JavaScript 核心进阶", author: "反杀", date: "2020.08.02"}

我们发现,修改变量 b2 的值,变量 book 也被修改了。这是因为执行 b2=book 时,b2 拷贝的是 book 的地址,所以他们指向的是同样的引用类型值。我们可称之为浅拷贝。
pic.1708326879449

所以说,引用数据类型,是按引用访问的。这里的引用,指的就是内存中的地址。

地址:我们常说的地址,其实指的是内存中的首地址,因为一个引用对象占多少个内存格子是不确定的。内存管理器知道这个引用的大小,那么就能找到首尾地址。

引用类型的比较,本质上是他们的内存地址在比较

1
2
3
4
5
6
7
8
9
10
11
const a = {
name: '张三',
age: 20,
};
const b = a;
b.name = '李四';
a === b; // true

const c = {};
const d = {};
c === d; // false

由于 c 和 d 是两个分开创建的对象,所以他们在内存中的地址是不一样的,所以结果也不同。直接比较引用地址,我们可以称之为浅比较。

还有一种场景,只比较一层数据结构,也被认为是浅比较,这种比较成本比较低,浅拷贝同理。

数据类型判断

判断是否为数组

  1. Array.isArray(arr)
  2. arr instanceof Array
  3. Object.prototype.toString.call(arr) === ‘[object, Array]’

判断是否为对象

  1. isObject
1
2
3
4
function isObject(value) {
const type = typeof value;
return value != null && (type === 'object' || type === 'function');
}
  1. Object.prototype.toString.call(obj) === ‘[object Object]’
  2. obj instanceof Object 数组也成立

内存

运行一段 app/进程,操作系统会分配一段连续的内存空间,在内存中运行。

内存用于储存程序运行时的信息。CPU 通过寄存器直接访问,访问速度非常快。
硬盘可以将大量电影、图片等信息永久存储在硬盘。CPU 不能直接访问硬盘的数据,要通过硬盘控制器来访问。

我们可以将一些数据做持久化储存,也就是常说的本地缓存,其实就是将数据存储在硬盘。在浏览器中,提供了 localStorage 对象来帮助我们实现本地缓存。

内存和硬盘的区别:

对象 容量 访问速度 CPU 能否直接访问 存储时效
内存 程序运行时
硬盘 不能 持久性

JS 中,内存分为栈内存和堆内存。栈内存与堆内存本身没有区别,只因为存取方式的差异,而有了不同。

使用栈内存时,从地址高位开始分配内存空间。使用堆内存时,从地址低位开始分配。

:::info
内存中的地址本应该用 16 进制表示,为了便于理解,此处用 10 进制表示。
:::
pic.1708326895926

内存空间

Bit-比特
Byte-字节 计算机数据类型的最基本单位。1 Byte = 8 Bits
KB-千字节
MB-兆字节
GB-吉字节
TB-太字节

1 GM = 1024 MB
1 TB = 1024 GB

英文字母 占一个字节空间
中文汉字 占两个字节空间
英文标点符号 占一个字节空间
中文标点符号 占两个字节

指针、引用、对象

指针:也叫做首地址。指针占 4 个字节
0xFF
[1][2][3][4][][][][]

引用:指针的别名。在我们前端里,可以认为指针和引用是同个东西。

举个例子:
基础数据类型:

1
2
var a = 10;
a = 20;

0xFF(十六进制。我们这里用十进制代替,更直观)
[10][20][][][][][][]

改为 20 后,引用变量 a 的指针发生改变。10 失去了引用,等待被回收。所以我们说基础数据类型的值是不可变的。
0xFF
[10][20][][][]

引用数据类型:

1
var b = 首地址 -> { m: 10 }

首地址也是个基础数据类型。当我们改变引用类型时,首地址不变。

基础数据结构

基础数据结构主要介绍一下栈、堆、队列

说到栈,其实有两种场景,栈数据结构和栈内存空间。

第一种场景,栈数据结构:表达的是对数据的一种存取方式。

把栈数据结构的存取方式,通过乒乓球盒子来分析
pic.1708326910241
乒乓球依次入栈,5 号球位于最顶层,他一定是最后放进去的。1 号球位于最底层,你要想拿 1 号球,必须将上面所有乒乓球取出来之后才能取出。但 1 号球是最先放入盒子的。

这种存取方式与栈数据结构如出一辙。特点可以总结为先进后出,后进先出(LIFO Last in, First Out)。

javaScript 中,数组提供了两个栈方法来应对这种存取方式。

push:向数组末尾添加元素「进栈方法」
pop:删除数组末尾元素「出栈方法」

第二种场景,栈内存空间。刚刚提到,内存空间,因为操作方式不同才有了区别,而栈内存空间的操作方式,正是使用了栈数据结构的思维。

栈内存空间,用于记录函数的执行记录,管理函数的执行顺序。我们称他为函数调用栈「call stack」

应用场景:有效的括号、两个栈实现队列

堆数据结构:是树结构中的一种。常见的是二叉堆。

二叉堆分为两种类型:最大堆和最小堆

最大堆:堆顶元素是整个堆中的最大值。任何父节点的键值都大于任何一个子节点
最下堆:堆顶元素是整个堆中的最小值。任何父节点的键值都小于任何一个子节点

以最小堆为例:

  • 插入节点时,只能插入二叉堆的最后一个位置。比较节点时,如果插入节点比父节点大,则需要上浮,持续比较,直到比父节点小为止
  • 删除节点时,我们通常只会删除堆顶的元素。删除后结构出现了混乱,需要将最后一个节点补充到堆顶。补充后树结构不符合最小堆的特性,因此需要与子元素进行比较,找到最小的子元素与其交换位置,这个行为称之为下沉。持续比较,知道符合最小堆的规则

应用场景:
数组的 sort 方法,也是采用类似堆的排序逻辑;前中后序遍历;

1
2
3
4
5
6
7
8
9
function sort(a, b) {
// 最小堆(升序)
return a < b;
}

function sort(a, b) {
// 最大堆(降序)
return a > b;
}

栈和堆的区别

  • 分配方式不同:栈有操作系统分配;堆由开发人员分配
  • 存放内容不同:栈用于存放函数的参数值、局部变量、函数返回值等;堆的存放内容由开发人员填充
  • 释放方式不同:栈内存的数据,随生命周期函数的调用结束而结束。堆内存由开发人员释放,容易产生内存泄漏。
  • 空间大小不同:每个进程拥有的栈大小远远小于堆大小。理论上,栈只有几兆大小,堆可以是虚拟内存的大小。

队列

先进先出的思维。在浏览器中,所有的异步事件都是放在任务队列中。

优先级队列是一个重要的概念,决定了哪个任务优先执行。

运用到实践中,有如下常用操作:

  1. 从队列最后入队
  2. 从队列头部出队
  3. 从队列任意位置离队(有其他事情)
  4. 从队列任意位置插队(特殊权利)
  5. 清空队列

应用场景:先到先处理

  • 医院挂号
  • promiseJobs

链表

链表是一种递归的数据结构,由多个节点组成,节点之前使用引用相互关联,组成一根链条。

链表的特征:

  • 在内存中,链表是松散不连续的结构,通过引用确定节点之间的联系,不像数组那样是排列在一起的连续内存地址
  • 链表没有序列,如果引用是单向的,只能通过上一个节点,找到下一个节点
  • 节点之间的引用可以是单向的「单向链表」,也可以是双向的「双向链表」,还可以是首尾连接的「循环链表」

和数组的区别:

  • 在内存空间里,链表是松散的,不连续的。数组是紧密的,连续的
  • 在性能角度考虑,访问某个成员,数组远远优于链表,而新增/删除元素,链表远远优于数组

应用场景:

  1. 节点元素的相互引用,nextSibling、previousSibling
  2. 原型链中,由__proto__进行关联的单向链表
  3. react 源码中的Fiber节点中的nextEffect

内存空间管理

内存溢出:多指栈溢出

内存泄露:指某段内存空间无法被管理

  1. 没有引用,无法被访问
  2. 失去引用时,没被标记,无法被垃圾回收机制回收

思考

基础数据类型存在栈内存,引用数据类型存在堆内存,这句话对不对?
不对。栈内存和堆内存本身并没有区别,只是存取方式的差异而有了不同。基础数据类型可以在栈里,也可以在堆里,函数执行时的参数、变量都在栈里。引用类型的地址在栈里,引用类型的数据在堆里。

变量会存在内存中吗,如果存在,以什么方式存在
不存在。内存中只存在 16 进制的地址编码和具体的数值

m 的值是否被改变?为什么?

1
2
3
4
5
6
7
8
const m = {
a: 1,
b: 2,
};
function foo(arg) {
arg.a = 20;
}
foo(m);

m 被改变了。函数传入一个引用数据类型时,会干扰外部的值,参数变量的地址入栈内存,引用类型对象入堆内存。

为什么使用不可变数据类型:
因为浅比较无法比较出引用数据类型之间的差异

引用类型为什么有地址这个概念?
因为一个内存空间无法记录整个对象的值,所以用首地址(地址)来记录

函数写法

函数声明

  • 函数名不可省略,省略了就变成非法语法
  • 函数声明会存在函数提升
1
function fn() {}

函数表达式

不存在函数提升,在使用前就得定义函数,阅读体验更好

1
const fn = function f() {};

匿名函数

匿名函数在回调参数中常见,可以省略函数名,优点是书写起来容易

1
setTimeout(function () {}, 1000);

匿名函数表达式

1
2
3
4
5
// 写法二:匿名函数表达式
const fn = function () {};

// 写法二:箭头函数写法的匿名函数表达式
const fn = () => {};

立即执行函数表达式(IIFE)

Immediately Invoked Function Expression,函数名不是必须的,完全可以省略。
第一个括号把函数变成了表达式,第二个括号执行了这个函数

1
(function IIFE() {})();
Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×