async/await初学者指南

2023-09-01 09:32:58 浏览数 (2)

JavaScript中的asyncawait关键字提供了一种现代语法,帮助我们处理异步操作。在本教程中,我们将深入研究如何使用async/await来掌控JavaScript程序中的流程控制。

总览

  • 如何创建JavaScript异步函数
    • async关键字
    • await关键字
    • 声明异步函数的不同方式
  • await/async内部机制
    • 从promise到async/await的转换
  • 错误处理
    • 在函数调用中使用catch()
  • 并行运行异步命令
  • 同步循环中的异步await
  • 顶层await
  • 总结

在JavaScript中,一些操作是异步的。这意味着它们产生的结果或者值不会立即奏效。

看看下面的代码:

代码语言:javascript复制
function fetchDataFromApi() {
  // Data fetching logic here
  console.log(data);
}

fetchDataFromApi();
console.log('Finished fetching data');

JavaScript解释器不会等待异步fetchDataFromApi函数完成后再解释下一条语句。因此,在打印API返回的真实数据之前,它就会打印Finished fetching data

大多数情况下,这并不是我们想要的行为。幸运的是,我们可以使用asyncawait关键字,使我们的程序在继续前进之前等待异步操作的完成。

这个功能是在ES2017引入JavaScript的,在所有现代浏览器[1]中都支持。

如何创建JavaScript异步函数

让我们近距离看看fetchDataFromApi数据获取的逻辑。在JavaScript中,数据获取是典型的异步操作案例。

使用Fetch API,我们可以这么做:

代码语言:javascript复制
function fetchDataFromApi() {
  fetch('https://v2.jokeapi.dev/joke/Programming?type=single')
    .then(res => res.json())
    .then(json => console.log(json.joke));
}

fetchDataFromApi();
console.log('Finished fetching data');

这里,我们从JokeAPI[2]获取一个编程笑话。API的响应是JSON格式的,所以我们在请求完成后提取该响应(使用json()方法),然后把这个笑话打印到控制台。

请注意,JokeAPI是第三方API,我们不能保证返回笑话的质量。

如果在浏览器中运行该代码,或者在Node中(17.5 版本中使用--experimental-fetch)运行,我们将看到,事情仍然以错误的顺序打印在控制台中。

让我们来改变它。

async关键字

我们需要做的第一件事是将包含的函数标记为异步的。我们可以通过使用async关键字来做到这一点,我们把它放在function关键字的前面:

代码语言:javascript复制
async function fetchDataFromApi() {
  fetch('https://v2.jokeapi.dev/joke/Programming?type=single')
    .then(res => res.json())
    .then(json => console.log(json.joke));
}

异步函数总是返回一个promise(后面会详细介绍),所以可以通过在函数调用上链接一个then()来获得正确的执行顺序:

代码语言:javascript复制
fetchDataFromApi()
  .then(() => {
    console.log('Finished fetching data');
  });

如果现在运行代码,看到的结果会是这样的:

代码语言:javascript复制
If Bill Gates had a dime for every time Windows crashed ... Oh wait, he does.
Finished fetching data

但我们并不想这样做!JavaScript的promise语法可能会有点毛糙,而这正是async/await的优势所在:它使我们能够用一种看起来更像同步代码的语法来编写异步代码,而且更容易阅读。

await关键字

接下来要做的是,在我们的函数中的任何异步操作前面加上 await 关键字。这将迫使JavaScript解释器"暂停"执行并等待结果。我们可以将这些操作的结果分配给变量:

代码语言:javascript复制
async function fetchDataFromApi() {
  const res = await fetch('https://v2.jokeapi.dev/joke/Programming?type=single');
  const json = await res.json();
  console.log(json.joke);
}

我们还需要等待调用fetchDataFromApi函数的结果:

代码语言:javascript复制
await fetchDataFromApi();
console.log('Finished fetching data');

很不幸,如果尝试运行代码,会得到一个错误:

代码语言:javascript复制
Uncaught SyntaxError: await is only valid in async functions, async generators and modules

这是因为我们不能在非模块脚本中的async函数之外使用await。我们将在后面详细讨论这个问题,但现在解决这个问题的最简单的方法是将调用的代码包裹在一个自己的函数中,我们也会将其标记为async

代码语言:javascript复制
async function fetchDataFromApi() {
  const res = await fetch('https://v2.jokeapi.dev/joke/Programming?type=single');
  const json = await res.json();
  console.log(json.joke);
}

async function init() {
  await fetchDataFromApi();
  console.log('Finished fetching data');
}

init();

如果现在运行代码,一切都如愿:

代码语言:javascript复制
UDP is better in the COVID era since it avoids unnecessary handshakes.
Finished fetching data

我们需要这个额外的模板是不幸的,但在我看来,这个代码仍然比基于promise的版本更容易阅读。

声明异步函数的不同方式

先前的例子中,使用了两个具名函数声明(function关键字后跟着函数名字),但我们并不局限于这些。我们也可以把函数表达式、箭头函数和匿名函数标记为async

「异步函数表达式」

当我们创建一个函数,并将其赋值给一个变量时,这便是「函数表达式」。该函数是匿名的,这意味着它没有名字。比如:

代码语言:javascript复制
const fetchDataFromApi = async function() {
  const res = await fetch('https://v2.jokeapi.dev/joke/Programming?type=single');
  const json = await res.json();
  console.log(json.joke);
}

这将以与我们之前的代码完全相同的方式工作。

「异步箭头函数」

箭头函数在ES6被引入。它们是函数表达式的紧凑替代品,并且总是匿名的。它们的基本语法如下:

代码语言:javascript复制
(params) => { <function body> }

为了标记箭头函数为匿名的,在左括号前插入async关键字。

举个例子,除了在上面的代码中创建一个额外的init函数外,另一个办法是将现有的代码包裹在一个IIFE中,我们将其标记为async

代码语言:javascript复制
(async () => {
  async function fetchDataFromApi() {
    const res = await fetch('https://v2.jokeapi.dev/joke/Programming?type=single');
    const json = await res.json();
    console.log(json.joke);
  }
  await fetchDataFromApi();
  console.log('Finished fetching data');
})();

使用函数表达式或函数声明并没有什么大的区别:大部分情况下,这只是一个使用偏好的问题。但有几件事情需要注意,比如变量提升,或者箭头函数无法绑定this的事实。

Await/Async内部机制

正如你可能已经猜到的,async/await在很大程度上是promise的语法糖。让我们更详细地看一下这个问题,因为更好地理解内部发生的事情将对理解async/await的工作方式有很大帮助。

第一件需要注意的事情是,async函数总是返回一个promise,即使我们不显式地告诉它这么做。比如:

代码语言:javascript复制
async function echo(arg) {
  return arg;
}

const res = echo(5);
console.log(res);

打印结果如下:

代码语言:javascript复制
Promise { <state>: "fulfilled", <value>: 5 }

promise可能会是三种状态之一:pendingfulfilled、或者rejected。一个promise开始时处于pending状态。如果与该promise有关的行为成功了,该promise就被称为fulfilled。如果行为不成功,该promise就被称为rejected。一旦promisefulfilled或者rejected,但不是pending,它也被认为是settled

当我们在async函数中使用 await 关键字来"暂停"函数执行时,真正发生的是我们在等待一个promise(无论是显式还是隐式)进入resolvedrejected状态。

基于上述示例,我们可以这么做:

代码语言:javascript复制
async function echo(arg) {
  return arg;
}

async function getValue() {
  const res = await echo(5);
  console.log(res);
}

getValue();
// 5

因为echo函数返回一个promise,而getValue函数中的await关键字在继续程序之前等待这个promise完成,所以我们能够将所需的值打印到控制台。

promise是对JavaScript中流程控制的一大改进,并且被一些较新的浏览器API所使用。比如Battery status API[3]、Clipboard API[4]、Fetch API[5]、MediaDevices API[6]等等。

Node还在其内置的util模块中添加了一个promise函数,可以将使用回调函数的代码转换为返回promise。而从v10开始,Node的fs模块中的函数可以直接返回promise

从promise到async/await的转换

那么,为什么这一切对我们来说都很重要呢?

好消息是,任何返回promise的函数都可以使用async/await。我并不是说我们应该对所有的事情都使用async/await(该语法确实有其缺点,我们将在讨论错误处理时看到),但我们应该意识到这是可能的。

我们已经看到了如何改变基于promise的获取调用,使之与async/await一起工作,所以让我们看另一个例子。这里有一个小的实用函数,使用Node基于promise的API和它的readFile方法来获取一个文件的内容。

使用Promise.then():

代码语言:javascript复制
const { promises: fs } = require('fs');

const getFileContents = function(fileName) {
  return fs.readFile(fileName, enc)
}

getFileContents('myFile.md', 'utf-8')
  .then((contents) => {
    console.log(contents);
  });

有了async/await就会变成:

代码语言:javascript复制
import { readFile } from 'node:fs/promises';

const getFileContents = function(fileName, enc) {
  return readFile(fileName, enc)
}

const contents = await getFileContents('myFile.md', 'utf-8');
console.log(contents);

注意:这是在利用一个叫做top-level await的功能,它只在ES模块中可用。要运行这段代码,请将文件保存为index.mjs并使用Node>=14.8的版本。

虽然这些都是简单的例子,但我发现async/await的语法更容易理解。当处理多个then()语句和错误处理时,这一点变得尤其真实。

错误处理

在处理异步函数时,有几种方法来处理错误。最常见的可能是使用try...catch块,我们可以把它包在异步操作中并捕捉任何发生的错误。

在下面的例子中,请注意我是如何将URL改成不存在的东西的:

代码语言:javascript复制
async function fetchDataFromApi() {
  try {
    const res = await fetch('https://non-existent-url.dev');
    const json = await res.json();
    console.log(json.joke);
  } catch (error) {
    // Handle the error here in whichever way you like
    console.log('Something went wrong!');
    console.warn(error)
  }
}

await fetchDataFromApi();
console.log('Finished fetching data');

这将导致以下信息被打印到控制台:

代码语言:javascript复制
Something went wrong!
TypeError: fetch failed
    ...
    cause: Error: getaddrinfo ENOTFOUND non-existent-url.dev
Finished fetching data

这种结果是因为fetch返回一个promise。当fetch操作失败时,promisereject方法被调用,await关键字将这种reject转换为一个可捕捉的错误。

然而,这种方法有几个问题。主要的问题是它很啰嗦,而且相当难看。想象一下,我们正在构建一个CRUD应用程序,我们为每个CRUD方法(创建、读取、更新、销毁)都有一个单独的函数。如果这些方法中的每一个都进行了异步API调用,我们就必须把每个调用包在自己的try...catch块中。这是相当多的额外代码。

另一个问题是,如果我们不使用await关键字,这将导致一个未处理的拒绝的promise

代码语言:javascript复制
import { readFile } from 'node:fs/promises';

const getFileContents = function(fileName, enc) {
  try {
    return readFile(fileName, enc)
  } catch (error) {
    console.log('Something went wrong!');
    console.warn(error)
  }
}

const contents = await getFileContents('this-file-does-not-exist.md', 'utf-8');
console.log(contents);

上述代码的打印如下:

代码语言:javascript复制
node:internal/process/esm_loader:91
    internalBinding('errors').triggerUncaughtException(
                              ^
[Error: ENOENT: no such file or directory, open 'this-file-does-not-exist.md'] {
  errno: -2,
  code: 'ENOENT',
  syscall: 'open',
  path: 'this-file-does-not-exist.md'
}

await不同,return关键字不会将拒绝的promise转化为可捕捉的错误。

在函数调用中使用catch()

每个返回promise的函数都可以利用promisecatch方法来处理任何可能发生的promise拒绝。

有了这个简单的补充,上例中的代码将优雅地处理错误:

代码语言:javascript复制
const contents = await getFileContents('this-file-does-not-exist.md', 'utf-8')
  .catch((error) => {
    console.log('Something went wrong!');
    console.warn(error);
  });
console.log(contents);

现在输出是这样子的:

代码语言:javascript复制
Something went wrong!
[Error: ENOENT: no such file or directory, open 'this-file-does-not-exist.md'] {
  errno: -2,
  code: 'ENOENT',
  syscall: 'open',
  path: 'this-file-does-not-exist.md'
}
undefined

至于使用哪种策略,我同意Valeri Karpov[7]的建议。使用try/catch来恢复async函数内部的预期错误,但通过在调用函数中添加catch()来处理意外错误。

并行运行异步命令

当我们使用await关键字来等待一个异步操作完成时,JavaScript解释器会相应地暂停执行。虽然这很方便,但这可能并不总是我们想要的。考虑一下下面的代码:

代码语言:javascript复制
(async () => {
  async function getStarCount(repo){
    const repoData = await fetch(repo);
    const repoJson = await repoData.json()
    return repoJson.stargazers_count;
  }

  const reactStars = await getStarCount('https://api.github.com/repos/facebook/react');
  const vueStars = await getStarCount('https://api.github.com/repos/vuejs/core');
  console.log(`React has ${reactStars} stars, whereas Vue has ${vueStars} stars`)
})();

这里我们正在进行两次API调用,分别获取React和Vue的GitHub star数。虽然这样可以正常运转,但我们没有理由在发出第二个fetch请求之前等待第一个promise完成。如果我们要发出很多请求,这将是一个相当大的瓶颈。

为了解决这个问题,我们可以使用Promise.all,它接收一个promise数组,并等待所有promise被解决或其中任何一个承诺被拒绝:

代码语言:javascript复制
(async () => {
  async function getStarCount(repo){
    // As before
  }

  const reactPromise = getStarCount('https://api.github.com/repos/facebook/react');
  const vuePromise = getStarCount('https://api.github.com/repos/vuejs/core');
  const [reactStars, vueStars] = await Promise.all([reactPromise, vuePromise]);

  console.log(`React has ${reactStars} stars, whereas Vue has ${vueStars} stars`);
})();

好多了!

同步循环中的异步await

在某些时候,我们会尝试在一个同步循环中调用一个异步函数。比如说:

代码语言:javascript复制
// Return promise which resolves after specified no. of milliseconds
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));

async function process(array) {
  array.forEach(async (el) => {
    await sleep(el); // we cannot await promise here
    console.log(el);
  });
}

const arr = [3000, 1000, 2000];
process(arr);

这不会像预期的那样奏效,因为forEach只会调用函数而不等待它完成,以下内容将被打印到控制台:

代码语言:javascript复制
1000
2000
3000

同样的事情也适用于其他许多数组方法,如mapfilterreduce

幸运的是,ES2018引入了异步迭代器,除了它们的next()方法会返回一个promise外,它们就像普通的迭代器。这意味着我们可以在其中使用 await。让我们使用for...of重写上面的代码:

代码语言:javascript复制
async function process(array) {
  for (el of array) {
    await sleep(el);
    console.log(el);
  };
}

现在,process函数的输出就是正确的顺序:

代码语言:javascript复制
3000
1000
2000

就像我们之前等待异步fetch请求的例子一样,这也会带来性能上的代价。for循环中的每个await都会阻塞事件循环,通常应该重构代码,一次性创建所有的promise,然后使用Promise.all()来获取结果。

甚至有一条ESLint规则[8],如果它检测到这种行为就会警告。

顶层await

最后,让我们来看看一个叫做「顶层await」的东西。这是ES2022中引入的语言,从14.8版开始在Node中可用。

当我们在文章开头运行我们的代码时,我们已经被这个东西所要解决的问题给缠住了。还记得这个错误吗?

代码语言:javascript复制
Uncaught SyntaxError: await is only valid in async functions, async generators and modules

当我们试图在一个async函数之外使用await时,就会发生这种情况。例如,在我们代码的顶层:

代码语言:javascript复制
const ms = await Promise.resolve('Hello, World!');
console.log(msg);

顶层await解决了这个问题,使上述代码有效,但只在ES模块中奏效。如果我们在浏览器中工作,我们可以把这段代码添加到一个叫做index.js的文件中,然后像这样把它加载到我们的页面中:

代码语言:javascript复制
<script src="index.js" type="module"></script>

事情会像预期的那样工作,不需要包装函数或丑陋的IIFE。

在Node中,事情变得更加有趣。要将一个文件声明为ES模块,我们应该做两件事中的一件。一种方法是以.mjs为扩展名保存,然后像这样运行它:

代码语言:javascript复制
node index.mjs

另一种方法是在package.json文件中设置"type": "module"

代码语言:javascript复制
{
  "name": "myapp",
  "type": "module",
  ...
}

顶层 await 也可以和动态导入很好地配合--一种类函数的表达式,它允许我们异步加载 ES 模块。这将返回一个promise,而这个promise将被解析为一个模块对象,这意味着我们可以这样做:

代码语言:javascript复制
const locale = 'DE';

const { default: greet } = await import(
  `${ locale === 'DE' ?
      './de.js' :
      './en.js'
  }`
);

greet();
// Outputs "Hello" or "Guten Tag" depending on the value of the locale variable

动态导入选项也很适合与React和Vue等框架相结合的懒加载。这使我们能够减少初始包的大小和交互指标的时间。

总结

在这篇文章中,我们研究了如何使用async/await来管理你的JavaScript程序的控制流。我们讨论了语法、async/await如何工作、错误处理,以及一些问题。如果你已经走到了这一步,你现在就是一个专家了。

0 人点赞