3.4 链式流
尽管我们之前对此有过几次暗示,但 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 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论