什么是Promise?

当开发者们决定要学习一种新技术或模式的时候,他们的第一步总是“给我看代码!”。摸着石头过河对我们来讲是十分自然的。

但事实上仅仅考察API丢失了一些抽象过程。Promise是这样一种工具:它能非常明显地看出使用者是否理解了它是为什么和关于什么,还是仅仅学习和使用API。

所以在我展示Promise的代码之前,我想在概念上完整地解释一下Promise到底是什么。我希望这能更好地指引你探索如何将Promise理论整合到你自己的异步流程中。

带着这样的想法,让我们来看两种类比,来解释Promise是什么。

未来的值

想象这样的场景:我走到快餐店的柜台前,点了一个起士汉堡。并交了1.47美元的现金。通过点餐和付款,我为得到一个 (起士汉堡)制造了一个请求。我发起了一个事务。

但是通常来说,起士汉堡不会立即到我手中。收银员交给一些东西代替我的起士汉堡:一个带有点餐排队号的收据。这个点餐号是一个“我欠你”的许诺(Promise),它保证我最终会得到我的起士汉堡。

于是我就拿着我的收据和点餐号。我知道它代表我的 未来的起士汉堡,所以我无需再担心它——除了挨饿!

在我等待的时候,我可以做其他的事情,比如给我的朋友发微信说,“嘿,一块儿吃午餐吗?我要吃起士汉堡”。

我已经在用我的 未来的起士汉堡 进行推理了,即便它还没有到我手中。我的大脑可以这么做是因为它将点餐号作为起士汉堡的占位符号。这个占位符号实质上使这个值 与时间无关。它是一个 未来的值

最终,我听到,“113号!”。于是我愉快地拿着收据走回柜台前。我把收据递给收银员,拿回我的起士汉堡。

换句话说,一旦我的 未来的值 准备好,我就用我的许诺值换回值本身。

但还有另外一种可能的输出。它们叫我的号,但当我去取起士汉堡时,收银员遗憾地告诉我,“对不起,看起来我们的起士汉堡卖光了。”把这种场景下顾客有多沮丧放在一边,我们可以看到 未来的值 的一个重要性质:它们既可以表示成功也可以表示失败。

每次我点起士汉堡时,我都知道我要么最终得到一个起士汉堡,要么得到起士汉堡卖光的坏消息,并且不得不考虑中午吃点儿别的东西。

注意: 在代码中,事情没有这么简单,因为还隐含着一种点餐号永远也不会被叫到的情况,这时我们就被搁置在了一种无限等待的未解析状态。我们待会儿再回头处理这种情况。

现在和稍后的值

这一切也许听起来在思维上太过抽象而不能实施在你的代码中。那么,让我们更具体一些。

然而,在我们能介绍Promise是如何以这种方式工作之前,我们先看看我们已经明白的代码——回调!——是如何处理这些 未来值 的。

在你写代码来推导一个值时,比如在一个number上进行数学操作,不论你是否理解,对于这个值你已经假设了某些非常基础的事实——这个值已经是一个实在的 现在 值:

  1. var x, y = 2;
  2. console.log( x + y ); // NaN <-- 因为`x`还没有被赋值

x + y操作假定xy都已经被设定好了。用我们一会将要阐述的术语来讲,我们假定xy的值已经被 解析(resovle) 了。

期盼+操作符本身能够魔法般地检测并等待xy的值被解析(也就是准备好),然后仅在那之后才进行操作是没道理的。如果不同的语句 现在 完成而其他的 稍后 完成,这就会在程序中造成混乱,对吧?

如果两个语句中的一个(或两者同时)可能还没有完成,你如何才能推断它们的关系呢?如果语句2要依赖语句1的完成,那么这里仅有两种输出:不是语句1 现在 立即完成而且一切处理正常进行,就是语句1还没有完成,所以语句2将会失败。

如果这些东西听起来很像第一章的内容,很好!

回到我们的x + y的数学操作。想象有一种方法可以说,“将xy相加,但如果它们中任意一个还没有被设置,就等到它们都被设置。尽快将它们相加。”

你的大脑也许刚刚跳进回调。好吧,那么…

  1. function add(getX,getY,cb) {
  2. var x, y;
  3. getX( function(xVal){
  4. x = xVal;
  5. // 两者都准备好了?
  6. if (y != undefined) {
  7. cb( x + y ); // 发送加法的结果
  8. }
  9. } );
  10. getY( function(yVal){
  11. y = yVal;
  12. // 两者都准备好了?
  13. if (x != undefined) {
  14. cb( x + y ); // 发送加法的结果
  15. }
  16. } );
  17. }
  18. // `fetchX()`和`fetchY()`是同步或异步的函数
  19. add( fetchX, fetchY, function(sum){
  20. console.log( sum ); // 很简单吧?
  21. } );

花点儿时间来感受一下这段代码的美妙(或者丑陋),我耐心地等你。

虽然丑陋是无法否认的,但是关于这种异步模式有一些非常重要的事情需要注意。

在这段代码中,我们将xy作为未来的值对待,我们将add(..)操作表达为:(从外部看来)它并不关心xy或它们两者现在是否可用。换句话所,它泛化了 现在稍后,如此我们可以信赖add(..)操作的一个可预测的结果。

通过使用一个临时一致的add(..)——它跨越 现在稍后 的行为是相同的——异步代码的推理变得容易的多了。

更直白地说:为了一致地处理 现在稍后,我们将它们都作为 稍后:所有的操作都变成异步的。

当然,这种粗略的基于回调的方法留下了许多提升的空间。为了理解在不用关心 未来的值 在时间上什么时候变得可用的情况下推理它而带来的好处,这仅仅是迈出的一小步。

Promise值

我们绝对会在本章的后面深入更多关于Promise的细节——所以如果这让你犯糊涂,不要担心——但让我们先简单地看一下我们如何通过Promise来表达x + y的例子:

  1. function add(xPromise,yPromise) {
  2. // `Promise.all([ .. ])`接收一个Promise的数组,
  3. // 并返回一个等待它们全部完成的新Promise
  4. return Promise.all( [xPromise, yPromise] )
  5. // 当这个Promise被解析后,我们拿起收到的`X`和`Y`的值,并把它们相加
  6. .then( function(values){
  7. // `values`是一个从先前被解析的Promise那里收到的消息数组
  8. return values[0] + values[1];
  9. } );
  10. }
  11. // `fetchX()`和`fetchY()`分别为它们的值返回一个Promise,
  12. // 这些值可能在 *现在* 或 *稍后* 准备好
  13. add( fetchX(), fetchY() )
  14. // 为了将两个数字相加,我们得到一个Promise。
  15. // 现在我们链式地调用`then(..)`来等待返回的Promise被解析
  16. .then( function(sum){
  17. console.log( sum ); // 这容易多了!
  18. } );

在这个代码段中有两层Promise。

fetchX()fetchY()被直接调用,它们的返回值(promise!)被传入add(..)。这些promise表示的值将在 现在稍后 准备好,但是每个promise都将行为泛化为与时间无关。我们以一种时间无关的方式来推理XY的值。它们是 未来值

第二层是由add(..)创建(通过Promise.all([ .. ]))并返回的promise,我们通过调用then(..)来等待它。当add(..)操作完成后,我们的sum未来值 就准备好并可以打印了。我们将等待XY未来值 的逻辑隐藏在add(..)内部。

注意:add(..)内部。Promise.all([ .. ])调用创建了一个promise(它在等待promiseXpromiseY被解析)。链式调用.then(..)创建了另一个promise,它的return values[0] + values[1]这一行会被立即解析(使用加法的结果)。这样,我们链接在add(..)调用末尾的then(..)调用——在代码段最后——实际上是在第二个被返回的promise上进行操作,而非被Promise.all([ .. ])创建的第一个promise。另外,虽然我们没有在这第二个then(..)的末尾链接任何操作,它也已经创建了另一个promise,我们可以选择监听/使用它。这类Promise链的细节将会在本章后面进行讲解。

就像点一个起士汉堡,Promise的解析可能是一个拒绝(rejection)而非完成(fulfillment)。不同的是,被完成的Promise的值总是程序化的,而一个拒绝值——通常被称为“拒绝理由”——既可以被程序逻辑设置,也可以被运行时异常隐含地设置。

使用Promise,then(..)调用实际上可以接受两个函数,第一个用作完成(正如刚才所示),而第二个用作拒绝:

  1. add( fetchX(), fetchY() )
  2. .then(
  3. // 完成处理器
  4. function(sum) {
  5. console.log( sum );
  6. },
  7. // 拒绝处理器
  8. function(err) {
  9. console.error( err ); // 倒霉!
  10. }
  11. );

如果在取得XY时出现了错误,或在加法操作时某些事情不知怎地失败了,add(..)返回的promise就被拒绝了,传入then(..)的第二个错误处理回调函数会从promise那里收到拒绝的值。

因为Promise包装了时间相关的状态——等待当前值的完成或拒绝——从外部看来,Promise本身是时间无关的,如此Promise就可以用可预测的方式组合,而不用关心时间或底层的结果。

另外,一旦Promise被解析,它就永远保持那个状态——它在那个时刻变成了一个 不可变的值——而且可以根据需要 被监听 任意多次。

注意: 因为Promise一旦被解析就是外部不可变的,所以现在将这个值传递给任何其他团体都是安全的,而且我们知道它不会被意外或恶意地被修改。这在许多团体监听同一个Promise的解析时特别有用。一个团体去影响另一个团体对Promise解析的监听能力是不可能的。不可变性听起来是一个学院派话题,但它实际上是Promise设计中最基础且最重要的方面之一,因此不能将它随意地跳过。

这是用于理解Promise的最强大且最重要的概念之一。通过大量的工作,你可以仅仅使用丑陋的回调组合来创建相同的效果,但这真的不是一个高效的策略,特别是你不得不一遍一遍地重复它。

Promise是一种用来包装与组合 未来值,并且可以很容易复用的机制。

完成事件

正如我们刚才看到的,一个独立的Promise作为一个 未来值 动作。但还有另外一种方式考虑Promise的解析:在一个异步任务的两个或以上步骤中,作为一种流程控制机制——俗称“这个然后那个”。

让我们想象调用foo(..)来执行某个任务。我们对它的细节一无所知,我们也不关心。它可能会立即完成任务,也可能会花一段时间完成。

我们仅仅想简单地知道foo(..)什么时候完成,以便于我们可以移动到下一个任务。换句话说,我们想要一种方法被告知foo(..)的完成,以便于我们可以 继续

在典型的JavaScript风格中,如果你需要监听一个通知,你很可能会想到事件(event)。那么我们可以将我们的通知需求重新表述为,监听由foo(..)发出的 完成(或 继续)事件。

注意: 将它称为一个“完成事件”还是一个“继续事件”取决于你的角度。你是更关心foo(..)发生的事情,还是更关心foo(..)完成 之后 发生的事情?两种角度都对而且都有用。事件通知告诉我们foo(..)已经 完成,但是 继续 到下一个步骤也没问题。的确,你为了事件通知调用而传入的回调函数本身,在前面我们称它为一个 延续。因为 完成事件 更加聚焦于foo(..),也就是我们当前注意的东西,所以在这篇文章的其余部分我们稍稍偏向于使用 完成事件

使用回调,“通知”就是被任务(foo(..))调用的我们的回调函数。但是使用Promise,我们将关系扭转过来,我们希望能够监听一个来自于foo(..)的事件,当我们被通知时,做相应的处理。

首先,考虑一些假想代码:

  1. foo(x) {
  2. // 开始做一些可能会花一段时间的事情
  3. }
  4. foo( 42 )
  5. on (foo "completion") {
  6. // 现在我们可以做下一步了!
  7. }
  8. on (foo "error") {
  9. // 噢,在`foo(..)`中有某些事情搞错了
  10. }

我们调用foo(..)然后我们设置两个事件监听器,一个给"completion",一个给"error"——foo(..)调用的两种可能的最终结果。实质上,foo(..)甚至不知道调用它的代码监听了这些事件,这构成了一个非常美妙的 关注分离(separation of concerns)

不幸的是,这样的代码将需要JS环境不具备的一些“魔法”(而且显得有些不切实际)。这里是一种用JS表达它的更自然的方式:

  1. function foo(x) {
  2. // 开始做一些可能会花一段时间的事情
  3. // 制造一个`listener`事件通知能力并返回
  4. return listener;
  5. }
  6. var evt = foo( 42 );
  7. evt.on( "completion", function(){
  8. // 现在我们可以做下一步了!
  9. } );
  10. evt.on( "failure", function(err){
  11. // 噢,在`foo(..)`中有某些事情搞错了
  12. } );

foo(..)明确地创建并返回了一个事件监听能力,调用方代码接收并在它上面注册了两个事件监听器。

很明显这反转了一般的面向回调代码,而且是有意为之。与将回调传入foo(..)相反,它返回一个我们称之为evt的事件能力,它接收回调。

但如果你回想第二章,回调本身代表着一种 控制反转。所以反转回调模式实际上是 反转的反转,或者说是一个 控制非反转——将控制权归还给我们希望保持它的调用方代码,

一个重要的好处是,代码的多个分离部分都可以被赋予事件监听能力,而且它们都可在foo(..)完成时被独立地通知,来执行后续的步骤:

  1. var evt = foo( 42 );
  2. // 让`bar(..)`监听`foo(..)`的完成
  3. bar( evt );
  4. // 同时,让`baz(..)`监听`foo(..)`的完成
  5. baz( evt );

控制非反转 导致了更好的 关注分离,也就是bar(..)baz(..)不必卷入foo(..)是如何被调用的问题。相似地,foo(..)也不必知道或关心bar(..)baz(..)的存在或它们是否在等待foo(..)完成的通知。

实质上,这个evt对象是一个中立的第三方团体,在分离的关注点之间进行交涉。

Promise“事件”

正如你可能已经猜到的,evt事件监听能力是一个Promise的类比。

在一个基于Promise的方式中,前面的代码段将会使foo(..)创建并返回一个Promise实例,而且这个promise将会被传入bar(..)baz(..)

注意: 我们监听的Promise解析“事件”并不是严格的事件(虽然它们为了某些目的表现得像事件),而且它们也不经常称为"completion""error"。相反,我们用then(..)来注册一个"then"事件。或者也许更准确地讲,then(..)注册了"fulfillment(完成)"和/或"rejection(拒绝)"事件,虽然我们在代码中不会看到这些名词被明确地使用。

考虑:

  1. function foo(x) {
  2. // 开始做一些可能会花一段时间的事情
  3. // 构建并返回一个promise
  4. return new Promise( function(resolve,reject){
  5. // 最终需要调用`resolve(..)`或`reject(..)`
  6. // 它们是这个promise的解析回调
  7. } );
  8. }
  9. var p = foo( 42 );
  10. bar( p );
  11. baz( p );

注意:new Promise( function(..){ .. } )中展示的模式通常被称为“揭示构造器(revealing constructor)”。被传入的函数被立即执行(不会被异步推迟,像then(..)的回调那样),而且它被提供了两个参数,我们叫它们resolvereject。这些是Promise的解析函数。resolve(..)一般表示完成,而reject(..)表示拒绝。

你可能猜到了bar(..)baz(..)的内部看起来是什么样子:

  1. function bar(fooPromise) {
  2. // 监听`foo(..)`的完成
  3. fooPromise.then(
  4. function(){
  5. // `foo(..)`现在完成了,那么做`bar(..)`的任务
  6. },
  7. function(){
  8. // 噢,在`foo(..)`中有某些事情搞错了
  9. }
  10. );
  11. }
  12. // `baz(..)`同上

Promise解析没有必要一定发送消息,就像我们将Promise作为 未来值 考察时那样。它可以仅仅作为一种流程控制信号,就像前面的代码中那样使用。

另一种表达方式是:

  1. function bar() {
  2. // `foo(..)`绝对已经完成了,那么做`bar(..)`的任务
  3. }
  4. function oopsBar() {
  5. // 噢,在`foo(..)`中有某些事情搞错了,那么`bar(..)`不会运行
  6. }
  7. // `baz()`和`oopsBaz()`同上
  8. var p = foo( 42 );
  9. p.then( bar, oopsBar );
  10. p.then( baz, oopsBaz );

注意: 如果你以前见过基于Promise的代码,你可能会相信这段代码的最后两行应当写做p.then( .. ).then( .. ),使用链接,而不是p.then(..); p.then(..)。这将会是两种完全不同的行为,所以要小心!这种区别现在看起来可能不明显,但是它们实际上是我们目前还没有见过的异步模式:分割(splitting)/分叉(forking)。不必担心!本章后面我们会回到这个话题。

与将ppromise传入bar(..)baz(..)相反,我们使用promise来控制bar(..)baz(..)何时该运行,如果有这样的时刻。主要区别在于错误处理。

在第一个代码段的方式中,无论foo(..)是否成功bar(..)都会被调用,如果被通知foo(..)失败了的话它提供自己的后备逻辑。显然,baz(..)也是这样做的。

在第二个代码段中,bar(..)仅在foo(..)成功后才被调用,否则oopsBar(..)会被调用。baz(..)也是。

两种方式本身都 。但会有一些情况使一种优于另一种。

在这两种方式中,从foo(..)返回的promisep都被用于控制下一步发生什么。

另外,两个代码段都以对同一个promisep调用两次then(..)结束,这展示了先前的观点,也就是Promise(一旦被解析)会永远保持相同的解析结果(完成或拒绝),而且可以按需要后续地被监听任意多次。

无论何时p被解析,下一步都将总是相同的,包括 现在稍后