链式流程

我们已经被暗示过几次,但Promise不仅仅是一个单步的 这个然后那个 操作机制。当然,那是构建块儿,但事实证明我们可以将多个Promise串联在一起来表达一系列的异步步骤。

使这一切能够工作的关键,是Promise的两个固有行为:

  • 每次你在一个Promise上调用then(..)的时候,它都创建并返回一个新的Promise,我们可以在它上面进行 链接
  • 无论你从then(..)调用的完成回调中(第一个参数)返回什么值,它都做为被链接的Promise的完成。

我们首先来说明一下这是什么意思,然后我们将会延伸出它是如何帮助我们创建异步顺序的控制流程的。考虑下面的代码:

  1. var p = Promise.resolve( 21 );
  2. var p2 = p.then( function(v){
  3. console.log( v ); // 21
  4. // 使用值`42`完成`p2`
  5. return v * 2;
  6. } );
  7. // 在`p2`后链接
  8. p2.then( function(v){
  9. console.log( v ); // 42
  10. } );

通过返回v * 2(也就是42),我们完成了由第一个then(..)调用创建并返回的p2promise。当p2then(..)调用运行时,它从return v * 2语句那里收到完成信号。当然,p2.then(..)还会创建另一个promise,我们将它存储在变量p3中。

但是不得不创建临时变量p2(或p3等)有点儿恼人。幸运的是,我们可以简单地将这些链接在一起:

  1. var p = Promise.resolve( 21 );
  2. p
  3. .then( function(v){
  4. console.log( v ); // 21
  5. // 使用值`42`完成被链接的promise
  6. return v * 2;
  7. } )
  8. // 这里是被链接的promise
  9. .then( function(v){
  10. console.log( v ); // 42
  11. } );

那么现在第一个then(..)是异步序列的第一步,而第二个then(..)就是第二步。它可以根据你的需要延伸至任意长。只要持续不断地用每个自动创建的Promise在前一个then(..)末尾进行连接即可。

但是这里错过了某些东西。要是我们想让第2步等待第1步去做一些异步的事情呢?我们使用的是一个立即的return语句,它立即完成了链接中的promise。

使Promise序列在每一步上都是真正异步的关键,需要回忆一下当你向Promise.resolve(..)传递一个Promise或thenable而非一个最终值时它如何执行。Promise.resolve(..)会直接返回收到的纯粹Promise,或者它会展开收到的thenable的值——并且它会递归地持续展开thenable。

如果你从完成(或拒绝)处理器中返回一个thenable或Promise,同样的展开操作也会发生。考虑这段代码:

  1. var p = Promise.resolve( 21 );
  2. p.then( function(v){
  3. console.log( v ); // 21
  4. // 创建一个promise并返回它
  5. return new Promise( function(resolve,reject){
  6. // 使用值`42`完成
  7. resolve( v * 2 );
  8. } );
  9. } )
  10. .then( function(v){
  11. console.log( v ); // 42
  12. } );

即便我们把42包装在一个我们返回的promise中,它依然会被展开并作为下一个被链接的promise的解析,如此第二个then(..)仍然收到42。如果我们在这个包装promise中引入异步,一切还是会同样正常的工作:

  1. var p = Promise.resolve( 21 );
  2. p.then( function(v){
  3. console.log( v ); // 21
  4. // 创建一个promise并返回
  5. return new Promise( function(resolve,reject){
  6. // 引入异步!
  7. setTimeout( function(){
  8. // 使用值`42`完成
  9. resolve( v * 2 );
  10. }, 100 );
  11. } );
  12. } )
  13. .then( function(v){
  14. // 在上一步中的100毫秒延迟之后运行
  15. console.log( v ); // 42
  16. } );

这真是不可思议的强大!现在我们可以构建一个序列,它可以有我们想要的任意多的步骤,而且每一步都可以按照需要来推迟下一步(或者不推迟)。

当然,在这些例子中一步一步向下传递的值是可选的。如果你没有返回一个明确的值,那么它假定一个隐含的undefined,而且promise依然会以同样的方式链接在一起。如此,每个Promise的解析只不过是进行至下一步的信号。

为了演示更长的链接,让我们把推迟Promise的创建(没有解析信息)泛化为一个我们可以在多个步骤中复用的工具:

  1. function delay(time) {
  2. return new Promise( function(resolve,reject){
  3. setTimeout( resolve, time );
  4. } );
  5. }
  6. delay( 100 ) // step 1
  7. .then( function STEP2(){
  8. console.log( "step 2 (after 100ms)" );
  9. return delay( 200 );
  10. } )
  11. .then( function STEP3(){
  12. console.log( "step 3 (after another 200ms)" );
  13. } )
  14. .then( function STEP4(){
  15. console.log( "step 4 (next Job)" );
  16. return delay( 50 );
  17. } )
  18. .then( function STEP5(){
  19. console.log( "step 5 (after another 50ms)" );
  20. } )
  21. ...

调用delay(200)创建了一个将在200毫秒内完成的promise,然后我们在第一个then(..)的完成回调中返回它,这将使第二个then(..)的promise等待这个200毫秒的promise。

注意: 正如刚才描述的,技术上讲在这个交替中有两个promise:一个200毫秒延迟的promise,和一个被第二个then(..)链接的promise。但你可能会发现将这两个promise组合在一起更容易思考,因为Promise机制帮你把它们的状态自动地混合到了一起。从这个角度讲,你可以认为return delay(200)创建了一个promise来取代早前一个返回的被链接的promise。

老实说,没有任何消息进行传递的一系列延迟作为Promise流程控制的例子不是很有用。让我们来看一个更加实在的场景:

与计时器不同,让我们考虑发起Ajax请求:

  1. // 假定一个`ajax( {url}, {callback} )`工具
  2. // 带有Promise的ajax
  3. function request(url) {
  4. return new Promise( function(resolve,reject){
  5. // `ajax(..)`的回调应当是我们的promise的`resolve(..)`函数
  6. ajax( url, resolve );
  7. } );
  8. }

我们首先定义一个request(..)工具,它构建一个promise表示ajax(..)调用的完成:

  1. request( "http://some.url.1/" )
  2. .then( function(response1){
  3. return request( "http://some.url.2/?v=" + response1 );
  4. } )
  5. .then( function(response2){
  6. console.log( response2 );
  7. } );

注意: 开发者们通常遭遇的一种情况是,他们想用本身不支持Promise的工具(就像这里的ajax(..),它期待一个回调)进行Promise式的异步流程控制。虽然ES6原生的Promise机制不会自动帮我们解决这种模式,但是在实践中所有的Promise库会帮我们这么做。它们通常称这种处理为“提升(lifting)”或“promise化”或其他的什么名词。我们稍后再回头讨论这种技术。

使用返回Promise的request(..),通过用第一个URL调用它我们在链条中隐式地创建了第一步,然后我们用第一个then(..)在返回的promise末尾进行连接。

一旦response1返回,我们用它的值来构建第二个URL,并且发起第二个request(..)调用。这第二个promisereturn的,所以我们的异步流程控制的第三步将会等待这个Ajax调用完成。最终,一旦response2返回,我们就打印它。

我们构建的Promise链不仅是一个表达多步骤异步序列的流程控制,它还扮演者将消息从一步传递到下一步的消息管道。

要是Promise链中的某一步出错了会怎样呢?一个错误/异常是基于每个Promise的,意味着在链条的任意一点捕获这些错误是可能的,而且这些捕获操作在那一点上将链条“重置”,使它回到正常的操作上来:

  1. // 步骤 1:
  2. request( "http://some.url.1/" )
  3. // 步骤 2:
  4. .then( function(response1){
  5. foo.bar(); // 没有定义,错误!
  6. // 永远不会跑到这里
  7. return request( "http://some.url.2/?v=" + response1 );
  8. } )
  9. // 步骤 3:
  10. .then(
  11. function fulfilled(response2){
  12. // 永远不会跑到这里
  13. },
  14. // 拒绝处理器捕捉错误
  15. function rejected(err){
  16. console.log( err ); // 来自 `foo.bar()` 的 `TypeError` 错误
  17. return 42;
  18. }
  19. )
  20. // 步骤 4:
  21. .then( function(msg){
  22. console.log( msg ); // 42
  23. } );

当错误在第2步中发生时,第3步的拒绝处理器将它捕获。拒绝处理器的返回值(在这个代码段里是42),如果有的话,将会完成下一步(第4步)的promise,如此整个链条又回到完成的状态。

注意: 就像我们刚才讨论过的,当我们从一个完成处理器中返回一个promise时,它会被展开并有可能推迟下一步。这对从拒绝处理器中返回的promise也是成立的,这样如果我们在第3步返回一个promise而不是return 42,那么这个promise就可能会推迟第4步。不管是在then(..)的完成还是拒绝处理器中,一个被抛出的异常都将导致下一个(链接着的)promise立即用这个异常拒绝。

如果你在一个promise上调用then(..),而且你只向它传递了一个完成处理器,一个假定的拒绝处理器会取而代之:

  1. var p = new Promise( function(resolve,reject){
  2. reject( "Oops" );
  3. } );
  4. var p2 = p.then(
  5. function fulfilled(){
  6. // 永远不会跑到这里
  7. }
  8. // 如果忽略或者传入任何非函数的值,
  9. // 会有假定有一个这样的拒绝处理器
  10. // function(err) {
  11. // throw err;
  12. // }
  13. );

如你所见,这个假定的拒绝处理器仅仅简单地重新抛出错误,它最终强制p2(链接着的promise)用同样的错误进行拒绝。实质上,它允许错误持续地在Promise链上传播,直到遇到一个明确定义的拒绝处理器。

注意: 稍后我们会讲到更多关于使用Promise进行错误处理的细节,因为会有更多微妙的细节需要关心。

如果没有一个恰当的合法的函数作为then(..)的完成处理器参数,也会有一个默认的处理器取而代之:

  1. var p = Promise.resolve( 42 );
  2. p.then(
  3. // 如果忽略或者传入任何非函数的值,
  4. // 会有假定有一个这样的完成处理器
  5. // function(v) {
  6. // return v;
  7. // }
  8. null,
  9. function rejected(err){
  10. // 永远不会跑到这里
  11. }
  12. );

如你所见,默认的完成处理器简单地将它收到的任何值传递给下一步(Promise)。

注意: then(null,function(err){ .. })这种模式——仅处理拒绝(如果发生的话)但让成功通过——有一个缩写的API:catch(function(err){ .. })。我们会在下一节中更全面地涵盖catch(..)

让我们简要地复习一下使链式流程控制成为可能的Promise固有行为:

  • 在一个Promise上的then(..)调用会自动生成一个新的Promise并返回。
  • 在完成/拒绝处理器内部,如果你返回一个值或抛出一个异常,新返回的Promise(可以被链接的)将会相应地被解析。
  • 如果完成或拒绝处理器返回一个Promise,它会被展开,所以无论它被解析为什么值,这个值都将变成从当前的then(..)返回的被链接的Promise的解析。

虽然链式流程控制很有用,但是将它认为是Promise的组合方式的副作用可能最准确,而不是它的主要意图。正如我们已经详细讨论过许多次的,Promise泛化了异步处理并且包装了与时间相关的值和状态,这才是让我们以这种有用的方式将它们链接在一起的原因。

当然,相对于我们在第二章中看到的一堆混乱的回调,这种链条的顺序表达是一个巨大的改进。但是仍然要蹚过相当多的模板代码(then(..) and function(){ .. })。在下一章中,我们将看到一种极大美化顺序流程控制的表达模式,生成器(generators)。

术语: Resolve(解析),Fulfill(完成),和Reject(拒绝)

在你更多深入地学习Promise之前,在“解析(resolve)”,“完成(fulfill)”,和“拒绝(reject)”这些名词之间还有一些我们需要辨明的小困惑。首先让我们考虑一下Promise(..)构造器:

  1. var p = new Promise( function(X,Y){
  2. // X() 给 fulfillment(完成)
  3. // Y() 给 rejection(拒绝)
  4. } );

如你所见,有两个回调(标识为XY)被提供了。第一个 通常 用于表示Promise完成了,而第二个 总是 表示Promise拒绝了。但“通常”是什么意思?它对这些参数的正确命名暗示着什么呢?

最终,这只是你的用户代码,和将被引擎翻译为没有任何含义的东西的标识符,所以在 技术上 它无紧要;foo(..)bar(..)在功能性上是相等的。但是你用的词不仅会影响你如何考虑这段代码,还会影响你所在团队的其他开发者如何考虑它。将精心策划的异步代码错误地考虑,几乎可以说要比面条一般的回调还要差劲儿。

所以,某种意义上你如何称呼它们很关键。

第二个参数很容易决定。几乎所有的文献都使用reject(..)做为它的名称,因为这正是它(唯一!)要做的,对于命名来说这是一个很好的选择。我也强烈推荐你一直使用reject(..)

但是关于第一个参数还是有些带有歧义,它在许多关于Promise的文献中常被标识为resolve(..)。这个词明显地是与“resolution(解析)”有关,它在所有的文献中(包括本书)广泛用于描述给Promise设定一个最终的值/状态。我们已经使用“解析Promise(resolve the Promise)”许多次来意味Promise的完成(fulfilling)或拒绝(rejecting)。

但是如果这个参数看起来被用于特指Promise的完成,为什么我们不更准确地叫它fulfill(..),而是用resolve(..)呢?要回答这个问题,让我们看一下Promise的两个API方法:

  1. var fulfilledPr = Promise.resolve( 42 );
  2. var rejectedPr = Promise.reject( "Oops" );

Promise.resolve(..)创建了一个Promise,它被解析为它被给予的值。在这个例子中,42是一个一般的,非Promise,非thenable的值,所以完成的promisefulfilledPr是为值42创建的。Promise.reject("Oops")为了原因"Oops"创建的拒绝的promiserejectedPr

现在让我们来解释为什么如果“resolve”这个词(正如Promise.resolve(..)里的)被明确用于一个既可能完成也可能拒绝的环境时,它没有歧义,反而更加准确:

  1. var rejectedTh = {
  2. then: function(resolved,rejected) {
  3. rejected( "Oops" );
  4. }
  5. };
  6. var rejectedPr = Promise.resolve( rejectedTh );

就像我们在本章前面讨论的,Promise.resolve(..)将会直接返回收到的纯粹的Promise,或者将收到的thenable展开。如果展开这个thenable之后是一个拒绝状态,那么从Promise.resolve(..)返回的Promise事实上是相同的拒绝状态。

所以对于这个API方法来说,Promise.resolve(..)是一个好的,准确的名称,因为它实际上既可以得到完成的结果,也可以得到拒绝的结果。

Promise(..)构造器的第一个回调参数既可以展开一个thenable(与Promise.resolve(..)相同),也可以展开一个Promise:

  1. var rejectedPr = new Promise( function(resolve,reject){
  2. // 用一个被拒绝的promise来解析这个promise
  3. resolve( Promise.reject( "Oops" ) );
  4. } );
  5. rejectedPr.then(
  6. function fulfilled(){
  7. // 永远不会跑到这里
  8. },
  9. function rejected(err){
  10. console.log( err ); // "Oops"
  11. }
  12. );

现在应当清楚了,对于Promise(..)构造器的第一个参数来说resolve(..)是一个合适的名称。

警告: 前面提到的reject(..) 不会resolve(..)那样进行展开。如果你向reject(..)传递一个Promise/thenable值,这个没有被碰过的值将作为拒绝的理由。一个后续的拒绝处理器将会受到你传递给reject(..)的实际的Promise/thenable,而不是它底层的立即值。

现在让我们将注意力转向提供给then(..)的回调。它们应当叫什么(在文献和代码中)?我的建议是fulfilled(..)rejected(..)

  1. function fulfilled(msg) {
  2. console.log( msg );
  3. }
  4. function rejected(err) {
  5. console.error( err );
  6. }
  7. p.then(
  8. fulfilled,
  9. rejected
  10. );

对于then(..)的第一个参数的情况,它没有歧义地总是完成状态,所以没有必要使用带有双重意义的“resolve”术语。另一方面,ES6语言规范中使用onFulfilled(..)onRejected(..) 来标识这两个回调,所以它们是准确的术语。