本文以实际样例阐述了异步 JavaScript 的发展过程,介绍了每种实现方式的优势和不足,能够帮助读者掌握相关技术的使用方式并把握技术发展的脉络。
我最喜欢的一个站点叫做BerkshireHathaway.com,它非常简单、高效,从 1997 年创建以来它一直都能很好地完成自己的任务。尤其值得注意的是,在过去的 20 年间,这个站点从来没有出现过缺陷。这是为什么呢?因为它是静态的,从建立到现在的 20 年间,它几乎没有发生过什么变化。如果你将所有的数据都放在前面的话,搭建站点是非常简单的。但是,如今大多数的站点都不会这么做。为了弥补这一点,我们发明了所谓的“模式”,帮助我们的应用从外部抓取数据。同其他大多数事情一样,这些模式都有一定的权衡,并随着时间的推移在发生着变化。在本文中,我们将会分析三种常用模式的优劣,即回调(Callback)、Promise 和 Async/Await,并从历史发展的维度讨论一下它们的意义和发展。
我们首先从数据获取模式的最原始方式开始介绍,那就是回调。
回调
在这里我假设你对回调一无所知,如果事实并非如此的话,那么你可以将内容稍微往后拖动一下。
当我第一次学习编程的时候,它就帮助我形成了一种思考方式,那就是将功能视为一种机器。这些机器能够完成任何你希望它能做到的事情,它们甚至能够接受输入并返回值。每个机器都有一个按钮,如果你希望这个机器运行的话,就按下按钮,这个按钮也就是()。
事实上,不仅我能按下按钮,你也可以,任何人按下按钮的效果都是一样的。只要按下按钮,不管你是否愿意,这个机器就会开始运行。
在上面的代码中,我们将add
函数赋值给了三个不同的变量:me
、you
和someoneElse
。有很重要的一点需要注意,原始的add
和我们创建的每个变量都指向的相同的内存点。在不同的名字之下,它们实际上是完全相同的内容。所以,当我们调用me
、you
或someoneElse
的时候,就像调用add
一样。
如果我们将add
传递给另外一台机器又会怎样呢?需要记住,不管谁按下这个“()”按钮,它都会执行。
你可能会觉得这有些诡异,但是这里没有任何新东西。此时,我们不再是在add
上“按下按钮”,而是将add
作为参数传递给addFive
,将其重命名为addReference
,然后我们“按下按钮”或者说调用它。
这里涉及到了 JavaScript 的一些重要概念。首先,就像可以将字符串或数字以参数的形式传递给函数一样,我们还可以将函数的引用作为参数进行传递。但我们这样做的时候,作为参数传递的函数被称为回调函数(callback function),而接收回调函数传入的那个函数则被称为高阶函数(higher order function)。
因为术语非常重要,所以对相同功能的代码,我们进行变量的重命名,使其匹配它们所要阐述的概念:
这种模式看上去应该是非常熟悉的,它到处可见。如果你曾经用过 JavaScript 的 Array 方法,那么你所使用的就是回调。如果你用过 lodash,那么你所使用的就是回调。如果你用过 jQuery,那么你所使用的也是回调。
一般而言,回调有两种常见的使用场景。首先,也就是我们在.map
和 _.filter
样例中所看到的,对于从一个值转换成另一个值的场景,这是一种非常好的抽象。我们可以说“这里有一个数组和一个函数。基于我给你的函数得到一个新的值”。其次,也就是我们在 jQuery 样例中所看到的,将函数的执行延迟至一个特定的时间。“这里有一个函数,当 id 为btn
的元素被点击时,执行这个函数”。我们接下来会主要关注第二个使用场景,“将函数的执行延迟至一个特定的时间”。
现在,我们只看到了同步操作的样例。正如我们在本文开始时提到的那样,我们所构建的大多数应用都不会将数据预先准备好,而是用户在与应用进行交互时,按需抓取外部的数据。通过上面的介绍,我们很快就能判断得出这个场景非常适合使用回调,因为它允许我们“将函数的执行延迟至一个特定的时间”。我们能够顺理成章的将这句话应用到数据获取的情景中。此时不再是将函数的执行延迟到一个特定的时间,而是将函数的执行延迟至我们得到了想要的数据之后。jQuery 的getJSON
方法可能是这种模式最常见的样例:
在获取到用户的数据之前,我们是不能更新应用的 UI 的。那么我们是怎么做的呢?我们可以说,“这是一个对象。如果请求成功的话,那么调用success
,并将用户的数据传递给它。如果请求没有成功的话,那么调用error
并将错误对象传递给它。你不用关心每个方法是做什么的,只需要确保在应该调用它们的时候,去进行调用就可以了。这个样例完美地阐述了如何使用回调进行异步请求。
到此为止,我们已经学习了回调是什么以及它如何为同步代码和异步代码带来收益。我们还没有讨论回调的阴暗面。看一下下面的代码,你能告诉我它都做了些什么吗?
如果你需要帮助的话,可以参考一下这些代码的在线版本。
注意一下,我们只是多添加了几层回调。首先,我们还是声明,如果不点击 id 为btn
的元素,那么原始的 AJAX 请求就不会发送。一旦点击了按钮,我们会发起第一个请求。如果请求成功的话,我们会发起第二个请求。如果第二个请求也成功的话,那么我们将会调用updateUI
方法,并将两个请求得到的数据传递给它。不管你一眼是否能够明白这些代码,客观地说,它要比之前的代码更加难以阅读。这也就涉及到所谓的“回调地狱”。
作为人类,我们习惯于序列化的思考方式。如果在嵌套回调中依然还有嵌套回调的话,它会强迫我们背离自然的思考方式。当代码的阅读方式与你的思考方式脱节时,缺陷也就难以避免地出现了。
与大多数软件问题的解决方案类似,简化“回调地狱”问题的一个常见方式就是对你的代码进行模块化。
如果你需要帮助的话,可以参考一下这些代码的在线版本。
好了,函数的名称能够帮助我们理解到底会发生什么,但客观地说,它真的“更好”了吗?其实也没好到哪里去。我们只是给回调地狱这个问题上添加了一块创可贴。问题依然存在,也就是我们会自然地按照顺序进行思考,即便有了额外的函数,嵌套回调也会打断我们顺序思考的方式。
回调方式的另外一个问题与控制反转(inversion of control)有关。当你编写回调的时候,你会假设自己将回调交给了一个负责任的程序,这个程序会在(并且仅会在)应该调用的时候调用你的回调。你实际上将控制权交给了另外一个程序。当你在处理 jQuery、lodash 这样的库,甚至普通 JavaScript 时,可以安全地假设回调函数会在正确的时间以正确的参数进行调用。但是,对于很多第三方库来说,回调函数是你与它们进行交互的接口。第三方库很可能有意或无意地破坏与你的回调进行交互的方式。
因为你不是调用criticalFunction
的人,因此你完全无法控制它何时被调用以及使用什么参数进行调用。大多数情况下,这都不是什么问题,但是一旦出现问题的话,就不是什么小问题。
Promise
你有没有不预约就进入一家繁忙餐厅的经历?在这种情况下,餐厅需要有一种方式在出现空桌时能够联系到你。过去,他们只会把你的名字记录下来并在出现空桌的时候呼喊你的名字。随后,他们自然而然地寻找更有意思的方案。有一种方式是他们不再记录你的名字,而是记录你的电话号码,当出现空桌的时候,他们就可以为你发送短信。这样一来,你就可以离开最初的呼喊范围了,但是更重要的是,这种方式允许他们在任何时候给你的电话发送广告。听起来很熟悉吧?应该是这样的!当然也可能并非如此。这种方式可以用来类比回调。将你的电话号码告诉餐厅就像将你的回调函数交给第三方服务一样。你期望餐厅在有空桌的时候给你发送短信,同样我们也期望第三方服务在合适的时候以它们承诺的方式调用我们函数。但是,一旦电话号码或回调函数交到了他们的手里,我们就完全失去对它的控制了。
幸好,还有另外一种解决方案。这种方案的设计允许你保留所有的控制权。你可能之前见过这种方式,那就是他们会给你一个蜂鸣器,如下所示。
如果你之前没有用过的话,它的想法其实非常简单。按照这种方式,他们不会记录你的名字或电话号码,而是给你一个这样的设备。当这个设备开始嗡嗡作响和发光时,就意味着有空桌了。在等待空桌的时候,你可以做任何你想做的事情,但此时你不需要放弃任何的东西。实际上,恰恰相反,是他们需要给你东西,这里没有所谓的控制反转。
蜂鸣器一定会处于如下三种状态之一:pending
、fulfilled
或rejected
。
pending
:默认状态,也是初始态。当他们给你蜂鸣器的时候,它就是这种状态。
fulfilled
:代表蜂鸣器开始闪烁,你的桌子已经准备就绪。
rejected
:如果蜂鸣器处于这种状态,则代表出现了问题。可能餐厅要打烊,或者他们忘记了晚上有人要包场。
再次强调,你作为蜂鸣器的接收者拥有完全的控制权。如果蜂鸣器处于fulfilled
状态,你就可以就坐了。如果它进入fulfilled
状态,但是你想忽略它,同样也可以。如果它进入了rejected
状态,这非常糟糕,但是你可以选择去其他地方就餐。如果什么事情都没有发生,它会依然处于pending
状态,你可能吃不上饭了,但是同时也没有失去什么。
现在,你已经掌握了餐厅蜂鸣器的事情,接下来,我们将这个知识用到其他重要的地方。
如果说将电话号码告诉餐厅就像将回调函数交给他们一样的话,那么接受这个蜂鸣器就像我们所谓的“Promise”一样。
像以往一样,我们首先从为什么开始。Promise 为什么会存在呢?它的出现是为了让异步请求所带来的复杂性更容易管理。与蜂鸣器非常类似,Promise
会处于如下三种状态中的某一种: pending
、fulfilled
或rejected
。但是与蜂鸣器不同,这些状态代表的不是饭桌的状态,它们所代表的是异步请求的状态。
如果异步请求依然还在进行,那么Promise
的状态会是pending
。如果异步请求成功完成的话,那么Promise
会将状态转换为fulfilled
。如果异步请求失败的话,Promise
会将状态转换为rejected
。蜂鸣器的比喻非常贴切,对吧?
理解了 Promise 为什么会存在以及它们的三种不同状态之后,我们还要回答三个问题:
如何创建 Promise?
如何改变 Promise 的状态?
如何监听 Promise 状态变化的时间?
1)如何创建 Promise?
这个问题非常简单,你可以使用new
创建Promise
的一个实例:
2)如何改变 Promise 的状态?
Promise
的构造函数会接收一个参数,这个参数是一个(回调)函数。该函数会被传入两个参数resolve
和reject
。
resolve
:一个能将 Promise 状态变为fulfilled
的函数;
reject
:一个能将 Promise 状态变为rejected
的函数;
在下面的代码中,我们使用setTimeout
等待两秒钟然后调用resolve
,这样会将 Promise 的状态变为fulfilled
:
分别用日志记录刚刚创建之时和大约两秒钟之后resolve
已被调用时的 Promise,我们可以看到状态的变化了:
请注意,Promise 从<pending>
变成了<resolved>
。
3)如何监听 Promise 状态变化的时间?
我认为,这是最重要的一个问题。我们已经知道了如何创建 Promise 和改变它的状态,这非常棒,但是如果我们不知道如何在状态变化之后做一些事情的话,这其实是没有太大意义的。
我们还没有讨论的一件事就是 Promise 到底是什么。当我们通过new Promise
创建 Promise 的时候,你实际创建的只是一个简单的 JavaScript 对象,这个对象可以调用两个方法then
和catch
。这是关键所在,当 Promise 的状态变为fulfilled
的时候,传递给.then
的函数将会被调用。如果 Promise 的状态变为rejected
,传递给.catch
的函数将会被调用。这就意味着,在你创建 Promise 的时候,要通过.then
将你希望异步请求成功时调用的函数传递进来,通过.catch
将你希望异步请求失败时调用的函数传递进来。
看一下下面的样例。我们依然使用setTimeout
在两秒钟(2000 毫秒)之后将 Promise 的状态变为fulfilled
:
如果你运行上述代码,会发现大约两秒钟之后,将会在控制台上打印出“Success!”。出现这样的结果主要有两个原因。首先,当我们创建 Promise 的时候,会在大约 2000 毫秒之后调用resolve
,这会将 Promise 的状态变为fulfilled
。其次,我们将onSuccess
函数传递给 Promise 的.then
。通过这种方式,我们告诉 Promise 在状态变成fulfilled
的时候(也就是大约 2000 毫秒之后)调用onSuccess
。
现在,我们假设发生了意料之外的事情,需要将 Promise 的状态变成rejected
。这次,我们不再调用resolve
,而是应该调用reject
:
这一次,调用的就不是onSuccess
函数了,而是onError
函数,这是因为我们调用了reject
。
现在,你已经掌握了 Promise API 相关的知识,现在我们开始看一下真正的代码。
还记得我们之前看到的异步回调样例吗?
这里我们能用 Promise API 的方式替换回调吗?如果我们将 AJAX 请求包装到 Promise 中会怎么样呢?如果能这样的话,我们就可以根据请求执行的情况简单地调用resolve
或reject
。让我们从getUser
入手:
非常好!请注意,getUser
的参数发生了变化。从接收id
、onSuccess
和onFailure
变成了只接收id
。这里不再需要这两个回调函数了,因为我们不必再将控制权转移出去了。相反,我们在这里使用了 Promise 的resolve
和reject
函数。如果请求成功的话,将会调用resolve
,如果出现错误的话,将会调用reject
。
接下来,我们重构getWeather
。我们按照相同的策略,将onSuccess
和onFailure
回调函数替换为resolve
和reject
。
看上去非常不错!我们需要更新的最后一个地方就是点击处理器。需要记住,我们想要的处理流程如下所示:
通过 Github API 获取用户的信息;
使用用户的地理位置,通过 Yahoo Weather API 获取其天气情况;
根据用户信息和天气信息更新 UI。
我们从第一步开始:通过 Github API 获取用户的信息。
注意,getUser
不再接收两个回调函数,它为我们返回的是一个 Promise,基于该 Promise,我们可以调用.then
和.catch
。如果调用.then
的话,会将用户信息传递给它。如果调用.catch
的话,会将错误信息传递给它。
接下来,让我们实现第二步:使用用户的地理位置获取其天气。
注意,我们采取了与第一步完全相同的模式,只不过调用getWeather
的时候,我们将userPromise
得到的user
传递了进去。
最后,在第三步中我们使用用户信息及其天气信息更新 UI。
在该地址有完整的源码,你可以进行尝试。
我们的新代码已经好多了,但是依然可以做一些改善。但是在进行改善之前,你需要了解 Promise 的另外两个特性,那就是链接(chaining)以及从resolve
中给then
传递参数。
链接
.then
和.catch
都会返回一个新的 Promise。这看上去像是一个很小的细节,但其实是非常重要的,因为这意味着 Promise 能够链接起来。
在下面的样例中,我们调用getPromise
,它会返回一个 Promise,这个 Promise 会在 2000 毫秒之后进行resolve
。从这里开始,因为.then
也将返回一个 Promise,所以我们就可以将多个.then
链接起来,直到我们抛出一个new Error
,而这个错误将会被.catch
方法捕获。
这样非常酷,但为什么它如此重要呢?还记得在讨论回调的章节中,我们讨论了回调的劣势之一就是它强迫我们背离自然、序列化的思考方式。当我们将 Promise 链接起来的时候,它不会再强迫我们背离自然的思考方式,因为链接之后的 Promise 是序列化的,也就是运行getPromise
,然后运行logA
,然后运行logB
……
我们看另外一个样例,这是使用fetch
API 时很常见的场景。fetch
将会为我们返回一个 Promise,它会解析为 HTTP 响应。为了得到实际的 JSON,我们还需要调用.json
。因为这种链接的方式,我们可以按照序列化的方式进行思考:
现在我们已经明白了链接的方式,接下来我们使用它来重构之前使用的getUser/getWeather
代码。
这看起来好多了,但是现在我们遇到了另外一个问题。你发现了吗?在第二个.then
中,我们想要调用updateUI
。这里的问题在于我们需要为updateUI
同时传递user
和weather
。按照我们目前的做法,我们只能接收到weather
,而没有user
。我们需要想出一种办法,让getWeather
在resolve
时能够同时得到user
和weather
。
问题的关键在于resolve
只是一个函数。你传递给它的任何参数都会往下传递给.then
所指定的函数。这意味着,在getWeather
中,如果我们自行调用resolve
的话,就可以同时将weather
和user
。然后,在链中的第二个.then
方法中,就可以同时接收到weather
和user
。
你可以在该地址查看最后的代码。
在点击处理逻辑中,与回调方式进行对比,我们就能看出 Promise 的威力。
此时逻辑感觉非常自然,因为它就是我们所习惯的序列化思考方式。getUser
,然后getWeather
,然后使用得到的数据更新 UI。
显而易见,Promise 能够显著提升异步代码的可读性,但是有没有能让它更好的方式呢?假设你是 TC39 委员会的成员,拥有为 JavaScript 语言添加新特性的权力。那么,你认为下面的代码还能怎样进行优化?
正如我们在前面所讨论的,这个代码已经非常好了。与我们大脑的思考方式相同,它是序列化顺序的。我们所遇到的问题就是需要将数据(users
)从第一个异步请求一直传递到最后一个.then
。这并不是什么大问题,但是需要我们修改getWeather
才能往下传递users
。如果我们想完全按照编写同步代码的方式来编写异步代码会怎样进行处理呢?如果我们真的能做到这一点,这个问题将会彻底消失,它看上去就完全是序列化的了。如下是可能的一种实现方式:
这看上去非常棒,我们的异步代码与同步代码完全相同。我们的大脑无需任何额外的步骤,因为这就是我们已经习以为常的思考方式。但令人遗憾的是,这样显然无法正常运行。我们都知道,user
和weather
仅仅是getUser
和getWeather
所返回的 Promise。但是不要忘记,我们现在是 TC39 的成员,有为语言添加任何特性的权力。这样的代码很难运行,我们必须教会 JavaScript 引擎区分异步函数调用和常规同步函数调用之前的差异。我们接下来添加几个关键字,让引擎运行起来更加容易。
首先,我们添加一个关键字到主函数上。这会提示引擎,我们会在这个函数中添加一些异步的方法调用。我们使用async
来达到这一目的。
很好!这看上去是非常合理的。接下来,我们添加另外一个关键字,让引擎能够知道哪个函数调用是异步的,函数所返回的是 Promise。这里我们使用await
,这就相当于说“嗨,引擎。这个函数是异步的并且会返回 Promise。你不能按照惯常的方式来执行,你需要等待 Promise 的最终值,然后才能继续运行”。在新的async
和await
就绪之后,代码将会变成下面的样子。
这种方式相当有吸引力。我们有了一种合理的方式,让异步代码的外表和行为完全和同步代码一致。接下来,就该让 TC39 的人相信这是一个好办法。你可能已经猜到了,我们并不需要说服他们,因为这已经是 JavaScript 的组成部分之一了,也就是所谓的Async/Await
。
你还不相信吗?该地址展现了添加 Async/Await 之后的实际代码,你尽可以进行尝试。
异步函数会返回 Promise
现在,我们已经看到了 Async/Await 所能带来的收益。接下来,我们讨论几个更小的细节,掌握它们也是非常重要的。首先,只要你为函数添加async
,它就会隐式的返回一个 Promise:
尽管getPromise
实际上空的,但是它依然会返回一个 Promise,因为它是一个async
函数。
如果async
函数有返回值的话,它也将会包装到一个 Promise 中。这意味着,你必须要使用.then
来访问它。
不能将 await 用到非 async 的函数中
如果你将await
用到非async
的函数中,那么将会出现错误。
关于这一点,我认为,当你将async
添加到一个函数上的时候,它会做两件事,首先它会让这个函数本身返回一个 Promise(或者将返回的内容包装到 Promise 中),其次,它会确保你能够在这个函数中使用await
。
错误处理
你可能发现,我们的代码有一点作弊。在原始的代码中,我们可以通过.catch
捕获所有的错误。在切换到 Async/Await 之后,我们移除了那些代码。在使用 Async/Await 时,最常用的方式就是将你的代码包装到一个try/catch
中,这样就能捕获错误了。
英文原文
https://tylermcginnis.com/async-javascript-from-callbacks-to-promises-to-async-await/
评论 5 条评论