JavaScript学习笔记(9):异步编程

同步和异步

同步程序和异步程序

同步程序:在程序执行的每一步,都可以推断出程序的状态。这是因为后面的指令总是在前面的指令完成后才会执行。

异步程序:部分任务处于独立运行的状态,不知道这些任务会什么时候完成,因此这些异步代码是以无法预知的进度推进的,只有在异步任务完成时想办法通知主程序。

回调函数模式

早期的JavaScript是使用回调函数来处理异步任务的,串联多个异步操作通常需要深度嵌套的回调函数(俗称“回调地狱”)来解决。

假设有以下函数,我们称它是异步的:

1
2
3
4
5
6
function double(value) {
setTimeout(() => {
setTimeout(console.log, 0, value*2);
}, 1000);
}
double(2); // 大约1000毫秒后输出 4

为什么说它是异步的,因为它具有异步程序的特征:

  • double函数设定了一个计时器,它将在1000毫秒之后将一个任务推到任务队列上,这个任务推到了任务队列上,它的执行就是独立的,对我们不可见了;
  • double在完成了任务调度(将任务推到队列上)之后会立即返回,异步任务的执行与它无关了。

异步返回值

如果对于异步函数返回的值,想要利用上的话,就需要回调函数来处理异步返回值,对于上述函数double我们为它添加一个回调函数,用来在1000毫秒后得到双倍值的时候调用以利用异步返回的值:

1
2
3
4
5
6
7
8
9
function double(value, callback) {
setTimeout(() => {
callback(value*2); // 调用回调函数处理异步返回值
}, 1000);
}
function callback(value) {
console.log(value);
}
double(2, callback);

失败处理

如果在处理时发生了错误还要准备一个失败处理回调函数,也加入到异步处理中去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function double(value, successCallback, failureCallback) {
setTimeout(() => {
try {
if(typeof value !== "number") {
throw new Error("I need a number.");
}
successCallback(value*2); // 调用回调函数处理异步返回值
} catch (error) {
failureCallback(error);
}
}, 1000);
}
function successCallback(value) {
console.log(value);
}
function failureCallback(error) {
console.log(error);
}
double(2, successCallback, failureCallback); // 4
double("2", successCallback, failureCallback); // Error: I need a number.

嵌套异步回调

如果一个异步返回值又依赖另一个异步返回值,就需要嵌套异步回调来处理,比如把上一步的成功回调函数改为:

1
2
3
function successCallback(value) {
double(value, (x) => console.log("Success:", x));
}

这就意味着在得到了异步结果之后还要对获得的结果再进行一次请求异步操作,需要再一次回调才可以。

这就导致了回调地狱的发生,维护难度极大。

期约

期约状态

首先要创建一个期约,并传入一个执行器函数进行初始化,这个执行器函数将运行一系列代码初始化期约对象,进行最开始的处理,它是同步执行的。

期约状态机

期约的三个状态:

  • 待定(pending);
  • 兑现(fulfilled)或解决(resolved);
  • 拒绝(rejected)。

一个期约刚创建的时候是待定的,当某个条件达成时它会转换为落定态,落定态包括解决和拒绝,但只能是两者之一,并且一旦落定就不会再改变状态了

期约故意将异步行为封装起来,从而隔离外部的同步代码,因此期约的状态是私有的,不能直接通过JavaScript检测到。而期约的状态管理是通过执行器函数来进行的。

执行器函数

执行器函数主要的职责是:初始化异步行为管理状态转换

执行器函数是一个会收到两个参数的函数,这两个参数分别为resolve和reject,它们是两个函数。执行器函数需要做的就是设定异步任务,然后将resolve函数和reject函数设定为异步任务的回调函数,在异步任务完成后调用它们进行状态转换。如假如1秒后异步任务完成,将期约变为了解决态:

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

期约一旦落定了就不能再改变状态了,再次调用resolve或者reject函数都会静默失败,因此我们可以设定一个超时拒绝,防止期约一直卡在待定态:

1
2
3
4
let p = new Promise((resolve, reject) => {
setTimeout(resolve, 1000);
setTimeout(reject, 5000);
});

这里我们模拟期约在1秒时被解决了,5秒时调用拒绝函数尝试拒绝会静默失败,因此最后的状态就是解决态;而如果在5秒内并没能解决,那么先调用的就是拒绝函数,后面即使异步任务成功返回了,也无法解决了。

resolve函数和reject函数是有参数的,它们的参数就是期约解决为的值或者被拒绝的理由,稍后可以通过期约对象取得它解决的结果或者拒绝理由。

创建落定的期约

默认创建的期约是待定的,但是也有办法创建一个落定的期约:即期约一创建直接就是落定态,不能再改变状态。

(1)Promise.resolve()静态方法:

以下两种写法等价:

1
2
Promise.resolve('foo');
new Promise(resolve => resolve('foo'));

通过调用Promise.resolve()静态方法,可以实例化一个解决的期约,它的第一个参数是解决为的值,使用这个静态方法,实际上可以把任何值都转换为一个期约。

如果传入的参数本身是一个期约(无论是待定的、解决的还是拒绝的),那它的行为就类似于一个空包装,直接返回原期约。因此,Promise.resolve()可以说是一个幂等方法

这个静态方法能够包装任何非期约值,包括错误对象,并将其转换为解决的期约。因此,也可能导致不符合预期的行为。

特别注意,如果它的参数是一个thenable对象,那么会返回一个待定的期约,然后把它的then方法挂到任务队列上等待执行,至于后面怎么落定,就看它的then方法了。

(2)Promise.reject()静态方法:

以下两种写法等价:

1
2
Promise.reject('foo');
new Promise((resolve,reject) => reject('foo'));

Promise.reject()会实例化一个拒绝的期约并抛出一个异步错误,这个错误不能通过try/catch捕获,而只能通过拒绝处理程序捕获。

Promise.reject()并不是幂等的,如果给它传一个期约对象,则这个期约会成为它返回的拒绝期约的理由。

同步错误与异步错误

try/catch模式是同步错误的捕获模式,它只能捕获到同步代码中的错误,而不能捕获到异步错误,异步错误需要使用错误处理程序才可以进行处理

拒绝期约的错误并没有抛到执行同步代码的线程里,而是通过浏览器异步消息队列来处理的。因此,try/catch块并不能捕获该错误。代码一旦开始以异步模式执行,则唯一与之交互的方式就是使用期约。

期约方法

then方法

Promise.prototype.then()是为期约实例添加处理程序的主要方法。这个then()方法接收最多两个参数:onResolved处理程序和onRejected处理程序。这两个参数都是可选的,如果提供的话,则会在期约分别进入解决和拒绝状态时执行。

此外传给then的任何非函数对象都会被静默忽略。如果只想传入onRejected处理程序,就必须在onResolved的位置上传入一个undefined。

then方法返回一个新的期约实例,这个新期约实例根据传入的上一级期约实例的状态:

  • 如果then方法中提供了这个状态对应的处理程序:新实例就是基于该处理程序的返回值构建的,构建的方式是通过Promise.resolve()包装来生成新期约;如果该处理程序没有显式的返回语句,则认为默认返回undefined。
  • 如果then方法中没有提供这个状态对应的处理程序:则新实例是上一个传入的期约通过Promise.resolve()包装之后的值。
  • 如果在then方法中的两个处理程序的执行中抛出了错误,返回的新实例将是一个包含抛出的错误信息的拒绝态的期约,即使这个错误是解决处理程序抛出的。

catch方法

Promise.prototype.catch()方法用于给期约添加拒绝处理程序。这个方法只接收一个参数:onRejected处理程序。事实上,这个方法就是一个语法糖,调用它就相当于调用Promise.prototype.then(null, onRejected)

catch方法和then方法一样返回一个新的期约实例,和then方法的行为一样。

finally方法

Promise.prototype.finally()方法用于给期约添加onFinally处理程序,这个处理程序在期约转换为解决或拒绝状态后都会执行。这个方法可以避免onResolved和onRejected处理程序中出现冗余代码。但onFinally处理程序没有办法知道期约的状态是解决还是拒绝,所以这个方法主要用于添加清理代码。

它也返回一个新的期约实例,但是规则不太一样了:

  • 如果onFinally处理程序返回的是一个待定的期约或者拒绝的期约,那么finally方法返回的新的期约就是这个onFinally处理程序返回的期约;
  • 如果onFinally处理程序抛出了错误,那么finally方法返回的新的期约就是以抛出的错误为拒绝理由的拒绝的期约;
  • 其他情况下finally方法都会把上一级收到的期约原样返回。

要注意返回待定的期约的情况,比如这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let p1 = Promise.resolve('foo');
let p2 = p1.finally(
() => {
new Promise((resolve, reject) => {
setTimeout(() => {
console.log("解决了!");
resolve('bar');
}, 100);
})
});
console.log(p2); // 立即输出: Promise <pending>
setTimeout(console.log, 0, p2); // 立即输出: Promise {<fulfilled>: 'foo'}
// 100 毫秒后输出: 解决了!
setTimeout(() => setTimeout(console.log, 0, p2), 200); // 200 毫秒后输出: Promise <resolved>: foo

这个finally返回待定期约的同时会把上一级期约后传的任务挂上队列,即无论返回的这个期约是否落定、是解决还是拒绝,原期约都会后传,且这个后传任务是立刻挂上队列的,导致p2仅仅在待定态停留了很短暂的时间。

这种情况一般没有太大用,所以一般finally并不会返回一个待定的期约。

处理程序的执行顺序

如果给期约添加了多个处理程序,当期约状态变化时,相关处理程序会按照添加它们的顺序依次执行。这里的“多个处理程序”既指多次调用同一个方法绑定的多个处理程序(如两次调用catch绑定了两个拒绝处理程序)也指调用不同的方法绑定的处理程序(如then、catch、finally都为拒绝行为创建了拒绝处理程序)。

期约连锁与合成

期约连锁

把期约逐个地串联起来就是“期约连锁”。能够这样做是因为每个期约方法(then()catch()finally())都会返回一个新的期约对象,而这个新期约又有自己的实例方法。

期约连锁有用的地方在于让上一个期约的解决处理函数返回一个新的待定期约,再为这个新的待定期约创建处理程序,并且处理程序可能又返回一个新的待定期约,每个待定期约都对应一个异步任务,这样就把若干异步任务串联了起来,下一个异步任务等到上一个异步任务落定之后再开始,如这样的例子:

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
let p = new Promise((resolve, reject) => {
console.log("期约 1 开始执行");
setTimeout(() => {
console.log("期约 1 解决为 1");
resolve(1);
}, 1000);
});
p.then((val) => {
return new Promise((resolve, reject) => {
console.log("期约 2 开始执行");
setTimeout((val) => {
console.log("期约 2 解决为", val*2);
resolve(val*2);
}, 1000, val);
});
}).then((val) => {
return new Promise((resolve, reject) => {
console.log("期约 3 开始执行");
setTimeout((val) => {
console.log("期约 3 解决为", val*3);
resolve(val*3);
}, 1000, val);
});
}).then((val) => {
console.log("所有异步任务解决完毕,最终的值为", val);
});
// 立即输出:期约 1 开始执行
// 约 1 秒后:期约 1 解决为 1
// 约 1 秒后:期约 2 开始执行
// 约 2 秒后:期约 2 解决为 2
// 约 2 秒后:期约 3 开始执行
// 约 3 秒后:期约 3 解决为 6
// 约 3 秒后:所有异步任务解决完毕,最终的值为 6

这个例子中,第一个期约取得了值1,后续每个期约都在前一个期约的取得的值的基础上加工,分别乘以2、3得到最终的值,实现了多个异步任务的串联。

期约合成

除了让多个期约串联之外,还可以实现期约的并联,就是通过期约合成来实现的。期约的合成方法有这些:

(1)Promise.race()静态方法:

它接收一个期约组成的可迭代对象作为参数并返回一个新的期约,当传入期约中任意一个落定时合成的期约也会落定,并且合成期约的落定状态与第一个落定的期约一样,解决值或拒绝理由也一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let p1 = Promise.race([
Promise.resolve(3),
Promise.resolve(1),
Promise.resolve(2)
]);
p1.then((val) => console.log(val)); // 3
let p2 = Promise.race([
new Promise(() => {}),
Promise.resolve(0),
Promise.reject(new Error("error!")),
Promise.resolve(1)
]);
p2.then((val) => console.log(val)); // 0
let p3 = Promise.race([
Promise.reject(new Error("error!")),
Promise.resolve(0),
Promise.resolve(1)
]);
p3.catch((val) => console.log(val)); // Error: error!

(2)Promise.all()静态方法:

它接收一个期约组成的可迭代对象作为参数并返回一个新的期约,新的期约在传入的这一组期约全部解决后才会解决。只要有任何一个期约处于待定态,合成的期约也会是待定的;只要有任何一个期约被拒绝,合成的期约也会变为拒绝态,拒绝的理由是第一个拒绝的期约给出的理由;只有所有期约都成功解决,合成的期约才会解决,解决值为包含所有期约解决值的数组(按迭代器顺序):

1
2
3
4
5
6
7
8
9
10
11
12
13
let p1 = Promise.all([
Promise.resolve(3),
Promise.resolve(1),
Promise.resolve(2)
]);
p1.then((val) => console.log(val)); // [3, 1, 2]
let p2 = Promise.all([
new Promise(() => {}),
Promise.reject(new Error("error!")),
Promise.reject(new Error("ignore")),
Promise.resolve(0)
]);
p2.catch((val) => console.log(val)); // Error: error!

异步函数

异步函数语法

异步函数其实是期约与生成器的语法糖。

传统异步程序使用期约实现,需要获得解决值的时候需要把所有对于这个值的处理逻辑塞到处理函数中,并通过then绑定到期约上,这样不方便。使用异步函数语法可以在函数逻辑中直接处理解决值,方法是使用生成器特性,需要使用期约返回值的时候就中断函数,将等待返回的断点挂在任务队列,然后跳出函数之外执行继续执行下面的同步代码,同时等待期约返回,期约返回之后再回到断点处继续执行。

async关键字

使用async关键字可以定义一个异步函数,可以用在函数声明、函数表达式、箭头函数和方法上。该关键字会让函数具有一部分异步特征,但其总体上仍然是同步的,在参数和闭包方面,异步函数依然有普通函数的正常行为。

异步函数的返回值会被Promise.resolve()包装成一个期约对象,如果没有显式的return语句则会返回undefined,因此异步函数始终返回期约对象。与在期约处理程序中一样,在异步函数中使用throw抛出错误,会返回一个拒绝的期约,拒绝的理由是抛出的错误对象,但拒绝的错误不会被异步函数捕获。

await关键字

await关键字就像生成器语法中的yield一样,负责暂停函数执行,等待期约解决并从原处恢复。

await关键字同样是尝试“解包”对象的值,即可以得到期约解决的值,然后将这个值传给表达式,再异步恢复异步函数的执行。await关键字的用法与JavaScript的一元操作一样(或者说是和yield关键字一样),它可以单独使用,也可以在表达式中使用。

await必须在直接的异步函数中使用,否则会抛出错误。

await期待一个期约对象,如果它得到的是抛出异常的同步代码那么就会得到一个对应的拒绝态的期约,如果它得到的是其他值或对象,那么就相当于使用Promise.resolve()包装了这个值或对象。

停止和恢复执行

在异步函数中真正起作用的是await,如果没有await,异步函数就和一个普通函数没什么区别。

在异步函数执行时遇到了await时,会立即中断运行,这时如果await后边的值包装后的期约是已经落定的,那么将它挂到消息队列(微任务队列,因为异步函数本质是期约语法糖),等待当前同步代码结束后,如果await后包装的是一个待定的期约,那就等落定后再挂上队列。

异步函数策略

使用异步函数模式来执行异步任务时需要注意一些情况和一些技巧。

模拟sleep函数

通过以下方式可以模拟出让程序休眠一定时间的sleep函数:

1
2
3
4
5
6
7
8
9
async function sleep(delay) {
return new Promise((resolve) => setTimeout(resolve, delay));
}
async function func() {
const t0 = Date.now();
await sleep(2000);
console.log(Date.now() - t0); // 2014
}
func();

但是它的局限是,这样的sleep函数只能用于异步函数中使异步函数的执行休眠,并且要使用await和sleep函数搭配使用才可以。

平行执行

如果有若干任务希望它们平行执行,如这样的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
async function randomDelay(id) {
// 随机延迟0~1000 毫秒
const delay = Math.random() * 1000;
return new Promise((resolve) => setTimeout(() => {
console.log(`${id} finished`);
resolve();
}, delay));
}
async function foo() {
const t0 = Date.now();
await randomDelay(0);
await randomDelay(1);
await randomDelay(2);
await randomDelay(3);
await randomDelay(4);
console.log(`${Date.now() - t0}ms elapsed`);
}
foo();
// 0 finished
// 1 finished
// 2 finished
// 3 finished
// 4 finished
// 1733ms elapsed

可以发现这些任务并非同时执行了,原因在于异步函数每次等待的并非只是期约的执行,它连带着期约创建也给等待了,也就是第一个期约创建、等待第一个期约解决、第二个期约创建、等待第二个期约返回……它每次中断连带着下一个期约的创建任务也给中断了,下一个期约创建不了就开始不了任务。

所以应该一口气把所有期约都创建了,让它们先跑着,然后我再去等待它们:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
async function foo() {
const t0 = Date.now();
const p0 = randomDelay(0);
const p1 = randomDelay(1);
const p2 = randomDelay(2);
const p3 = randomDelay(3);
const p4 = randomDelay(4);
await p0;
await p1;
await p2;
await p3;
await p4;
setTimeout(console.log, 0, `${Date.now() - t0}ms elapsed`);
}
foo();
// 3 finished
// 4 finished
// 0 finished
// 1 finished
// 2 finished
// 893ms elapsed

当然这里更加优雅的方式是使用循环包装期约的创建和等待过程。总之一口气创建完所有的期约就可以让异步任务一起开始,然后再分别等待。当然这里只是期约异步任务的执行同时进行了,任务完成得有先有后,可是由于await语句的顺序,await接收这些期约的返回值还是按照顺序接收的。如果想要期约把值返回给主程序的顺序也是随机的,就不能使用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
25
26
27
28
29
30
31
32
function randomDelay(id) {
const delay = Math.random() * 1000;
return new Promise((resolve) => setTimeout(() => {
resolve(id);
console.log(`${id} finished`);
}, delay));
}
function onResolved(id) {
console.log(`${id} returned`);
}
const t0 = Date.now();
const p0 = randomDelay(0).then(onResolved);
const p1 = randomDelay(1).then(onResolved);
const p2 = randomDelay(2).then(onResolved);
const p3 = randomDelay(3).then(onResolved);
const p4 = randomDelay(4).then(onResolved);
const p = Promise.all([p0,p1,p2,p3,p4]).then(((t0) => {
return () => {
setTimeout(console.log, 0, `${Date.now() - t0}ms elapsed`);
}
})(t0));
// 1 finished
// 1 returned
// 4 finished
// 4 returned
// 0 finished
// 0 returned
// 2 finished
// 2 returned
// 3 finished
// 3 returned
// 754ms elapsed

串行执行异步任务

之前我们通过期约串联可以执行异步任务,还以那个乘以2、3的例子为例,现在我们使用异步函数也可以实现:

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
44
async function fun1(x) {
console.log("异步任务 1 开始执行");
console.log("等待任务 1 的返回值");
const r = await 1;
console.log("异步任务 1 返回了", r);
console.log("返回加工的值为", r*x);
return r*x;
}
async function fun2(x) {
console.log("异步任务 2 开始执行");
console.log("等待任务 2 的返回值");
const r = await 2;
console.log("异步任务 2 返回了", r);
console.log("返回加工的值为", r*x);
return r*x;
}
async function fun3(x) {
console.log("异步任务 3 开始执行");
console.log("等待任务 3 的返回值");
const r = await 3;
console.log("异步任务 3 返回了", r);
console.log("返回加工的值为", r*x);
return r*x;
}
async function fun(x) {
for (const fn of [fun1,fun2,fun3]) {
x = await fn(x);
}
return x;
}
fun(1).then(console.log);
// 异步任务 1 开始执行
// 等待任务 1 的返回值
// 异步任务 1 返回了 1
// 返回加工的值为 1
// 异步任务 2 开始执行
// 等待任务 2 的返回值
// 异步任务 2 返回了 2
// 返回加工的值为 2
// 异步任务 3 开始执行
// 等待任务 3 的返回值
// 异步任务 3 返回了 3
// 返回加工的值为 6
// 6

以上三个函数分别异步请求了三个值,并对初始值1进行加工,请求到的值分别是1、2、3,因此最后加工得到的最终结果为1*1*2*3=6。可以看到三个异步函数是串联执行的。




* 你好,我是大森。如果文章内容帮到了你,你可通过下方付款二维码支持作者 *