异步函数

异步函数,也称为“async/await”(语法关键字),是 ES6 期约模式在 ECMAScript 函数中的应用。

这个特性从行为和语法上都增强了 JavaScript,让以同步方式写的代码能够异步执行。

下面来看一个最简单的例子:

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

这个期约在1000 毫秒之后解决为数值 3。如果程序中的其他代码要在这个值可用时访问它,则需写一个解决处理程序,这其实是很不方便的,

1
2
let p = new Promise((resolve, reject) => setTimeout(resolve, 1000, 3)); 
p.then((x) => console.log(x)); // 3

因为其他代码都必须塞到期约处理程序中。不过可以把处理程序定义为一个函数:

1
2
3
function handler(x) { console.log(x); } 
let p = new Promise((resolve, reject) => setTimeout(resolve, 1000, 3));
p.then(handler); // 3

这个改进其实也不大。这是因为任何需要访问这个期约所产生值的代码,都需要以处理程序的形式 来接收这个值。也就是说,代码照样还是要放到处理程序里。

ES8 为此提供了 async/await 关键字。

async 和 await

async

1.async 关键字用于声明异步函数。这个关键字可以用在函数声明、函数表达式、箭头函数和方法上

1
2
3
4
5
6
async function foo() {} 
let bar = async function() {};
let baz = async () => {};
class Qux {
async qux() {}
}

2.使用 async 关键字可以让函数具有异步特征,但总体上其代码仍然是同步求值的。

而在参数或闭包方面,异步函数仍然具有普通 JavaScript 函数的正常行为。

3.异步函数的返回值期待(但实际上并不要求)一个实现 thenable 接口的对象,但常规的值也可以。 如果返回的是实现 thenable 接口的对象,则这个对象可以由提供给 then()的处理程序“解包”。如果不是,则返回值就被当作已经解决的期约。下面的代码演示了这些情况:

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
// 返回一个原始值 
async function foo() {
return 'foo';
}
foo().then(console.log);
// foo
// 返回一个没有实现 thenable 接口的对象
async function bar() {
return ['bar'];
}
bar().then(console.log);
// ['bar']
// 返回一个实现了 thenable 接口的非期约对象
async function baz() {
const thenable = {
then(callback) { callback('baz'); }
};
return thenable;
}
baz().then(console.log);
// baz

// 返回一个期约
async function qux() {
return Promise.resolve('qux');
}
qux().then(console.log);
// qux

4.与在期约处理程序中一样,在异步函数中抛出错误会返回拒绝的期约:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
async function foo() { 
console.log(1);
throw 3;
}
// 给返回的期约添加一个拒绝处理程序
foo().catch(console.log);
console.log(2);
// 1
// 2
// 3
不过,拒绝期约的错误不会被异步函数捕获:
async function foo() {
console.log(1);
Promise.reject(3);
}
// Attach a rejected handler to the returned promise
foo().catch(console.log);
console.log(2);
// 1
// 2
// Uncaught (in promise): 3

await

1.异步函数主要针对不会马上完成的任务,所以自然需要一种暂停和恢复执行的能力。使用 await 关键字可以暂停异步函数代码的执行,等待期约解决。

1
2
3
4
5
6
7
8
9
10
let p = new Promise((resolve, reject) => setTimeout(resolve, 1000, 3)); 
p.then((x) => console.log(x)); // 3
使用 async/await 可以写成这样:
async function foo() {
let p = new Promise((resolve, reject) => setTimeout(resolve, 1000, 3));
console.log(await p);
}
foo();
// 3

2.await 关键字会暂停执行异步函数后面的代码,让出 JavaScript 运行时的执行线程。这个行 为与生成器函数中的 yield 关键字是一样的。

3.await 关键字的用法与 JavaScript 的一元操作一样。它可以单独使用,也可以在表达式中使用,如下面的例子所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 异步打印"foo" 
async function foo() {
console.log(await Promise.resolve('foo'));
}
foo();
// foo
// 异步打印"bar"
async function bar() {
return await Promise.resolve('bar');
}
bar().then(console.log);
// bar
// 1000 毫秒后异步打印"baz"
async function baz() {
await new Promise((resolve, reject) => setTimeout(resolve, 1000));
console.log('baz');
}
baz();
// baz(1000 毫秒后)

4.await 关键字期待(但实际上并不要求)一个实现 thenable 接口的对象,但常规的值也可以。如 果是实现 thenable 接口的对象,则这个对象可以由 await 来“解包”。如果不是,则这个值就被当作已经解决的期约。

await 的限制

1.await 关键字必须在异步函数中使用,不能在顶级上下文如<script>标签或模块中使用。不过,定义并立即调用异步函数是没问题的。

1
2
3
4
5
6
7
8
9
10
async function foo() { 
console.log(await Promise.resolve(3));
}
foo();
// 3
// 立即调用的异步函数表达式
(async function() {
console.log(await Promise.resolve(3));
})();
// 3

2.异步函数的特质不会扩展到嵌套函数。因此,await 关键字也只能直接出现在异步函数的定 义中。在同步函数内部使用 await 会抛出 SyntaxError。

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
// 不允许:await 出现在了箭头函数中
function foo() {
const syncFn = () => {
return await Promise.resolve('foo');
};
console.log(syncFn());
}
// 不允许:await 出现在了同步函数声明中
function bar() {
function syncFn() {
return await Promise.resolve('bar');
}
console.log(syncFn());
}
// 不允许:await 出现在了同步函数表达式中
function baz() {
const syncFn = function() {
return await Promise.resolve('baz');
};
console.log(syncFn());
}
// 不允许:IIFE 使用同步函数表达式或箭头函数
function qux() {
(function () { console.log(await Promise.resolve('qux')); })();
(() => console.log(await Promise.resolve('qux')))();
}

3.要完全理解 await 关键字,必须知道它并非只是等待一个值可用那么简单。JavaScript 运行时在碰 到 await 关键字时,会记录在哪里暂停执行。等到 await 右边的值可用了,JavaScript 运行时会向消息 队列中推送一个任务,这个任务会恢复异步函数的执行。

因此,即使 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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
async function foo() { 
console.log(2);
await null;
console.log(4);
}
console.log(1);
foo();
console.log(3);
// 1
// 2
// 3
// 4
控制台中输出结果的顺序很好地解释了运行时的工作过程:
(1) 打印 1
(2) 调用异步函数 foo();
(3)(在 foo()中)打印 2
(4)(在 foo()中)await 关键字暂停执行,为立即可用的值 null 向消息队列中添加一个任务;
(5) foo()退出;
(6) 打印 3
(7) 同步线程的代码执行完毕;
(8) JavaScript 运行时从消息队列中取出任务,恢复异步函数执行;
(9)(在 foo()中)恢复执行,await 取得 null 值(这里并没有使用);
(10)(在 foo()中)打印 4
(11) foo()返回。
如果 await 后面是一个期约,则问题会稍微复杂一些。此时,为了执行异步函数,实际上会有两个
任务被添加到消息队列并被异步求值。下面的例子虽然看起来很反直觉,但它演示了真正的执行顺序:
async function foo() {
console.log(2);
console.log(await Promise.resolve(8));
console.log(9);
}
async function bar() {
console.log(4);
console.log(await 6);
console.log(7);
}
console.log(1);
foo();
console.log(3);
bar();
console.log(5);
// 1
// 2
// 3
// 4
// 5
// 6
// 7
// 8
// 9
运行时会像这样执行上面的例子:
(1) 打印 1
(2) 调用异步函数 foo();
(3)(在 foo()中)打印 2
(4)(在 foo()中)await 关键字暂停执行,向消息队列中添加一个期约在落定之后执行的任务;
(5) 期约立即落定,把给 await 提供值的任务添加到消息队列;
(6) foo()退出;
(7) 打印 3
(8) 调用异步函数 bar();
(9)(在 bar()中)打印 4
(10)(在 bar()中)await 关键字暂停执行,为立即可用的值 6 向消息队列中添加一个任务;
(11) bar()退出;
(12) 打印 5
(13) 顶级线程执行完毕;
(14) JavaScript 运行时从消息队列中取出解决 await 期约的处理程序,并将解决的值 8 提供给它;
(15) JavaScript 运行时向消息队列中添加一个恢复执行 foo()函数的任务;
(16) JavaScript 运行时从消息队列中取出恢复执行 bar()的任务及值 6
(17)(在 bar()中)恢复执行,await 取得值 6
(18)(在 bar()中)打印 6
(19)(在 bar()中)打印 7
(20) bar()返回;
(21) 异步任务完成,JavaScript 从消息队列中取出恢复执行 foo()的任务及值 8
(22)(在 foo()中)打印 8
(23)(在 foo()中)打印 9
(24) foo()返回。

异步函数策略

因为简单实用,所以异步函数很快成为 JavaScript 项目使用最广泛的特性之一。

实现 sleep()

1
2
3
4
5
6
7
8
9
10
async function sleep(delay) { 
return new Promise((resolve) => setTimeout(resolve, delay));
}
async function foo() {
const t0 = Date.now();
await sleep(1500); // 暂停约 1500 毫秒
console.log(Date.now() - t0);
}
foo();
// 1502

利用平行执行

串行执行期约

使用 async/await,期约连锁会变得很简单:

1
2
3
4
5
6
7
8
9
10
function addTwo(x) {return x + 2;} 
function addThree(x) {return x + 3;}
function addFive(x) {return x + 5;}
async function addTen(x) {
for (const fn of [addTwo, addThree, addFive]) {
x = await fn(x);
}
return x;
}
addTen(9).then(console.log); // 19

这里,await 直接传递了每个函数的返回值,结果通过迭代产生。当然,这个例子并没有使用期约, 如果要使用期约,则可以把所有函数都改成异步函数。这样它们就都返回期约了:

1
2
3
4
5
6
7
8
9
10
async function addTwo(x) {return x + 2;} 
async function addThree(x) {return x + 3;}
async function addFive(x) {return x + 5;}
async function addTen(x) {
for (const fn of [addTwo, addThree, addFive]) {
x = await fn(x);
}
return x;
}
addTen(9).then(console.log); // 19

栈追踪与内存管理