返回介绍

第一部分 类型和语法

第二部分 异步和性能

3.4 链式流

发布于 2023-05-24 16:38:21 字数 10148 浏览 0 评论 0 收藏 0

尽管我们之前对此有过几次暗示,但 Promise 并不只是一个单步执行 this-then-that 操作的机制。当然,那是构成部件,但是我们可以把多个 Promise 连接到一起以表示一系列异步步骤。

这种方式可以实现的关键在于以下两个 Promise 固有行为特性:

· 每次你对 Promise 调用 then(..) ,它都会创建并返回一个新的 Promise,我们可以将其链接起来;

· 不管从 then(..) 调用的完成回调(第一个参数)返回的值是什么,它都会被自动设置为被链接 Promise(第一点中的)的完成。

先来解释一下这是什么意思,然后推导一下其如何帮助我们创建流程控制异步序列。考虑如下代码:

var p = Promise.resolve( 21 );

var p2 = p.then( function(v){
  console.log( v );   // 21

  // 用值42填充p2
  return v * 2;
} );

// 连接p2
p2.then( function(v){
  console.log( v );   // 42
} );

我们通过返回 v * 2( 即 42) ,完成了第一个调用 then(..) 创建并返回的 promise p2 。p2 的 then(..) 调用在运行时会从 return v * 2 语句接受完成值。当然,p2.then(..) 又创建了另一个新的 promise,可以用变量 p3 存储。

但是,如果必须创建一个临时变量 p2 (或 p3 等),还是有一点麻烦的。谢天谢地,我们很容易把这些链接到一起:

var p = Promise.resolve( 21 );

p
.then( function(v){
  console.log( v );  // 21

  // 用值42完成连接的promise
  return v * 2;
} )
// 这里是链接的promise
.then( function(v){
  console.log( v );  // 42
} );

现在第一个 then(..) 就是异步序列中的第一步,第二个 then(..) 就是第二步。这可以一直任意扩展下去。只要保持把先前的 then(..) 连到自动创建的每一个 Promise 即可。

但这里还漏掉了一些东西。如果需要步骤 2 等待步骤 1 异步来完成一些事情怎么办?我们使用了立即返回 return 语句,这会立即完成链接的 promise。

使 Promise 序列真正能够在每一步有异步能力的关键是,回忆一下当传递给 Promise.resolve(..) 的是一个 Promise 或 thenable 而不是最终值时的运作方 式。Promise.resolve(..) 会直接返回接收到的真正 Promise,或展开接收到的 thenable 值,并在持续展开 thenable 的同时递归地前进。

从完成(或拒绝)处理函数返回 thenable 或者 Promise 的时候也会发生同样的展开。考虑:

var p = Promise.resolve( 21 );

p.then( function(v){
  console.log( v );     // 21

  // 创建一个promise并将其返回
  return new Promise( function(resolve,reject){
    // 用值42填充
    resolve( v * 2 );
  } );
} )
.then( function(v){
  console.log( v );     // 42
} );

虽然我们把 42 封装到了返回的 promise 中,但它仍然会被展开并最终成为链接的 promise 的决议,因此第二个 then(..) 得到的仍然是 42 。如果我们向封装的 promise 引入异步,一切都仍然会同样工作:

var p = Promise.resolve( 21 );

p.then( function(v){
  console.log( v );     // 21

  // 创建一个promise并返回
  return new Promise( function(resolve,reject){
    // 引入异步!
    setTimeout( function(){
      // 用值42填充
      resolve( v * 2 );
    }, 100 );
  } );

} )
.then( function(v){
  // 在前一步中的100ms延迟之后运行
  console.log( v );     // 42
} );

这种强大实在不可思议!现在我们可以构建这样一个序列:不管我们想要多少个异步步骤,每一步都能够根据需要等待下一步(或者不等!)。

当然,在这些例子中,一步步传递的值是可选的。如果不显式返回一个值,就会隐式返回 undefined ,并且这些 promise 仍然会以同样的方式链接在一起。这样,每个 Promise 的决议就成了继续下一个步骤的信号。

为了进一步阐释链接,让我们把延迟 Promise 创建(没有决议消息)过程一般化到一个工具中,以便在多个步骤中复用:

function delay(time) {
  return new Promise( function(resolve,reject){
    setTimeout( resolve, time );
  } );
}

delay( 100 ) // 步骤1
.then( function STEP2(){
  console.log( "step 2 (after 100ms)" );
  return delay( 200 );
} )
.then( function STEP3(){
  console.log( "step 3 (after another 200ms)" );
} )
.then( function STEP4(){
  console.log( "step 4 (next Job)" );
  return delay( 50 );
} )
.then( function STEP5(){
  console.log( "step 5 (after another 50ms)" );
} )
...

调用 delay(200) 创建了一个将在 200ms 后完成的 promise,然后我们从第一个 then(..) 完成回调中返回这个 promise,这会导致第二个 then(..) 的 promise 等待这个 200ms 的 promise。

如前所述,严格地说,这个交互过程中有两个 promise:200ms 延迟 promise,和第二个 then(..) 链接到的那个链接 promise。但是你可能已经发现了,在脑海中把这两个 promise 合二为一之后更好理解,因为 Promise 机制已经自动为你把它们的状态合并在了一起。这样一来,可以把 return delay(200) 看作是创建了一个 promise,并用其替换了前面返回的链接 promise。

但说实话,没有消息传递的延迟序列对于 Promise 流程控制来说并不是一个很有用的示例。我们来考虑如下这样一个更实际的场景。

这里不用定时器,而是构造 Ajax 请求:

// 假定工具ajax( {url}, {callback} )存在

// Promise-aware ajax
function request(url) {
  return new Promise( function(resolve,reject){
    // ajax(..)回调应该是我们这个promise的resolve(..)函数
    ajax( url, resolve );
  } );
}

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

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

开发者常会遇到这样的情况:他们想要通过本身并不支持 Promise 的工具(就像这里的 ajax(..) ,它接收的是一个回调)实现支持 Promise 的异步流程控制。虽然原生 ES6 Promise 机制并不会自动为我们提供这个模式,但所有实际的 Promise 库都会提供。通常它们把这个过程称为“提升”“promise 化”或者其他类似的名称。我们稍后会再介绍这种技术。

利用返回 Promise 的 request(..) ,我们通过使用第一个 URL 调用它来创建链接中的第一步,并且把返回的 promise 与第一个 then(..) 链接起来。

response1 一返回,我们就使用这个值构造第二个 URL,并发出第二个 request(..) 调用。第二个 request(..) 的 promise 返回,以便异步流控制中的第三步等待这个 Ajax 调用完成。最后,response2 一返回,我们就立即打出结果。

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

如果这个 Promise 链中的某个步骤出错了怎么办?错误和异常是基于每个 Promise 的,这意味着可能在链的任意位置捕捉到这样的错误,而这个捕捉动作在某种程度上就相当于在这一位置将整条链“重置”回了正常运作:

// 步骤1:
request( "http://some.url.1/" )

// 步骤2:
.then( function(response1){
  foo.bar(); // undefined,出错!

  // 永远不会到达这里
  return request( "http://some.url.2/?v=" + response1 );
} )

// 步骤3:
.then(
  function fulfilled(response2){
    // 永远不会到达这里
  },

  // 捕捉错误的拒绝处理函数
  function rejected(err){
    console.log( err );
    // 来自foo.bar()的错误TypeError
    return 42;
  }
)
// 步骤4:
.then( function(msg){
  console.log( msg );      // 42
} );

第 2 步出错后,第 3 步的拒绝处理函数会捕捉到这个错误。拒绝处理函数的返回值(这段代码中是 42 ),如果有的话,会用来完成交给下一个步骤(第 4 步)的 promise,这样,这个链现在就回到了完成状态。

正如之前讨论过的,当从完成处理函数返回一个 promise 时,它会被展开并有可能延迟下一个步骤。从拒绝处理函数返回 promise 也是如此,因此如果在第 3 步返回的不是 42 而是一个 promise 的话,这个 promise 可能会延迟第 4 步。调用 then(..) 时的完成处理函数或拒绝处理函数如果抛出异常,都会导致(链中的)下一个 promise 因这个异常而立即被拒绝。

如果你调用 promise 的 then(..) ,并且只传入一个完成处理函数,一个默认拒绝处理函数就会顶替上来:

var p = new Promise( function(resolve,reject){
  reject( "Oops" );
} );

var p2 = p.then(
  function fulfilled(){
    // 永远不会达到这里
  }
  // 假定的拒绝处理函数,如果省略或者传入任何非函数值
  // function(err) {
  //   throw err;
  // }
);

如你所见,默认拒绝处理函数只是把错误重新抛出,这最终会使得 p2 (链接的 promise)用同样的错误理由拒绝。从本质上说,这使得错误可以继续沿着 Promise 链传播下去,直到遇到显式定义的拒绝处理函数。

稍后我们会介绍关于 Promise 错误处理的更多细节,因为还有其他一些微妙的细节需要考虑。

如果没有给 then(..) 传递一个适当有效的函数作为完成处理函数参数,还是会有作为替代的一个默认处理函数:

var p = Promise.resolve( 42 );

p.then(
  // 假设的完成处理函数,如果省略或者传入任何非函数值
  // function(v) {
  //     return v;
  // }
  null,
  function rejected(err){
    // 永远不会到达这里
  }
);

你可以看 到,默认的完成处理函数只是把接收到的任何传入值传递给下一个步骤(Promise)而已。

then(null,function(err){ .. }) 这个模式——只处理拒绝(如果有的话),但又把完成值传递下去——有一个缩写形式的 API:catch(function(err){ .. }) 。下一小节会详细介绍 catch(..) 。

让我们来简单总结一下使链式流程控制可行的 Promise 固有特性。

· 调用 Promise 的 then(..) 会自动创建一个新的 Promise 从调用返回。

· 在完成或拒绝处理函数内部,如果返回一个值或抛出一个异常,新返回的(可链接的)Promise 就相应地决议。

· 如果完成或拒绝处理函数返回一个 Promise,它将会被展开,这样一来,不管它的决议值是什么,都会成为当前 then(..) 返回的链接 Promise 的决议值。

尽管链式流程控制是有用的,但是对其最精确的看法是把它看作 Promise 组合到一起的一个附加益处,而不是主要目的。正如前面已经多次深入讨论的,Promise 规范化了异步,并封装了时间相关值的状态,使得我们能够把它们以这种有用的方式链接到一起。

当然,相对于第 2 章讨论的回调的一团乱麻,链接的顺序表达(this-then-this-then-this...)已经是一个巨大的进步。但是,仍然有大量的重复样板代码(then(..) 以及 function(){ ... } )。在第 4 章,我们将会看到在顺序流程控制表达方面提升巨大的优美模式,通过生成器实现。

术语:决议、完成以及拒绝

对于术语决议 (resolve)、完成 (fulfill)和拒绝 (reject),在更深入学习 Promise 之前,我们还有一些模糊之处需要澄清。先来研究一下构造器 Promise(..) :

var p = new Promise( function(X,Y){
  // X()用于完成
  // Y()用于拒绝
} );

你可以看到,这里提供了两个回调(称为 X 和 Y )。第一个通常用于标识 Promise 已经完成,第二个总是用于标识 Promise 被拒绝。这个“通常”是什么意思呢?对于这些参数的精确命名,这又意味着什么呢?

追根究底,这只是你的用户代码和标识符名称,对引擎而言没有意义。所以从技术上说,这无关紧要,foo(..) 或者 bar(..) 还是同样的函数。但是,你使用的文字不只会影响你对这些代码的看法,也会影响团队其他开发者对代码的认识。错误理解精心组织起来的异步代码还不如使用一团乱麻的回调函数。

所以事实上,命名还是有一定的重要性的。

第二个参数名称很容易决定。几乎所有的文献都将其命名为 reject(..) ,因为这就是它真实的(也是唯一的!)工作,所以这样的名字是很好的选择。我强烈建议大家要一直使用 reject(..) 这一名称。

但是,第一个参数就有一些模糊了,Promise 文献通常将其称为 resolve(..) 。这个词显然和决议(resolution)有关,而决议在各种文献(包括本书)中是用来描述“为 Promise 设定最终值 / 状态”。前面我们已经多次使用“Promise 决议”来表示完成或拒绝 Promise。

但是,如果这个参数是用来特指完成这个 Promise,那为什么不用使用 fulfill(..) 来代替 resolve(..) 以求表达更精确呢?要回答这个问题,我们先来看看两个 Promise API 方法:

var fulfilledPr = Promise.resolve( 42 );

var rejectedPr = Promise.reject( "Oops" );

Promise.resolve(..) 创建了一个决议为输入值的 Promise。在这个例子中,42 是一个非 Promise、非 thenable 的普通值,所以完成后的 promise fullfilledPr 是为值 42 创建的。Promise.reject("Oops") 创建了一个被拒绝的 promise rejectedPr ,拒绝理由为 "Oops" 。

现在我们来解释为什么单词 resolve(比如在 Promise.resolve(..) 中)如果用于表达结果可能是完成也可能是拒绝的话,既没有歧义,而且也确实更精确:

var rejectedTh = {
  then: function(resolved,rejected) {
    rejected( "Oops" );
  }
};

var rejectedPr = Promise.resolve( rejectedTh );

本章前面已经介绍 过,Promise.resolve(..) 会将传入的真正 Promise 直接返回,对传入的 thenable 则会展 开。如果这个 thenable 展开得到一个拒绝状态,那么从 Promise.resolve(..) 返回的 Promise 实际上就是这同一个拒绝状态。

所以对这个 API 方法来说,Promise.resolve(..) 是一个精确的好名字,因为它实际上的结果可能是完成或拒绝。

Promise(..) 构造器的第一个参数回调会展开 thenable(和 Promise.resolve(..) 一样)或真正的 Promise:

var rejectedPr = new Promise( function(resolve,reject){
  // 用一个被拒绝的promise完成这个promise
  resolve( Promise.reject( "Oops" ) );
} );

rejectedPr.then(
  function fulfilled(){
    // 永远不会到达这里
  },
  function rejected(err){
    console.log( err ); // "Oops"
  }
);

现在应该很清楚了,Promise(..) 构造器的第一个回调参数的恰当称谓是 resolve(..) 。

前面提到的 reject(..) 不会像 resolve(..) 一样进行展开。如果向 reject(..) 传入一个 Promise/thenable 值,它会把这个值原封不动地设置为拒绝理由。后续的拒绝处理函数接收到的是你实际传给 reject(..) 的那个 Promise/thenable,而不是其底层的立即值。

不过,现在我们再来关注一下提供给 then(..) 的回调。它们(在文献和代码中)应该怎么命名呢?我的建议是 fulfilled(..) 和 rejected(..) :

function fulfilled(msg) {
  console.log( msg );
}

function rejected(err) {
  console.error( err );
}

p.then(
  fulfilled,
  rejected
);

对 then(..) 的第一个参数来说,毫无疑义,总是处理完成的情况,所以不需要使用标识两种状态的术语“resolve”。这里提一下,ES6 规范将这两个回调命名为 onFulfilled(..) 和 onRjected(..) ,所以这两个术语很准确。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文