什么是Promise?
当开发者们决定要学习一种新技术或模式的时候,他们的第一步总是“给我看代码!”。摸着石头过河对我们来讲是十分自然的。
但事实上仅仅考察API丢失了一些抽象过程。Promise是这样一种工具:它能非常明显地看出使用者是否理解了它是为什么和关于什么,还是仅仅学习和使用API。
所以在我展示Promise的代码之前,我想在概念上完整地解释一下Promise到底是什么。我希望这能更好地指引你探索如何将Promise理论整合到你自己的异步流程中。
带着这样的想法,让我们来看两种类比,来解释Promise是什么。
未来的值
想象这样的场景:我走到快餐店的柜台前,点了一个起士汉堡。并交了1.47美元的现金。通过点餐和付款,我为得到一个 值(起士汉堡)制造了一个请求。我发起了一个事务。
但是通常来说,起士汉堡不会立即到我手中。收银员交给一些东西代替我的起士汉堡:一个带有点餐排队号的收据。这个点餐号是一个“我欠你”的许诺(Promise),它保证我最终会得到我的起士汉堡。
于是我就拿着我的收据和点餐号。我知道它代表我的 未来的起士汉堡,所以我无需再担心它——除了挨饿!
在我等待的时候,我可以做其他的事情,比如给我的朋友发微信说,“嘿,一块儿吃午餐吗?我要吃起士汉堡”。
我已经在用我的 未来的起士汉堡 进行推理了,即便它还没有到我手中。我的大脑可以这么做是因为它将点餐号作为起士汉堡的占位符号。这个占位符号实质上使这个值 与时间无关。它是一个 未来的值。
最终,我听到,“113号!”。于是我愉快地拿着收据走回柜台前。我把收据递给收银员,拿回我的起士汉堡。
换句话说,一旦我的 未来的值 准备好,我就用我的许诺值换回值本身。
但还有另外一种可能的输出。它们叫我的号,但当我去取起士汉堡时,收银员遗憾地告诉我,“对不起,看起来我们的起士汉堡卖光了。”把这种场景下顾客有多沮丧放在一边,我们可以看到 未来的值 的一个重要性质:它们既可以表示成功也可以表示失败。
每次我点起士汉堡时,我都知道我要么最终得到一个起士汉堡,要么得到起士汉堡卖光的坏消息,并且不得不考虑中午吃点儿别的东西。
注意: 在代码中,事情没有这么简单,因为还隐含着一种点餐号永远也不会被叫到的情况,这时我们就被搁置在了一种无限等待的未解析状态。我们待会儿再回头处理这种情况。
现在和稍后的值
这一切也许听起来在思维上太过抽象而不能实施在你的代码中。那么,让我们更具体一些。
然而,在我们能介绍Promise是如何以这种方式工作之前,我们先看看我们已经明白的代码——回调!——是如何处理这些 未来值 的。
在你写代码来推导一个值时,比如在一个number
上进行数学操作,不论你是否理解,对于这个值你已经假设了某些非常基础的事实——这个值已经是一个实在的 现在 值:
var x, y = 2;
console.log( x + y ); // NaN <-- 因为`x`还没有被赋值
x + y
操作假定x
和y
都已经被设定好了。用我们一会将要阐述的术语来讲,我们假定x
和y
的值已经被 解析(resovle) 了。
期盼+
操作符本身能够魔法般地检测并等待x
和y
的值被解析(也就是准备好),然后仅在那之后才进行操作是没道理的。如果不同的语句 现在 完成而其他的 稍后 完成,这就会在程序中造成混乱,对吧?
如果两个语句中的一个(或两者同时)可能还没有完成,你如何才能推断它们的关系呢?如果语句2要依赖语句1的完成,那么这里仅有两种输出:不是语句1 现在 立即完成而且一切处理正常进行,就是语句1还没有完成,所以语句2将会失败。
如果这些东西听起来很像第一章的内容,很好!
回到我们的x + y
的数学操作。想象有一种方法可以说,“将x
和y
相加,但如果它们中任意一个还没有被设置,就等到它们都被设置。尽快将它们相加。”
你的大脑也许刚刚跳进回调。好吧,那么…
function add(getX,getY,cb) {
var x, y;
getX( function(xVal){
x = xVal;
// 两者都准备好了?
if (y != undefined) {
cb( x + y ); // 发送加法的结果
}
} );
getY( function(yVal){
y = yVal;
// 两者都准备好了?
if (x != undefined) {
cb( x + y ); // 发送加法的结果
}
} );
}
// `fetchX()`和`fetchY()`是同步或异步的函数
add( fetchX, fetchY, function(sum){
console.log( sum ); // 很简单吧?
} );
花点儿时间来感受一下这段代码的美妙(或者丑陋),我耐心地等你。
虽然丑陋是无法否认的,但是关于这种异步模式有一些非常重要的事情需要注意。
在这段代码中,我们将x
和y
作为未来的值对待,我们将add(..)
操作表达为:(从外部看来)它并不关心x
或y
或它们两者现在是否可用。换句话所,它泛化了 现在 和 稍后,如此我们可以信赖add(..)
操作的一个可预测的结果。
通过使用一个临时一致的add(..)
——它跨越 现在 和 稍后 的行为是相同的——异步代码的推理变得容易的多了。
更直白地说:为了一致地处理 现在 和 稍后,我们将它们都作为 稍后:所有的操作都变成异步的。
当然,这种粗略的基于回调的方法留下了许多提升的空间。为了理解在不用关心 未来的值 在时间上什么时候变得可用的情况下推理它而带来的好处,这仅仅是迈出的一小步。
Promise值
我们绝对会在本章的后面深入更多关于Promise的细节——所以如果这让你犯糊涂,不要担心——但让我们先简单地看一下我们如何通过Promise
来表达x + y
的例子:
function add(xPromise,yPromise) {
// `Promise.all([ .. ])`接收一个Promise的数组,
// 并返回一个等待它们全部完成的新Promise
return Promise.all( [xPromise, yPromise] )
// 当这个Promise被解析后,我们拿起收到的`X`和`Y`的值,并把它们相加
.then( function(values){
// `values`是一个从先前被解析的Promise那里收到的消息数组
return values[0] + values[1];
} );
}
// `fetchX()`和`fetchY()`分别为它们的值返回一个Promise,
// 这些值可能在 *现在* 或 *稍后* 准备好
add( fetchX(), fetchY() )
// 为了将两个数字相加,我们得到一个Promise。
// 现在我们链式地调用`then(..)`来等待返回的Promise被解析
.then( function(sum){
console.log( sum ); // 这容易多了!
} );
在这个代码段中有两层Promise。
fetchX()
和fetchY()
被直接调用,它们的返回值(promise!)被传入add(..)
。这些promise表示的值将在 现在 或 稍后 准备好,但是每个promise都将行为泛化为与时间无关。我们以一种时间无关的方式来推理X
和Y
的值。它们是 未来值。
第二层是由add(..)
创建(通过Promise.all([ .. ])
)并返回的promise,我们通过调用then(..)
来等待它。当add(..)
操作完成后,我们的sum
未来值 就准备好并可以打印了。我们将等待X
和Y
的 未来值 的逻辑隐藏在add(..)
内部。
注意: 在add(..)
内部。Promise.all([ .. ])
调用创建了一个promise(它在等待promiseX
和promiseY
被解析)。链式调用.then(..)
创建了另一个promise,它的return values[0] + values[1]
这一行会被立即解析(使用加法的结果)。这样,我们链接在add(..)
调用末尾的then(..)
调用——在代码段最后——实际上是在第二个被返回的promise上进行操作,而非被Promise.all([ .. ])
创建的第一个promise。另外,虽然我们没有在这第二个then(..)
的末尾链接任何操作,它也已经创建了另一个promise,我们可以选择监听/使用它。这类Promise链的细节将会在本章后面进行讲解。
就像点一个起士汉堡,Promise的解析可能是一个拒绝(rejection)而非完成(fulfillment)。不同的是,被完成的Promise的值总是程序化的,而一个拒绝值——通常被称为“拒绝理由”——既可以被程序逻辑设置,也可以被运行时异常隐含地设置。
使用Promise,then(..)
调用实际上可以接受两个函数,第一个用作完成(正如刚才所示),而第二个用作拒绝:
add( fetchX(), fetchY() )
.then(
// 完成处理器
function(sum) {
console.log( sum );
},
// 拒绝处理器
function(err) {
console.error( err ); // 倒霉!
}
);
如果在取得X
或Y
时出现了错误,或在加法操作时某些事情不知怎地失败了,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(..)
的事件,当我们被通知时,做相应的处理。
首先,考虑一些假想代码:
foo(x) {
// 开始做一些可能会花一段时间的事情
}
foo( 42 )
on (foo "completion") {
// 现在我们可以做下一步了!
}
on (foo "error") {
// 噢,在`foo(..)`中有某些事情搞错了
}
我们调用foo(..)
然后我们设置两个事件监听器,一个给"completion"
,一个给"error"
——foo(..)
调用的两种可能的最终结果。实质上,foo(..)
甚至不知道调用它的代码监听了这些事件,这构成了一个非常美妙的 关注分离(separation of concerns)。
不幸的是,这样的代码将需要JS环境不具备的一些“魔法”(而且显得有些不切实际)。这里是一种用JS表达它的更自然的方式:
function foo(x) {
// 开始做一些可能会花一段时间的事情
// 制造一个`listener`事件通知能力并返回
return listener;
}
var evt = foo( 42 );
evt.on( "completion", function(){
// 现在我们可以做下一步了!
} );
evt.on( "failure", function(err){
// 噢,在`foo(..)`中有某些事情搞错了
} );
foo(..)
明确地创建并返回了一个事件监听能力,调用方代码接收并在它上面注册了两个事件监听器。
很明显这反转了一般的面向回调代码,而且是有意为之。与将回调传入foo(..)
相反,它返回一个我们称之为evt
的事件能力,它接收回调。
但如果你回想第二章,回调本身代表着一种 控制反转。所以反转回调模式实际上是 反转的反转,或者说是一个 控制非反转——将控制权归还给我们希望保持它的调用方代码,
一个重要的好处是,代码的多个分离部分都可以被赋予事件监听能力,而且它们都可在foo(..)
完成时被独立地通知,来执行后续的步骤:
var evt = foo( 42 );
// 让`bar(..)`监听`foo(..)`的完成
bar( evt );
// 同时,让`baz(..)`监听`foo(..)`的完成
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(拒绝)"
事件,虽然我们在代码中不会看到这些名词被明确地使用。
考虑:
function foo(x) {
// 开始做一些可能会花一段时间的事情
// 构建并返回一个promise
return new Promise( function(resolve,reject){
// 最终需要调用`resolve(..)`或`reject(..)`
// 它们是这个promise的解析回调
} );
}
var p = foo( 42 );
bar( p );
baz( p );
注意: 在new Promise( function(..){ .. } )
中展示的模式通常被称为“揭示构造器(revealing constructor)”。被传入的函数被立即执行(不会被异步推迟,像then(..)
的回调那样),而且它被提供了两个参数,我们叫它们resolve
和reject
。这些是Promise的解析函数。resolve(..)
一般表示完成,而reject(..)
表示拒绝。
你可能猜到了bar(..)
和baz(..)
的内部看起来是什么样子:
function bar(fooPromise) {
// 监听`foo(..)`的完成
fooPromise.then(
function(){
// `foo(..)`现在完成了,那么做`bar(..)`的任务
},
function(){
// 噢,在`foo(..)`中有某些事情搞错了
}
);
}
// `baz(..)`同上
Promise解析没有必要一定发送消息,就像我们将Promise作为 未来值 考察时那样。它可以仅仅作为一种流程控制信号,就像前面的代码中那样使用。
另一种表达方式是:
function bar() {
// `foo(..)`绝对已经完成了,那么做`bar(..)`的任务
}
function oopsBar() {
// 噢,在`foo(..)`中有某些事情搞错了,那么`bar(..)`不会运行
}
// `baz()`和`oopsBaz()`同上
var p = foo( 42 );
p.then( bar, oopsBar );
p.then( baz, oopsBaz );
注意: 如果你以前见过基于Promise的代码,你可能会相信这段代码的最后两行应当写做p.then( .. ).then( .. )
,使用链接,而不是p.then(..); p.then(..)
。这将会是两种完全不同的行为,所以要小心!这种区别现在看起来可能不明显,但是它们实际上是我们目前还没有见过的异步模式:分割(splitting)/分叉(forking)。不必担心!本章后面我们会回到这个话题。
与将p
promise传入bar(..)
和baz(..)
相反,我们使用promise来控制bar(..)
和baz(..)
何时该运行,如果有这样的时刻。主要区别在于错误处理。
在第一个代码段的方式中,无论foo(..)
是否成功bar(..)
都会被调用,如果被通知foo(..)
失败了的话它提供自己的后备逻辑。显然,baz(..)
也是这样做的。
在第二个代码段中,bar(..)
仅在foo(..)
成功后才被调用,否则oopsBar(..)
会被调用。baz(..)
也是。
两种方式本身都 对。但会有一些情况使一种优于另一种。
在这两种方式中,从foo(..)
返回的promisep
都被用于控制下一步发生什么。
另外,两个代码段都以对同一个promisep
调用两次then(..)
结束,这展示了先前的观点,也就是Promise(一旦被解析)会永远保持相同的解析结果(完成或拒绝),而且可以按需要后续地被监听任意多次。
无论何时p
被解析,下一步都将总是相同的,包括 现在 和 稍后。