[关闭]
@levinzhang 2018-11-10T16:22:29.000000Z 字数 14658 阅读 343

异步JavaScript的演化史:从回调到Promise再到Async/Await

摘要

本文以实际样例阐述了异步JavaScript的发展过程,介绍了每种实现方式的优势和不足,能够帮助读者掌握相关技术的使用方式并把握技术发展的脉络。


本文最初发表于Tyler McGinnis
个人站点,由InfoQ中文站翻译分享。

我最喜欢的一个站点叫做BerkshireHathaway.com,它非常简单、高效,从1997年创建以来它一直都能很好地完成自己的任务。尤其值得注意的是,在过去的20年间,这个站点从来没有出现过缺陷。这是为什么呢?因为它是静态的,从建立到现在的20年间,它几乎没有发生过什么变化。如果你将所有的数据都放在前面的话,搭建站点是非常简单的。但是,如今大多数的站点都不会这么做。为了弥补这一点,我们发明了所谓的“模式”,帮助我们的应用从外部抓取数据。同其他大多数事情一样,这些模式都有一定的权衡,并随着时间的推移在发生着变化。在本文中,我们将会分析三种常用模式的优劣,即回调(Callback)、Promise和Async/Await,并从历史发展的维度讨论一下它们的意义和发展。

我们首先从数据获取模式的最原始方式开始介绍,那就是回调。

回调

在这里我假设你对回调一无所知,如果事实并非如此的话,那么你可以将内容稍微往后拖动一下。

当我第一次学习编程的时候,它就帮助我形成了一种思考方式,那就是将功能视为一种机器。这些机器能够完成任何你希望它能做到的事情,它们甚至能够接受输入并返回值。每个机器都有一个按钮,如果你希望这个机器运行的话,就按下按钮,这个按钮也就是()。

  1. function add (x, y) {
  2. return x + y
  3. }
  4. add(2,3) // 5 - 按下按钮,运行机器。

事实上,不仅我能按下按钮,也可以,任何人按下按钮的效果都是一样的。只要按下按钮,不管你是否愿意,这个机器就会开始运行。

  1. function add (x, y) {
  2. return x + y
  3. }
  4. const me = add
  5. const you = add
  6. const someoneElse = add
  7. me(2,3) // 5 - 按下按钮,运行机器。
  8. you(2,3) // 5 - 按下按钮,运行机器。
  9. someoneElse(2,3) // 5 - 按下按钮,运行机器。

在上面的代码中,我们将add函数赋值给了三个不同的变量:meyousomeoneElse。有很重要的一点需要注意,原始的add和我们创建的每个变量都指向的相同的内存点。在不同的名字之下,它们实际上是完全相同的内容。所以,当我们调用meyousomeoneElse的时候,就像调用add一样。

如果我们将add传递给另外一台机器又会怎样呢?需要记住,不管谁按下这个“()”按钮,它都会执行。

  1. function add (x, y) {
  2. return x + y
  3. }
  4. function addFive (x, addReference) {
  5. return addReference(x, 5) // 15 - 按下按钮,运行机器。
  6. }
  7. addFive(10, add) // 15

你可能会觉得这有些诡异,但是这里没有任何新东西。此时,我们不再是在add上“按下按钮”,而是将add作为参数传递给addFive,将其重命名为addReference,然后我们“按下按钮”或者说调用它。

这里涉及到了JavaScript的一些重要概念。首先,就像可以将字符串或数字以参数的形式传递给函数一样,我们还可以将函数的引用作为参数进行传递。但我们这样做的时候,作为参数传递的函数被称为回调函数(callback function),而接收回调函数传入的那个函数则被称为高阶函数(higher order function)。

因为术语非常重要,所以对相同功能的代码,我们进行变量的重命名,使其匹配它们所要阐述的概念:

  1. function add (x,y) {
  2. return x + y
  3. }
  4. function higherOrderFunction (x, callback) {
  5. return callback(x, 5)
  6. }
  7. higherOrderFunction(10, add)

这种模式看上去应该是非常熟悉的,它到处可见。如果你曾经用过JavaScript的Array方法,那么你所使用的就是回调。如果你用过lodash,那么你所使用的就是回调。如果你用过jQuery,那么你所使用的也是回调。

  1. [1,2,3].map((i) => i + 5)
  2. _.filter([1,2,3,4], (n) => n % 2 === 0 );
  3. $('#btn').on('click', () =>
  4. console.log('Callbacks are everywhere')
  5. )

一般而言,回调有两种常见的使用场景。首先,也就是我们在.map_.filter样例中所看到的,对于从一个值转换成另一个值的场景,这是一种非常好的抽象。我们可以说“这里有一个数组和一个函数。基于我给你的函数得到一个新的值”。其次,也就是我们在jQuery样例中所看到的,将函数的执行延迟至一个特定的时间。“这里有一个函数,当id为btn的元素被点击时,执行这个函数”。我们接下来会主要关注第二个使用场景,“将函数的执行延迟至一个特定的时间”。

现在,我们只看到了同步操作的样例。正如我们在本文开始时提到的那样,我们所构建的大多数应用都不会将数据预先准备好,而是用户在与应用进行交互时,按需抓取外部的数据。通过上面的介绍,我们很快就能判断得出这个场景非常适合使用回调,因为它允许我们“将函数的执行延迟至一个特定的时间”。我们能够顺理成章的将这句话应用到数据获取的情景中。此时不再是将函数的执行延迟到一个特定的时间,而是将函数的执行延迟至我们得到了想要的数据之后。jQuery的getJSON方法可能是这种模式最常见的样例:

  1. // updateUI和showError的内容无关紧要。
  2. // 假定它们所做的工作与它们的名字相同。
  3. const id = 'tylermcginnis'
  4. $.getJSON({
  5. url: `https://api.github.com/users/${id}`,
  6. success: updateUI,
  7. error: showError,
  8. })

在获取到用户的数据之前,我们是不能更新应用的UI的。那么我们是怎么做的呢?我们可以说,“这是一个对象。如果请求成功的话,那么调用success,并将用户的数据传递给它。如果请求没有成功的话,那么调用error并将错误对象传递给它。你不用关心每个方法是做什么的,只需要确保在应该调用它们的时候,去进行调用就可以了。这个样例完美地阐述了如何使用回调进行异步请求。

到此为止,我们已经学习了回调是什么以及它如何为同步代码和异步代码带来收益。我们还没有讨论回调的阴暗面。看一下下面的代码,你能告诉我它都做了些什么吗?

  1. // updateUI、showError和getLocationURL的内容无关紧要。
  2. // 假定它们所做的工作与它们的名字相同。
  3. const id = 'tylermcginnis'
  4. $("#btn").on("click", () => {
  5. $.getJSON({
  6. url: `https://api.github.com/users/${id}`,
  7. success: (user) => {
  8. $.getJSON({
  9. url: getLocationURL(user.location.split(',')),
  10. success (weather) {
  11. updateUI({
  12. user,
  13. weather: weather.query.results
  14. })
  15. },
  16. error: showError,
  17. })
  18. },
  19. error: showError
  20. })
  21. })

如果你需要帮助的话,可以参考一下这些代码的在线版本

注意一下,我们只是多添加了几层回调。首先,我们还是声明,如果不点击id为btn的元素,那么原始的AJAX请求就不会发送。一旦点击了按钮,我们会发起第一个请求。如果请求成功的话,我们会发起第二个请求。如果第二个请求也成功的话,那么我们将会调用updateUI方法,并将两个请求得到的数据传递给它。不管你一眼是否能够明白这些代码,客观地说,它要比之前的代码更加难以阅读。这也就涉及到所谓的“回调地狱”。

作为人类,我们习惯于序列化的思考方式。如果在嵌套回调中依然还有嵌套回调的话,它会强迫我们背离自然的思考方式。当代码的阅读方式与你的思考方式脱节时,缺陷也就难以避免地出现了。

与大多数软件问题的解决方案类似,简化“回调地狱”问题的一个常见方式就是对你的代码进行模块化。

  1. function getUser(id, onSuccess, onFailure) {
  2. $.getJSON({
  3. url: `https://api.github.com/users/${id}`,
  4. success: onSuccess,
  5. error: onFailure
  6. })
  7. }
  8. function getWeather(user, onSuccess, onFailure) {
  9. $.getJSON({
  10. url: getLocationURL(user.location.split(',')),
  11. success: onSuccess,
  12. error: onFailure,
  13. })
  14. }
  15. $("#btn").on("click", () => {
  16. getUser("tylermcginnis", (user) => {
  17. getWeather(user, (weather) => {
  18. updateUI({
  19. user,
  20. weather: weather.query.results
  21. })
  22. }, showError)
  23. }, showError)
  24. })

如果你需要帮助的话,可以参考一下这些代码的在线版本

好了,函数的名称能够帮助我们理解到底会发生什么,但客观地说,它真的“更好”了吗?其实也没好到哪里去。我们只是给回调地狱这个问题上添加了一块创可贴。问题依然存在,也就是我们会自然地按照顺序进行思考,即便有了额外的函数,嵌套回调也会打断我们顺序思考的方式。

回调方式的另外一个问题与控制反转(inversion of control)有关。当你编写回调的时候,你会假设自己将回调交给了一个负责任的程序,这个程序会在(并且仅会在)应该调用的时候调用你的回调。你实际上将控制权交给了另外一个程序。当你在处理jQuery、lodash这样的库,甚至普通JavaScript时,可以安全地假设回调函数会在正确的时间以正确的参数进行调用。但是,对于很多第三方库来说,回调函数是你与它们进行交互的接口。第三方库很可能有意或无意地破坏与你的回调进行交互的方式。

  1. function criticalFunction () {
  2. // 这个函数一定要进行调用,并且要传入正确的参数
  3. }
  4. thirdPartyLib(criticalFunction)

因为你不是调用criticalFunction的人,因此你完全无法控制它何时被调用以及使用什么参数进行调用。大多数情况下,这都不是什么问题,但是一旦出现问题的话,就不是什么小问题。

Promise

你有没有不预约就进入一家繁忙餐厅的经历?在这种情况下,餐厅需要有一种方式在出现空桌时能够联系到你。过去,他们只会把你的名字记录下来并在出现空桌的时候呼喊你的名字。随后,他们自然而然地寻找更有意思的方案。有一种方式是他们不再记录你的名字,而是记录你的电话号码,当出现空桌的时候,他们就可以为你发送短信。这样一来,你就可以离开最初的呼喊范围了,但是更重要的是,这种方式允许他们在任何时候给你的电话发送广告。听起来很熟悉吧?应该是这样的!当然也可能并非如此。这种方式可以用来类比回调。将你的电话号码告诉餐厅就像将你的回调函数交给第三方服务一样。你期望餐厅在有空桌的时候给你发送短信,同样我们也期望第三方服务在合适的时候以它们承诺的方式调用我们函数。但是,一旦电话号码或回调函数交到了他们的手里,我们就完全失去对它的控制了。

幸好,还有另外一种解决方案。这种方案的设计允许你保留所有的控制权。你可能之前见过这种方式,那就是他们会给你一个蜂鸣器,如下所示。

如果你之前没有用过的话,它的想法其实非常简单。按照这种方式,他们不会记录你的名字或电话号码,而是给你一个这样的设备。当这个设备开始嗡嗡作响和发光时,就意味着有空桌了。在等待空桌的时候,你可以做任何你想做的事情,但此时你不需要放弃任何的东西。实际上,恰恰相反,是他们需要给东西,这里没有所谓的控制反转。

蜂鸣器一定会处于如下三种状态之一:pendingfulfilledrejected

pending:默认状态,也是初始态。当他们给你蜂鸣器的时候,它就是这种状态。

fulfilled:代表蜂鸣器开始闪烁,你的桌子已经准备就绪。

rejected:如果蜂鸣器处于这种状态,则代表出现了问题。可能餐厅要打烊,或者他们忘记了晚上有人要包场。

再次强调,你作为蜂鸣器的接收者拥有完全的控制权。如果蜂鸣器处于fulfilled状态,你就可以就坐了。如果它进入fulfilled状态,但是你想忽略它,同样也可以。如果它进入了rejected状态,这非常糟糕,但是你可以选择去其他地方就餐。如果什么事情都没有发生,它会依然处于pending状态,你可能吃不上饭了,但是同时也没有失去什么。

现在,你已经掌握了餐厅蜂鸣器的事情,接下来,我们将这个知识用到其他重要的地方。

如果说将电话号码告诉餐厅就像将回调函数交给他们一样的话,那么接受这个蜂鸣器就像我们所谓的“Promise”一样。

像以往一样,我们首先从为什么开始。Promise为什么会存在呢?它的出现是为了让异步请求所带来的复杂性更容易管理。与蜂鸣器非常类似,Promise会处于如下三种状态中的某一种: pendingfulfilledrejected。但是与蜂鸣器不同,这些状态代表的不是饭桌的状态,它们所代表的是异步请求的状态。

如果异步请求依然还在进行,那么Promise的状态会是pending。如果异步请求成功完成的话,那么Promise会将状态转换为fulfilled。如果异步请求失败的话,Promise会将状态转换为rejected。蜂鸣器的比喻非常贴切,对吧?

理解了Promise为什么会存在以及它们的三种不同状态之后,我们还要回答三个问题:

  1. 如何创建Promise?

  2. 如何改变Promise的状态?

  3. 如何监听Promise状态变化的时间?

1)如何创建Promise?

这个问题非常简单,你可以使用new创建Promise的一个实例:

  1. const promise = new Promise()

2)如何改变Promise的状态?

Promise的构造函数会接收一个参数,这个参数是一个(回调)函数。该函数会被传入两个参数resolvereject

resolve:一个能将Promise状态变为fulfilled的函数;

reject:一个能将Promise状态变为rejected的函数;

在下面的代码中,我们使用setTimeout等待两秒钟然后调用resolve,这样会将Promise的状态变为fulfilled

  1. const promise = new Promise((resolve, reject) => {
  2. setTimeout(() => {
  3. resolve() // 将状态变为“fulfilled”
  4. }, 2000)
  5. })

分别用日志记录刚刚创建之时和大约两秒钟之后resolve已被调用时的Promise,我们可以看到状态的变化了:

请注意,Promise从<pending>变成了<resolved>

3)如何监听Promise状态变化的时间?

我认为,这是最重要的一个问题。我们已经知道了如何创建Promise和改变它的状态,这非常棒,但是如果我们不知道如何在状态变化之后做一些事情的话,这其实是没有太大意义的。

我们还没有讨论的一件事就是Promise到底是什么。当我们通过new Promise创建Promise的时候,你实际创建的只是一个简单的JavaScript对象,这个对象可以调用两个方法thencatch。这是关键所在,当Promise的状态变为fulfilled的时候,传递给.then的函数将会被调用。如果Promise的状态变为rejected,传递给.catch的函数将会被调用。这就意味着,在你创建Promise的时候,要通过.then将你希望异步请求成功时调用的函数传递进来,通过.catch将你希望异步请求失败时调用的函数传递进来。

看一下下面的样例。我们依然使用setTimeout在两秒钟(2000毫秒)之后将Promise的状态变为fulfilled

  1. function onSuccess () {
  2. console.log('Success!')
  3. }
  4. function onError () {
  5. console.log('💩')
  6. }
  7. const promise = new Promise((resolve, reject) => {
  8. setTimeout(() => {
  9. resolve()
  10. }, 2000)
  11. })
  12. promise.then(onSuccess)
  13. promise.catch(onError)

如果你运行上述代码,会发现大约两秒钟之后,将会在控制台上打印出“Success!”。出现这样的结果主要有两个原因。首先,当我们创建Promise的时候,会在大约2000毫秒之后调用resolve,这会将Promise的状态变为fulfilled。其次,我们将onSuccess函数传递给Promise的.then。通过这种方式,我们告诉Promise在状态变成fulfilled的时候(也就是大约2000毫秒之后)调用onSuccess

现在,我们假设发生了意料之外的事情,需要将Promise的状态变成rejected。这次,我们不再调用resolve,而是应该调用reject

  1. function onSuccess () {
  2. console.log('Success!')
  3. }
  4. function onError () {
  5. console.log('💩')
  6. }
  7. const promise = new Promise((resolve, reject) => {
  8. setTimeout(() => {
  9. reject()
  10. }, 2000)
  11. })
  12. promise.then(onSuccess)
  13. promise.catch(onError)

这一次,调用的就不是onSuccess函数了,而是onError函数,这是因为我们调用了reject

现在,你已经掌握了Promise API相关的知识,现在我们开始看一下真正的代码。

还记得我们之前看到的异步回调样例吗?

  1. function getUser(id, onSuccess, onFailure) {
  2. $.getJSON({
  3. url: `https://api.github.com/users/${id}`,
  4. success: onSuccess,
  5. error: onFailure
  6. })
  7. }
  8. function getWeather(user, onSuccess, onFailure) {
  9. $.getJSON({
  10. url: getLocationURL(user.location.split(',')),
  11. success: onSuccess,
  12. error: onFailure,
  13. })
  14. }
  15. $("#btn").on("click", () => {
  16. getUser("tylermcginnis", (user) => {
  17. getWeather(user, (weather) => {
  18. updateUI({
  19. user,
  20. weather: weather.query.results
  21. })
  22. }, showError)
  23. }, showError)
  24. })

这里我们能用Promise API的方式替换回调吗?如果我们将AJAX请求包装到Promise中会怎么样呢?如果能这样的话,我们就可以根据请求执行的情况简单地调用resolvereject。让我们从getUser入手:

  1. function getUser(id) {
  2. return new Promise((resolve, reject) => {
  3. $.getJSON({
  4. url: `https://api.github.com/users/${id}`,
  5. success: resolve,
  6. error: reject
  7. })
  8. })
  9. }

非常好!请注意,getUser的参数发生了变化。从接收idonSuccessonFailure变成了只接收id。这里不再需要这两个回调函数了,因为我们不必再将控制权转移出去了。相反,我们在这里使用了Promise的resolvereject函数。如果请求成功的话,将会调用resolve,如果出现错误的话,将会调用reject

接下来,我们重构getWeather。我们按照相同的策略,将onSuccessonFailure回调函数替换为resolvereject

  1. function getWeather(user) {
  2. return new Promise((resolve, reject) => {
  3. $.getJSON({
  4. url: getLocationURL(user.location.split(',')),
  5. success: resolve,
  6. error: reject,
  7. })
  8. })
  9. }

看上去非常不错!我们需要更新的最后一个地方就是点击处理器。需要记住,我们想要的处理流程如下所示:

  1. 通过Github API获取用户的信息;
  2. 使用用户的地理位置,通过Yahoo Weather API获取其天气情况;
  3. 根据用户信息和天气信息更新UI。

我们从第一步开始:通过Github API获取用户的信息。

  1. $("#btn").on("click", () => {
  2. const userPromise = getUser('tylermcginnis')
  3. userPromise.then((user) => {
  4. })
  5. userPromise.catch(showError)
  6. })

注意,getUser不再接收两个回调函数,它为我们返回的是一个Promise,基于该Promise,我们可以调用.then.catch。如果调用.then的话,会将用户信息传递给它。如果调用.catch的话,会将错误信息传递给它。

接下来,让我们实现第二步:使用用户的地理位置获取其天气。

  1. $("#btn").on("click", () => {
  2. const userPromise = getUser('tylermcginnis')
  3. userPromise.then((user) => {
  4. const weatherPromise = getWeather(user)
  5. weatherPromise.then((weather) => {
  6. })
  7. weatherPromise.catch(showError)
  8. })
  9. userPromise.catch(showError)
  10. })

注意,我们采取了与第一步完全相同的模式,只不过调用getWeather的时候,我们将userPromise得到的user传递了进去。

最后,在第三步中我们使用用户信息及其天气信息更新UI。

  1. $("#btn").on("click", () => {
  2. const userPromise = getUser('tylermcginnis')
  3. userPromise.then((user) => {
  4. const weatherPromise = getWeather(user)
  5. weatherPromise.then((weather) => {
  6. updateUI({
  7. user,
  8. weather: weather.query.results
  9. })
  10. })
  11. weatherPromise.catch(showError)
  12. })
  13. userPromise.catch(showError)
  14. })

在该地址有完整的源码,你可以进行尝试。

我们的新代码已经好多了,但是依然可以做一些改善。但是在进行改善之前,你需要了解Promise的另外两个特性,那就是链接(chaining)以及从resolve中给then传递参数。

链接

.then.catch都会返回一个新的Promise。这看上去像是一个很小的细节,但其实是非常重要的,因为这意味着Promise能够链接起来。

在下面的样例中,我们调用getPromise,它会返回一个Promise,这个Promise会在2000毫秒之后进行resolve。从这里开始,因为.then也将返回一个Promise,所以我们就可以将多个.then链接起来,直到我们抛出一个new Error,而这个错误将会被.catch方法捕获。

  1. function getPromise () {
  2. return new Promise((resolve) => {
  3. setTimeout(resolve, 2000)
  4. })
  5. }
  6. function logA () {
  7. console.log('A')
  8. }
  9. function logB () {
  10. console.log('B')
  11. }
  12. function logCAndThrow () {
  13. console.log('C')
  14. throw new Error()
  15. }
  16. function catchError () {
  17. console.log('Error!')
  18. }
  19. getPromise()
  20. .then(logA) // A
  21. .then(logB) // B
  22. .then(logCAndThrow) // C
  23. .catch(catchError) // Error!

这样非常酷,但为什么它如此重要呢?还记得在讨论回调的章节中,我们讨论了回调的劣势之一就是它强迫我们背离自然、序列化的思考方式。当我们将Promise链接起来的时候,它不会再强迫我们背离自然的思考方式,因为链接之后的Promise是序列化的,也就是运行getPromise,然后运行logA,然后运行logB……

我们看另外一个样例,这是使用fetch API时很常见的场景。fetch将会为我们返回一个Promise,它会解析为HTTP响应。为了得到实际的JSON,我们还需要调用.json。因为这种链接的方式,我们可以按照序列化的方式进行思考:

  1. fetch('/api/user.json')
  2. .then((response) => response.json())
  3. .then((user) => {
  4. // user现在已经准备就绪了。
  5. })

现在我们已经明白了链接的方式,接下来我们使用它来重构之前使用的getUser/getWeather代码。

  1. function getUser(id) {
  2. return new Promise((resolve, reject) => {
  3. $.getJSON({
  4. url: `https://api.github.com/users/${id}`,
  5. success: resolve,
  6. error: reject
  7. })
  8. })
  9. }
  10. function getWeather(user) {
  11. return new Promise((resolve, reject) => {
  12. $.getJSON({
  13. url: getLocationURL(user.location.split(',')),
  14. success: resolve,
  15. error: reject,
  16. })
  17. })
  18. }
  19. $("#btn").on("click", () => {
  20. getUser("tylermcginnis")
  21. .then(getWeather)
  22. .then((weather) => {
  23. // 在这里我们同时需要user和weather
  24. //目前我们只有weather
  25. updateUI() // ????
  26. })
  27. .catch(showError)
  28. })

这看起来好多了,但是现在我们遇到了另外一个问题。你发现了吗?在第二个.then中,我们想要调用updateUI。这里的问题在于我们需要为updateUI同时传递userweather。按照我们目前的做法,我们只能接收到weather,而没有user。我们需要想出一种办法,让getWeatherresolve时能够同时得到userweather

问题的关键在于resolve只是一个函数。你传递给它的任何参数都会往下传递给.then所指定的函数。这意味着,在getWeather中,如果我们自行调用resolve的话,就可以同时将weatheruser。然后,在链中的第二个.then方法中,就可以同时接收到weatheruser

  1. function getWeather(user) {
  2. return new Promise((resolve, reject) => {
  3. $.getJSON({
  4. url: getLocationURL(user.location.split(',')),
  5. success(weather) {
  6. resolve({ user, weather: weather.query.results })
  7. },
  8. error: reject,
  9. })
  10. })
  11. }
  12. $("#btn").on("click", () => {
  13. getUser("tylermcginnis")
  14. .then(getWeather)
  15. .then((data) => {
  16. // Now, data is an object with a
  17. // "weather" property and a "user" property.
  18. updateUI(data)
  19. })
  20. .catch(showError)
  21. })

你可以在该地址查看最后的代码。

在点击处理逻辑中,与回调方式进行对比,我们就能看出Promise的威力。

  1. // Callbacks 🚫
  2. getUser("tylermcginnis", (user) => {
  3. getWeather(user, (weather) => {
  4. updateUI({
  5. user,
  6. weather: weather.query.results
  7. })
  8. }, showError)
  9. }, showError)
  10. // Promises ✅
  11. getUser("tylermcginnis")
  12. .then(getWeather)
  13. .then((data) => updateUI(data))
  14. .catch(showError);

此时逻辑感觉非常自然,因为它就是我们所习惯的序列化思考方式。getUser,然后getWeather,然后使用得到的数据更新UI。

显而易见,Promise能够显著提升异步代码的可读性,但是有没有能让它更好的方式呢?假设你是TC39委员会的成员,拥有为JavaScript语言添加新特性的权力。那么,你认为下面的代码还能怎样进行优化?

  1. $("#btn").on("click", () => {
  2. getUser("tylermcginnis")
  3. .then(getWeather)
  4. .then((data) => updateUI(data))
  5. .catch(showError)
  6. })

正如我们在前面所讨论的,这个代码已经非常好了。与我们大脑的思考方式相同,它是序列化顺序的。我们所遇到的问题就是需要将数据(users)从第一个异步请求一直传递到最后一个.then。这并不是什么大问题,但是需要我们修改getWeather才能往下传递users。如果我们想完全按照编写同步代码的方式来编写异步代码会怎样进行处理呢?如果我们真的能做到这一点,这个问题将会彻底消失,它看上去就完全是序列化的了。如下是可能的一种实现方式:

  1. $("#btn").on("click", () => {
  2. const user = getUser('tylermcginnis')
  3. const weather = getWeather(user)
  4. updateUI({
  5. user,
  6. weather,
  7. })
  8. })

这看上去非常棒,我们的异步代码与同步代码完全相同。我们的大脑无需任何额外的步骤,因为这就是我们已经习以为常的思考方式。但令人遗憾的是,这样显然无法正常运行。我们都知道,userweather仅仅是getUsergetWeather所返回的Promise。但是不要忘记,我们现在是TC39的成员,有为语言添加任何特性的权力。这样的代码很难运行,我们必须教会JavaScript引擎区分异步函数调用和常规同步函数调用之前的差异。我们接下来添加几个关键字,让引擎运行起来更加容易。

首先,我们添加一个关键字到主函数上。这会提示引擎,我们会在这个函数中添加一些异步的方法调用。我们使用async来达到这一目的。

  1. $("#btn").on("click", async () => {
  2. const user = getUser('tylermcginnis')
  3. const weather = getWeather(user)
  4. updateUI({
  5. user,
  6. weather,
  7. })
  8. })

很好!这看上去是非常合理的。接下来,我们添加另外一个关键字,让引擎能够知道哪个函数调用是异步的,函数所返回的是Promise。这里我们使用await,这就相当于说“嗨,引擎。这个函数是异步的并且会返回Promise。你不能按照惯常的方式来执行,你需要等待Promise的最终值,然后才能继续运行”。在新的asyncawait就绪之后,代码将会变成下面的样子。

  1. $("#btn").on("click", async () => {
  2. const user = await getUser('tylermcginnis')
  3. const weather = await getWeather(user.location)
  4. updateUI({
  5. user,
  6. weather,
  7. })
  8. })

这种方式相当有吸引力。我们有了一种合理的方式,让异步代码的外表和行为完全和同步代码一致。接下来,就该让TC39的人相信这是一个好办法。你可能已经猜到了,我们并不需要说服他们,因为这已经是JavaScript的组成部分之一了,也就是所谓的Async/Await

你还不相信吗?该地址展现了添加Async/Await之后的实际代码,你尽可以进行尝试。

异步函数会返回Promise

现在,我们已经看到了Async/Await所能带来的收益。接下来,我们讨论几个更小的细节,掌握它们也是非常重要的。首先,只要你为函数添加async,它就会隐式的返回一个Promise:

  1. async function getPromise(){}
  2. const promise = getPromise()

尽管getPromise实际上空的,但是它依然会返回一个Promise,因为它是一个async函数。

如果async函数有返回值的话,它也将会包装到一个Promise中。这意味着,你必须要使用.then来访问它。

  1. async function add (x, y) {
  2. return x + y
  3. }
  4. add(2,3).then((result) => {
  5. console.log(result) // 5
  6. })

不能将await用到非async的函数中

如果你将await用到非async的函数中,那么将会出现错误。

  1. $("#btn").on("click", () => {
  2. const user = await getUser('tylermcginnis') // SyntaxError: await is a reserved word
  3. const weather = await getWeather(user.location) // SyntaxError: await is a reserved word
  4. updateUI({
  5. user,
  6. weather,
  7. })
  8. })

关于这一点,我认为,当你将async添加到一个函数上的时候,它会做两件事,首先它会让这个函数本身返回一个Promise(或者将返回的内容包装到Promise中),其次,它会确保你能够在这个函数中使用await

错误处理

你可能发现,我们的代码有一点作弊。在原始的代码中,我们可以通过.catch捕获所有的错误。在切换到Async/Await之后,我们移除了那些代码。在使用Async/Await时,最常用的方式就是将你的代码包装到一个try/catch中,这样就能捕获错误了。

  1. $("#btn").on("click", async () => {
  2. try {
  3. const user = await getUser('tylermcginnis')
  4. const weather = await getWeather(user.location)
  5. updateUI({
  6. user,
  7. weather,
  8. })
  9. } catch (e) {
  10. showError(e)
  11. }
  12. })
添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注