作者选择了COVID-19救助基金作为Write for Donations计划的捐赠对象。
介绍
在互联网早期,网站通常由HTML页面中的静态数据组成。但是现在,随着Web应用程序变得更加互动和动态,执行像发起外部网络请求以检索API数据这样的密集操作变得越来越必要。为了处理这些操作,开发人员必须使用JavaScript的异步编程技术。
由于JavaScript是一种单线程编程语言,具有同步执行模型,按顺序处理一个操作,因此它一次只能处理一个语句。然而,像从API请求数据这样的操作可能需要不确定的时间,这取决于请求的数据大小、网络连接速度和其他因素。如果API调用以同步方式执行,浏览器将无法处理任何用户输入,如滚动或点击按钮,直到该操作完成。这就是所谓的阻塞。
为了防止阻塞行为,浏览器环境有许多Web API,JavaScript可以访问它们是异步的,这意味着它们可以与其他操作并行运行,而不是顺序运行。这很有用,因为它允许用户在异步操作被处理时继续正常使用浏览器。
作为JavaScript开发人员,您需要知道如何处理异步Web API的响应或错误。在本文中,您将了解事件循环,通过回调处理异步行为的原始方式,ECMAScript 2015引入的promise,以及使用async/await
的现代实践。
注意:本文侧重于浏览器环境中的客户端JavaScript。相同的概念通常也适用于Node.js环境,但是Node.js使用其自己的C++ API而不是浏览器的Web API。有关Node.js中异步编程的更多信息,请参阅如何在Node.js中编写异步代码。
事件循环
这部分将解释JavaScript如何使用事件循环处理异步代码。首先,它将通过演示事件循环的工作原理,然后解释事件循环的两个元素:堆栈和队列。
不使用任何异步Web API的JavaScript代码将以同步方式执行——依次执行,顺序执行。通过调用每个打印数字到控制台的三个函数来演示这一点:
在这段代码中,您定义了三个使用console.log()
打印数字的函数。
接下来,调用这些函数:
输出将基于调用函数的顺序——first()
,second()
,然后third()
:
Output1
2
3
当使用异步Web API时,规则变得更加复杂。一个内置的API,您可以使用setTimeout
进行测试,它设置一个计时器,并在指定的时间后执行操作。setTimeout
需要是异步的,否则整个浏览器在等待期间将保持冻结状态,这会导致用户体验较差。
将setTimeout
添加到second
函数中以模拟异步请求:
setTimeout
接受两个参数:它将异步运行的函数以及调用该函数之前等待的时间。在这段代码中,您将 console.log
包装在一个匿名函数中,并将其传递给 setTimeout
,然后将该函数设置为在 0
毫秒后运行。
现在调用函数,就像之前做的那样:
您可能期望将 setTimeout
设置为 0
,这样运行这三个函数仍然会按顺序打印数字。但是由于它是异步的,具有超时的函数将会最后打印:
Output1
3
2
无论您将超时设置为零秒还是五分钟,都不会有任何区别——由异步代码调用的 console.log
将在同步顶级函数之后执行。这是因为 JavaScript 宿主环境,这里是浏览器,使用了称为 事件循环 的概念来处理并发或并行事件。由于 JavaScript 只能一次执行一条语句,因此它需要事件循环来告知何时执行哪个特定语句。事件循环使用 堆栈 和 队列 的概念来处理这个问题。
堆栈
堆栈,或称为调用堆栈,保存了当前正在运行的函数的状态。如果您对堆栈的概念不熟悉,您可以将其想象成具有“后进先出”(LIFO)属性的数组,这意味着您只能在堆栈的末尾添加或删除项目。JavaScript 将在堆栈中运行当前的帧(或特定环境中的函数调用),然后将其移除并继续下一个。
对于仅包含同步代码的示例,浏览器按照以下顺序处理执行:
- 将
first()
添加到堆栈,运行first()
,将1
记录到控制台,从堆栈中移除first()
。 - 将
second()
添加到堆栈,运行second()
,将2
记录到控制台,从堆栈中移除second()
。 - 将
third()
添加到堆栈,运行third()
,将3
记录到控制台,从堆栈中移除third()
。
具有 setTimout
的第二个示例如下:
- 将
first()
添加到堆栈,运行first()
,将1
记录到控制台,从堆栈中移除first()
。 - 将
second()
添加到堆栈,运行second()
。- 将
setTimeout()
添加到堆栈,运行setTimeout()
Web API,启动定时器并将匿名函数添加到队列,从堆栈中移除setTimeout()
。
- 将
- 从堆栈中移除
second()
。 - 将
third()
添加到堆栈,运行third()
,将3
记录到控制台,然后从堆栈中删除third()
。 - 事件循环检查队列以查找任何挂起的消息,并从
setTimeout()
找到匿名函数,将该函数添加到堆栈中,该函数将2
记录到控制台,然后将其从堆栈中删除。
使用setTimeout
,一个异步的Web API,引入了队列的概念,本教程将在接下来介绍。
队列
队列,也称为消息队列或任务队列,是函数的等待区域。每当调用堆栈为空时,事件循环将检查队列以查找任何等待的消息,从最旧的消息开始。一旦找到消息,它将被添加到堆栈中,堆栈将执行消息中的函数。
在setTimeout
示例中,匿名函数会立即在其余的顶级执行之后运行,因为定时器被设置为0
秒。重要的是要记住,定时器并不意味着代码会在确切的0
秒或指定的时间执行,而是在那段时间内将匿名函数添加到队列中。这个队列系统存在是因为如果定时器在定时器结束时直接将匿名函数添加到堆栈中,它会中断当前正在运行的任何函数,这可能会导致意想不到和不可预测的效果。
注意: 还有另一个队列叫做作业队列或微任务队列,它处理promise。像promise这样的微任务比像setTimeout
这样的宏任务具有更高的优先级。
现在您知道事件循环如何使用堆栈和队列来处理代码的执行顺序。下一个任务是弄清楚如何控制代码的执行顺序。为此,您首先会学习确保异步代码通过事件循环正确处理的原始方法:回调函数。
回调函数
在setTimeout
示例中,具有超时的函数在主顶层执行上下文中的所有内容之后运行。但是,如果您想要确保其中一个函数(例如third
函数)在超时之后运行,那么您必须使用异步编码方法。这里的超时可以表示包含数据的异步 API 调用。您希望使用来自 API 调用的数据,但必须确保数据先返回。
处理这个问题的原始解决方案是使用回调函数。回调函数没有特殊的语法;它们只是作为参数传递给另一个函数的函数。将另一个函数作为参数的函数称为高阶函数。根据这个定义,如果将函数作为参数传递,任何函数都可以成为回调函数。回调函数本质上不是异步的,但可以用于异步目的。
以下是高阶函数和回调的语法代码示例:
在这段代码中,您定义了一个名为fn
的函数,定义了一个名为higherOrderFunction
的函数,该函数接受一个名为callback
的函数作为参数,并将fn
作为回调传递给higherOrderFunction
。
运行此代码将会得到以下结果:
OutputJust a function
让我们回到第一个
、第二个
和第三个
函数,并使用setTimeout
。到目前为止,你所拥有的是:
任务是要让第三个
函数总是在第二个
函数中的异步操作完成后延迟执行。这就是回调函数的用处。你将不再在执行的顶层执行第一个
、第二个
和第三个
,而是将第三个
函数作为参数传递给第二个
。第二个
函数将在异步操作完成后执行回调函数。
下面是应用回调的三个函数:
现在,执行第一个
和第二个
,然后将第三个
作为参数传递给第二个
:
运行此代码块后,你将得到以下输出:
Output1
2
3
首先会打印1
,在计时器完成后(在本例中为零秒,但你可以将其更改为任意时间),会打印2
然后3
。通过将函数作为回调函数传递,你成功地延迟了函数的执行,直到异步 Web API (setTimeout
) 完成。
重点是回调函数并不是异步的,setTimeout
是负责处理异步任务的异步Web API。回调函数只是允许你在异步任务完成时获得通知,并处理任务的成功或失败。
现在你已经学会了如何使用回调函数来处理异步任务,下一节将解释嵌套太多回调函数和创建“金字塔般的地狱”的问题。
嵌套回调函数和金字塔般的地狱
回调函数是确保一个函数延迟执行直到另一个函数完成并返回数据的有效方法。然而,由于回调函数的嵌套性质,如果你有很多相互依赖的连续异步请求,代码可能会变得混乱。这是JavaScript开发人员早期的一个大问题,因此包含嵌套回调的代码通常被称为“金字塔般的地狱”或“回调地狱”。
以下是嵌套回调的演示:
在这段代码中,每个新的setTimeout
都嵌套在一个高阶函数中,创建了一个越来越深的回调的金字塔形状。运行此代码会得到以下结果:
Output1
2
3
在实践中,使用真实世界的异步代码,情况可能会变得更加复杂。您很可能需要在异步代码中进行错误处理,然后将一些数据从每个响应传递到下一个请求。使用回调来做这件事会使您的代码难以阅读和维护。
以下是一个更加现实的“回调地狱”的可运行示例,您可以尝试运行一下:
在这段代码中,您必须使每个函数考虑到可能的response
和可能的error
,使函数callbackHell
在视觉上令人困惑。
运行此代码将会得到以下结果:
Output
First 9
Second 3
Error: Whoa! Something went wrong.
at asynchronousRequest (<anonymous>:4:21)
at second (<anonymous>:29:7)
at <anonymous>:9:13
处理异步代码的这种方式很难理解。因此,ES6 引入了Promise的概念。这是下一节的重点。
Promise
A promise represents the completion of an asynchronous function. It is an object that might return a value in the future. It accomplishes the same basic goal as a callback function, but with many additional features and a more readable syntax. As a JavaScript developer, you will likely spend more time consuming promises than creating them, as it is usually asynchronous Web APIs that return a promise for the developer to consume. This tutorial will show you how to do both.
创建一个Promise
您可以使用new Promise
语法初始化一个promise,您必须用一个函数来初始化它。传递给promise的函数具有resolve
和reject
参数。resolve
和reject
函数分别处理操作的成功和失败。
编写以下代码以声明一个promise:
如果您使用浏览器的控制台检查初始化的promise,您会发现它处于pending
状态并且值为undefined
:
Output__proto__: Promise
[[PromiseStatus]]: "pending"
[[PromiseValue]]: undefined
到目前为止,还没有为promise设置任何内容,因此它将永远处于pending
状态。您可以做的第一件事是通过使用一个值来解决它来实现promise:
现在,检查promise,您会发现它的状态为fulfilled
,并且value
设置为您传递给resolve
的值:
Output__proto__: Promise
[[PromiseStatus]]: "fulfilled"
[[PromiseValue]]: "We did it!"
正如本节开头所述,promise是一个可能返回值的对象。在成功实现之后,value
从undefined
变为具有数据的值。
A promise can have three possible states: pending, fulfilled, and rejected.
- 等待中 – 在被解决或被拒绝之前的初始状态
- 已实现 – 操作成功,承诺已解决
- 已拒绝 – 操作失败,承诺已拒绝
在实现或拒绝后,承诺就会被解决。
现在你已经了解了如何创建承诺,让我们看看开发人员如何消耗这些承诺。
消耗承诺
上一节的承诺已经实现了一个值,但你也想能够访问这个值。承诺有一个叫做 then
的方法,在代码中当一个承诺达到 resolve
时会运行。 then
会将承诺的值作为参数返回。
这就是你如何返回并记录示例承诺的 value
:
你创建的承诺有一个 [[PromiseValue]]
是 我们做到了!
。这个值会作为 response
传递给匿名函数:
OutputWe did it!
到目前为止,你创建的示例没有涉及到异步 Web API——它只是解释了如何创建、解决和消耗一个原生 JavaScript 承诺。使用 setTimeout
,你可以测试异步请求。
以下代码模拟了作为承诺返回的异步请求的数据:
使用then
语法确保response
只有在setTimeout
操作完成后的2000
毫秒后才会被记录。所有这些都可以在不嵌套回调的情况下完成。
现在,两秒后,它将解决承诺值,并且它将在then
中被记录:
OutputResolving an asynchronous request!
承诺还可以链接以将数据传递给多个异步操作。如果在then
中返回了一个值,那么可以添加另一个then
,它将以前一个then
的返回值来实现:
第二个then
中的已完成响应将记录返回值:
OutputResolving an asynchronous request! And chaining!
由于then
可以被链接,它使得承诺的消耗看起来比回调更同步,因为它们不需要嵌套。这将使得代码更具可读性,更易于维护和验证。
错误处理
到目前为止,您只处理了具有成功resolve
的promise,这将promise置于fulfilled
状态。但是,在处理异步请求时,通常还需要处理错误 – 如果API关闭,或者发送了格式错误或未经授权的请求。一个promise应该能够处理这两种情况。在本节中,您将创建一个函数来测试创建和消费promise的成功和错误情况。
这个getUsers
函数将向promise传递一个标志,并返回promise:
设置代码,使得如果onSuccess
是true
,超时将会使用一些数据来实现。如果是false
,函数将以错误的形式拒绝:
对于成功的结果,您返回代表示例用户数据的JavaScript对象。
为了处理错误,您将使用catch
实例方法。这将为您提供一个带有error
参数的失败回调。
使用then
方法处理成功情况,使用catch
方法处理错误情况,运行getUser
命令,并将onSuccess
设置为false
:
由于触发了错误,then
将被跳过,而catch
将处理错误:
OutputFailed to fetch data!
如果您切换标志并改用resolve
,catch
将被忽略,数据将返回:
这将产生用户数据:
Output(3) [{…}, {…}, {…}]
0: {id: 1, name: "Jerry"}
1: {id: 2, name: "Elaine"}
3: {id: 3, name: "George"}
供参考,下面是Promise
对象上的处理程序方法表:
Method | Description |
---|---|
then() |
Handles a resolve . Returns a promise, and calls onFulfilled function asynchronously |
catch() |
Handles a reject . Returns a promise, and calls onRejected function asynchronously |
finally() |
Called when a promise is settled. Returns a promise, and calls onFinally function asynchronously |
对于新手开发人员和以前从未在异步环境中工作过的经验丰富的程序员来说,Promises可能会令人困惑。但是正如前面提到的,消耗promises比创建它们更常见。通常,浏览器的Web API或第三方库将提供promise,您只需要使用它。
在最后的promise部分,本教程将引用一个常见的Web API返回promises的用例:Fetch API。
使用Fetch API与Promises
最有用且经常使用的 Web API 之一是 Fetch API,它返回一个 Promise,允许您在网络上进行异步资源请求。`fetch` 是一个两部分的过程,因此需要链接 `then`。以下示例演示了如何使用 GitHub API 获取用户数据,并处理可能的错误:
`fetch` 请求发送到 `https://api.github.com/users/octocat` URL,异步等待响应。第一个 `then` 将响应传递给匿名函数,该函数将响应格式化为 JSON 数据,然后将 JSON 传递给第二个 `then`,将数据记录到控制台。`catch` 语句记录任何错误到控制台。
运行此代码将产生以下结果:
Outputlogin: "octocat",
id: 583231,
avatar_url: "https://avatars3.githubusercontent.com/u/583231?v=4"
blog: "https://github.blog"
company: "@github"
followers: 3203
...
这是从 `https://api.github.com/users/octocat` 请求的数据,以 JSON 格式呈现。
本教程的这一部分展示了 promise 在处理异步代码方面的许多改进。但是,虽然使用 `then` 处理异步操作比回调金字塔更容易理解,但一些开发人员仍然更喜欢同步格式编写异步代码。为了满足这一需求,ECMAScript 2016(ES7)引入了 `async` 函数和 `await` 关键字,以使处理 promise 更加容易。ECMAScript 2016(ES7)
使用 async/await
的异步函数
使用 async
函数可以以类似同步的方式处理异步代码。async
函数在内部仍然使用 promises,但具有更传统的 JavaScript 语法。在本节中,您将尝试使用此语法的示例。
您可以通过在函数之前添加 async
关键字来创建一个 async
函数:
虽然此函数尚未处理任何异步操作,但其行为与传统函数不同。如果执行该函数,您会发现它返回一个带有 [[PromiseStatus]]
和 [[PromiseValue]]
的 promise,而不是返回值。
尝试通过记录对 getUser
函数的调用来测试这一点:
这将产生以下结果:
Output__proto__: Promise
[[PromiseStatus]]: "fulfilled"
[[PromiseValue]]: Object
这意味着您可以使用 then
处理 async
函数,方式与处理 promise 相同。尝试使用以下代码:
此对 getUser
的调用将返回值传递给匿名函数,该函数将值记录到控制台。
运行此程序时,您将收到以下结果:
Output{}
async
函数可以使用 await
运算符处理其中调用的 Promise。 await
可以在 async
函数内部使用,并且会在承诺完成之前等待执行指定的代码。
有了这个知识,您可以使用 async
/await
重写上一节中的 Fetch 请求如下:
这里的 await
运算符确保在请求填充数据之前不会记录 data
。
现在最终的 data
可以在 getUser
函数内处理,而无需使用 then
。 这是记录 data
的输出:
Outputlogin: "octocat",
id: 583231,
avatar_url: "https://avatars3.githubusercontent.com/u/583231?v=4"
blog: "https://github.blog"
company: "@github"
followers: 3203
...
注意: 在许多环境中,使用 async
是必要的来使用 await
—— 然而,一些新版本的浏览器和 Node 允许使用顶层 await
,这允许您绕过创建包装 await
的异步函数。
最后,由于您在异步函数中处理了已完成的 Promise,您还可以在函数内处理错误。 而不是使用 then
的 catch
方法,您将使用 try
/catch
模式来处理异常。
添加以下突出显示的代码:
如果程序收到错误,则现在将跳转到 catch
块并将该错误记录到控制台。
现代异步 JavaScript 代码通常使用 async
/await
语法处理,但了解 Promise 的工作原理至关重要,特别是因为 Promise 能够实现一些 async
/await
无法处理的附加功能,比如将 promises 与 Promise.all()
结合使用。
注意:使用 生成器结合 promises 可以为代码增加更多灵活性,从而复制 async
/await
。欲了解更多信息,请参阅我们的 JavaScript 中理解生成器 教程。
结论
由于 Web API 经常以异步方式提供数据,学习如何处理异步操作的结果是成为 JavaScript 开发人员的重要部分。在本文中,您了解了宿主环境如何使用事件循环来处理代码的执行顺序,包括 堆栈 和 队列。您还尝试了三种处理异步事件成功或失败的方式,包括回调、Promise 和 async
/await
语法的示例。最后,您使用了 Fetch Web API 来处理异步操作。
要了解有关浏览器如何处理并行事件的更多信息,请阅读 Mozilla 开发者网络上的 并发模型和事件循环。如果您想要了解更多关于 JavaScript 的知识,请返回我们的 JavaScript 编程指南 系列。