全面理解 Promise 系列(四):异步函数和 await 表达式
小举 筑基

JavaScript 的设计初衷是作为底层工具在幕后提供高级语言特性使用。异步函数便是这样的高级语言特性,它让使用 promise 编程更接近于不使用 promise 编程。与其担心如何追踪 promise 及各种处理器,我们不如用异步函数将 promise 抽象化的。这样做的结果是使代码遵循我们所熟悉的自上而下的运行顺序。在讨论异步函数的原理细节之前,我们首先应当了解如何定义它。

4.1 定义异步函数

异步函数可以在任何原本使用同步函数的场合中使用。在大多数情况下,我们只需要在函数定义或方法定义之前添加 async 关键字,即可使其成为异步函数或异步方法。下面是一些例子:

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 doSomething() {
// 函数体
}

// 异步箭头函数
const doSomething = async () => {
// 函数体
};

// 异步箭头函数
const doSomething = async (a) => {
// 函数体
};

// 异步对象方法
const obj = {
async doSomething() {
// 函数体
},
};

// 异步类方法
class MyClass {
async doSomething() {
// 函数体
}
}

async 关键字表示,紧跟其后的函数或方法应该是异步的。对 javascript 引擎来说,提前知道一个函数是否为异步函数很重要,因为异步函数的表现与同步函数不同。

4.2 异步函数的不同之处

异步函数与同步函数的差异异体现在以下四个方面:

  • 返回值总是一个 promise;
  • 抛出的错误是处于拒绝状态的 promise;
  • 可以使用 await 表达式;
  • 可以使用 for-await-of 循环。

4.2.1 返回值总是一个 Promise

与在同步函数中相同,我们可以在异步函数中使用 return 操作符。不同的是,无论我们用 return 指定什么类型的值异步函数,都总是返回一个 promise。 举例来说,如果返回一个数,那么这个数会被包裹在一个 promise 中,如下所示:

1
2
3
4
5
6
7
8
9
10
11
async function getNumber() {
return 42;
}

const result = getNumber();
console.log(result instanceof Promise); // true
console.log(typeof result === 'number'); // false

result.then((value) => {
console.log(value); // 42
});

在这段代码中,异步函数 getNumber() 返回了 42,但返回值实际上是一个处于履行状态的 promise。我们可以附加一个履行处理器来检索该值。实际上,异步函数会在后台调用promise.resolve(), 以确保总是返回一个 promise。如果在异步函数中向 return 传递一个 promise, 那么该 promise 不会被直接传递。相反,它的状态和值将被复制给一个新的 promise 并返回。下面是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
const promise = Promise.resolve(42);

const doSomething = async function () {
return promise;
};

const result = doSomething();
console.log(result === promise); // false

result.then((value) => {
console.log(value); // 42
});

在这里,result 不是原 Promise,但它的内部状态与原 Promise 相同。因此,它的值仍然是 42。

如果我们没有为一个异步函数指定返回值,那么它就会返回一个 Promise,并且其值为 undefined。 举例如下:

1
2
3
4
5
6
7
8
9
10
async function doSomething() {
// 没有返回值
}

const result = doSomething();
console.log(result instanceof Promise); // true

result.then((value) => {
console.log(value); // undefined
});

无论我们用异步函数做什么,它都都会返回一个 Promsie。即便有错误被抛出,情况也是如此。

4.2.2 抛出的错误是处于拒绝状态的 Promise

当异步函数发生错误时,它会返回一个处于拒绝状态的 Promise,而不是在函数外抛出错误。这意味着我们不能通过 try-catch 来捕获函数中的错误。举例来说,下面的代码不会捕获到错误:

1
2
3
4
5
6
7
8
9
10
async function throwError() {
throw new Error('Something wrong!');
}

try {
throwError();
console.log("Didn't catch error");
} catch (error) {
console.log(error); // 永远不会被调用
}

在这个例子中,throwError() 函数会抛出一个错误,但是try-catch 没有捕获到这个错误,这是因为 throwError() 返回的是一个处于拒绝状态的 Promise。而不是在函数外面(try 块里)抛出错误。为了捕获这个错误,我们需要使用拒绝处理器,如下所示:

1
2
3
4
5
6
7
async function throwError() {
throw new Error('Something wrong!');
}

throwError().catch((error) => {
console.log(error); // Something wrong!
});

在这里,拒绝处理器是用 catch() 分配的,结果向控制台输出错误消息。JavaScript 引擎花了很大力气来确保异步函数总是返回 Promise。这样一来我们就可以用统一的方法处理返回值。这又引入了异步函数不同于同步函数的第三个方面:可以使用 await 表达式。

4.2.3 可以使用 await 表达式

await 表达式的设计初衷是使 Promise 的应用变得简单。在 await 表达式中使用任何 Promise 都不需要手动分配履行处理器和拒绝处理器,而更像是同步函数中的代码:await 表达式会暂停函数的执行,直到 Promise 被履行或被拒绝。在履行时返回 Promise 履行后的结果值,在拒绝的时候抛出 Promise 被拒绝后的结果值。这让我们能够轻松的将 await 表达式的结果值赋给某个变量,并使用 try-catch 语句捕获拒绝值。下面是一个使用 fetch() API 但不使用 await 表达式的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function receiveJsonData(url) {
return fetch(url)
.then((response) => {
if (response.ok) {
return response.json();
} else {
throw new Error(
`Unexpected status code: ${response.status} ${response.statusText}`
);
}
})
.catch((error) => {
console.error(error);
});
}

receiveJsonData() 函数返回一个 Promise。 该 Promise 的结果为 response 中的 JSON 数据。另有一个用于输出错误消息的拒绝处理器。下面展示如何把这个函数改写成使用 await 表达式的异步函数:

1
2
3
4
5
6
7
8
9
10
11
12
try {
const response = await fetch(url);
if (response.ok) {
return await response.json();
} else {
throw new Error(
`Unexpected status code: ${response.status} ${response.statusText}`
);
}
} catch (error) {
console.error(error);
}

在这个重写的版本中,对 await fetch(url) 的调用返回的 Promise 定义了履行处理器和拒绝处理器。变量 response 被赋予该 Promise 履行后的结果值(如果成功的话),而如果 Promise 被拒绝,则会抛出错误,并由 try-catch 语句捕获。fetch(url) 仍旧会返回一个结果为 JSON 数据的 Promise,但它是通过返回 response.json() (另一个 Promise)履行后的结果值来实现的。如果 response.json() 也被拒绝,那么 await response.json() 表达式也会抛出错误,并由 catch 语句捕获。

🤔 你可能会有这样的疑问:如果返回的是一个 Promise, 那么为什么不直接返回 response.json() 而非要使用 await 表达式呢?来看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
async function receiveJsonData(url) {
try {
const response = await fetch(url);
if (response.ok) {
return response.json();
} else {
throw new Error(
`Unexpected status code: ${response.status} ${response.statusText}`
);
}
} catch (error) {
console.error(error);
}
}

这个例子和之前的例子只有一个区别,就是 return await response.json()return response.json()。 因为 response.json() 是一个 Promise,如果这个 Promise 被拒绝,使用 await 表达式会抛出错误,而不使用 await 表达式的话这个被拒绝的 Promise 不会被当作错误抛出,因此也不会被 catch 捕获。要想捕获这个错误只能使用 response.json() 返回的 Promise 的拒绝处理器。如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
async function receiveJsonData(url) {
try {
const response = await fetch(url);
if (response.ok) {
return response.json(); // 注意这里不使用 await 表达式
} else {
throw new Error(
`Unexpected status code: ${response.status} ${response.statusText}`
);
}
} catch (error) {
console.error(error);
}
}

receiveJsonData('https://example.com/data.json')
.then((data) => {
doSomethingWith(data);
})
.catch((error) => {
console.error(error);
});

这两种情况各有用例。有时候我们想在异步函数内部捕获错误,又是我们想让错误在函数外部得到处理。

  1. 对非 Promise 值使用 await 表达式

我们可以对非 promise 使用 await 表达式,因为其结果值总是通过Promise.resolve() 传递。这意味着,若结果值为 promise, 那么该 promise 将被直接传递;若结果值为非 promise 的 thenable 对象,则该对象会被解析为 promise 后再被传递。而其他类型的值则会被包裹在 promise 中后再被传递。来看下面的例子:

1
2
3
4
5
6
7
async function doSomething() {
return 42;
}

doSomething().then((value) => {
console.log(value); // 42
});

这段代码中的 doSomething() 函数返回一个处于履行状态的 promise, 其值为 42。我们可以像下面这样重写这个函数,从而在不用异步函数的情况下实现同样的功能:

1
2
3
4
5
6
7
function doSomething() {
return Promise.resolve(42);
}

doSomething().then((value) => {
console.log(value); // 42
});

await 拥有处理非 Promise deferred 值的能力,这意味着,即使我们猜错了正在使用的值,也没有关系。

  1. 对多个 Promise 使用 await 表达式

尽管偶尔一个表达式只对一个 promise 进行操作,但我们可以利用 promise 的内置方法来有效的对多个 promise 进行操作。举例来说,如果想等待一个数组中的每一个 promise 都成功履行,那么我们可以结合使用 Promise.all() 方法和 await 表达式:

1
2
3
4
5
6
7
8
9
10
11
async function doSomething() {
try {
return await Promise.all([
fetch('https://example.com/data1.json'),
fetch('https://example.com/data2.json'),
fetch('https://example.com/data3.json'),
]);
} catch (error) {
console.error(error);
}
}

在这段代码中 await 被用于 Promise.all() 的结果,以使函数等待,直到所有 promise 都成功履行,或者其中一个被拒绝(被拒绝的话会有错误抛出)。在函数等待时,这三个 promise 可以自由的并行执行。下面是一个在 Node.js 中读取多个文件的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { readFile } from 'node:fs/promises';

async function readFiles(filenames) {
const fileContents = await Promise.all([
filenames.map(filename => readFile(filename, 'utf8'));

])
return fileContents.map(fileContent => JSON.parse(fileContent));
}

readFiles(['file1.json', 'file2.json', 'file3.json'])
.then(fileContents => {
// 按需处理数据
console.log(fileContents);
})
.catch(error => {
console.error(error);
});

异步函数 readFiles() 使用 await 和 Promise.all() 来等待所有的文件读取完成。然后文件内容可以被解析为 JSON 数据,从而以最合适的格式返回数据。

💡 当然,我们也可以将 await 和 Promise.allSettled()Promise.any()Promise.race() 或者其他任何返回 Promise 的函数结合使用。

4.2.4 可以使用 for-await-of 循环

另一个可以在异步函数中启用的特殊语法是 for-await-of 循环。它让我们能够从一个 iterable 对象中检索值。 iterable 对象具有 Symbol.iterator 方法,它返回一个迭代器,异步 iterable 对象具有 Symbol.asyncIterator方法,它也返回一个迭代器,且结果值总是一个 Promise。 for-await-of 循环首先对 iterable 对象返回的每一个值调用 Promise.resolve(),接着等待每个 Promise 确定状态,然后继续循环的下一次迭代。

Javascript 中最常用的 iterable 对象是数组,我们可以使用一个 promise 数组和一个 for-await-of 循环来依次处理每个 promise, 如下所示:

1
2
3
4
5
6
7
const promise1 = Promise.resolve(1);
const promise2 = Promise.resolve(2);
const promise3 = Promise.resolve(3);

for await (const value of [promise1, promise2, promise3]) {
console.log(value); // 1 2 3
}

这个例子按照顺序处理 promise1、promise2 和 promise3。尽管这些是已解决(状态已确定)的 promise, 但 for-await-of 循环也可以用于未解决的 promise。因为 for-await-of 循环总是对从 iterable 对象中获取的值调用 Promise.resolve(), 所以我们可以直接将它用于数组,如下所示:

1
2
3
for await (const value of [1, 2, 3]) {
console.log(value); // 1 2 3
}

在这个例子中,虽然数组中没有 Promise,但 for-await-of 循环依然可以正常工作。

在 Node.js 中最常用的异步 iterable 对象是 readStream 对象。readStream 对象被用来定期从一个数据可能不全的数据源中读取数据。对于网络请求,读取大文件或事件流, readStream 对象提供了便捷的途径。以下是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import fs from 'node:fs';

async function readCompleteTextStream(readableObj) {
readableObj.setEncoding('utf8');

let data = '';
for await (const chunk of readableObj) {
data += chunk;
}

return data;
}

const stream = fs.createReadStream('file.txt');
readCompleteTextStream(stream)
.then((data) => {
console.log(data);
})
.catch((error) => {
console.error(error);
});

readCompleteTextStream() 函数接受一个名为 readableOb 的 readStream 对象作为参数。为了读取文本文件,我们首先使用 setEncoding() 方法将编码设置为 ‘UTF8’,然后使用 for-await-of 循环遍历从 readableObj 读取的数据。如果文件内容较短,那么其中可能只有一个数据块, 如果文件内容较长,那么其中可能有多个数据块。使用 for-await-of 循环时,我们不必担心究竟有多少个数据块。
与 await 表达式类似,如果从异步对象返回的任何 promise 被拒绝,那么 for-await-of 循环会抛出一个错误。我们可以在异步函数内部用 try-catch 语句去捕获这个错误。如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
async function readCompleteTextStream(readableObj) {
readableObj.setEncoding('utf8');
try {
let data = '';
for await (const chunk of readableObj) {
data += chunk;
}
return data;
} catch (error) {
console.error(error);
}
}

const stream = fs.createReadStream('file.txt');
readCompleteTextStream(stream)
.then((data) => {
console.log(data);
})
.catch((error) => {
console.error(error);
});

for-await-of 循环中的第一个被拒绝的 promise, 会导致错误抛出try-catch 语句可以捕获这个错误,并将其记录到控制台中。如果没有 try-catch 语句,那么 for-await-of 循环中被拒绝的 promise 将作为readCompleteTextStream()函数中被拒绝的 promise 返回。

4.3 顶层 await 表达式

我们可以在 javascript 的模块的顶层(位于异步函数之外)使用 await 表达式。从本质上讲,javascript 模块在默认情况下,就像包裹着整个模块的异步函数一样。这使得我们可以直接用调用基于 promise 函数,比如使用awaitimports()`函数:

1
2
3
4
5
6
// 静态倒入
import somthing from './file.js';

// 动态导入
const filename = './another-file.js';
const somethingElse = await import(filename);

使用顶层 await 表达式,我们可以静态加载模块的同时动态加载模块。(动态加载的模块,允许我们动态的构建模块指定符,这在静态导入中是不可能实现的)。这个例子同时展示了静态导入和动态导入,以说明二者的区别。

当 javascript 的引擎遇到一个顶层 await 表达式时,javascript 模块会暂停执行,直到该 pr omise 被解决。如果被暂停的模块的父级模块有静态导入需要处理,那么即使在顶层使用 awit 表达式的同级模块被暂停时,这些静态导入也可以继续。在这种情况下,我们无法保证同级模块的加载顺序,但这个顺序往往并不重要。

⚠️ 不能在 Javascript 脚本中使用顶层 await 表达式。为了使用顶层 await 表达式,我们必须使用 import 或<script type="module">标签来加载 javascript 代码。

4.4 总结

  • 异步函数让我们可以在无需手动分配履行处理器和拒绝处理器的情况下使用 promise。通过在函数定义之前添加 async 关键字。我们可以将任何函数变成异步函数。
  • 异步函数的返回值总是一个 promise。如果你从异步函数中返回一个 promise, 那么它将被复制并返回到被调用的节点。如果你返回一个非 promise 值,那么该值将被解析为 promise,并返回到被调用的节点。
  • 异步函数抛出的错误会被捕获,并作为处于拒绝状态的 promise 返回。正因为如此,我们无法使用 try-catch 语句来捕获源自异步函数的错误。相反,我们需要给返回的 promise 分配一个拒绝处理器。
  • 异步函数有两种特殊的语法 await 表达式和 for-wait-of 循环。await 表达式用于为 promise 自动分配履行处理器和拒绝处理器,从而使履行值成为 awit 表达式返回的值,拒绝则会导致错误被抛出。for-await-of 循环用于异步 iterable 对象,并允许在循环中使用 promise。 for-await-of 循环等待从异步 iterable 对象返回的每个 Promise 确定状态,然后进入下一次迭代。如果一个来自异步 iterable 对象的 promise 被拒绝,那么就会有错误被抛出。
  • 此外,我们还可以在 javascript 模块的顶层使用 await 的表达式。不过这个功能在 javascript 脚本中不可用。