第三章: Promise - Promise 的限制

优质
小牛编辑
132浏览
2023-12-01

Promise的限制

本节中我们将要讨论的许多细节已经在这一章中被提及了,但我们将明确地复习这些限制。

顺序的错误处理

我们在本章前面的部分详细讲解了Promise风格的错误处理。Promise的设计方式——特别是他们如何链接——所产生的限制,创建了一个非常容易掉进去的陷阱,Promise链中的错误会被意外地无声地忽略掉。

但关于Promise的错误还有一些其他事情要考虑。因为Promise链只不过是将组成它的Promise连在一起,没有一个实体可以用来将整个链条表达为一个单独的 东西,这意味着没有外部的方法能够监听可能发生的任何错误。

如果你构建一个不包含错误处理器的Promise链,这个链条的任意位置发生的任何错误都将沿着链条向下无限传播,直到被监听为止(通过在某一步上注册拒绝处理器)。所以,在这种特定情况下,拥有链条的最后一个promise的引用就够了(下面代码段中的p),因为你可以在这里注册拒绝处理器,而且它会被所有传播的错误通知:

  1. // `foo(..)`, `STEP2(..)` 和 `STEP3(..)`
  2. // 都是promise兼容的工具
  3. var p = foo( 42 )
  4. .then( STEP2 )
  5. .then( STEP3 );

虽然这看起来有点儿小糊涂,但是这里的p没有指向链条中的第一个promise(foo(42)调用中来的那一个),而是指向了最后一个promise,来自于then(STEP3)调用的那一个。

另外,这个promise链条上看不到一个步骤做了自己的错误处理。这意味着你可以在p上注册一个拒绝处理器,如果在链条的任意位置发生了错误,它就会被通知。

  1. p.catch( handleErrors );

但如果这个链条中的某一步事实上做了自己的错误处理(也许是隐藏/抽象出去了,所以你看不到),那么你的handleErrors(..)就不会被通知。这可能是你想要的——它毕竟是一个“被处理过的拒绝”——但它也可能 是你想要的。完全缺乏被通知的能力(被“已处理过的”拒绝错误通知)是一个在某些用法中约束功能的一种限制。

它基本上和try..catch中存在的限制是相同的,它可以捕获一个异常并简单地吞掉。所以这不是一个 Promise特有 的问题,但它确实是一个我们希望绕过的限制。

不幸的是,许多时候Promise链序列的中间步骤不会被留下引用,所以没有这些引用,你就不能添加错误处理器来可靠地监听错误。

单独的值

根据定义,Promise只能有一个单独的完成值或一个单独的拒绝理由。在简单的例子中,这没什么大不了的,但在更精巧的场景下,你可能发现这个限制。

通常的建议是构建一个包装值(比如objectarray)来包含这些多个消息。这个方法好用,但是在你的Promise链的每一步上把消息包装再拆开显得十分尴尬和烦人。

分割值

有时你可以将这种情况当做一个信号,表示你可以/应当将问题拆分为两个或更多的Promise。

想象你有一个工具foo(..),它异步地产生两个值(xy):

  1. function getY(x) {
  2. return new Promise( function(resolve,reject){
  3. setTimeout( function(){
  4. resolve( (3 * x) - 1 );
  5. }, 100 );
  6. } );
  7. }
  8. function foo(bar,baz) {
  9. var x = bar * baz;
  10. return getY( x )
  11. .then( function(y){
  12. // 将两个值包装近一个容器
  13. return [x,y];
  14. } );
  15. }
  16. foo( 10, 20 )
  17. .then( function(msgs){
  18. var x = msgs[0];
  19. var y = msgs[1];
  20. console.log( x, y ); // 200 599
  21. } );

首先,让我们重新安排一下foo(..)返回的东西,以便于我们不必再将xy包装进一个单独的array值中来传送给一个Promise。相反,我们将每一个值包装进它自己的promise:

  1. function foo(bar,baz) {
  2. var x = bar * baz;
  3. // 将两个promise返回
  4. return [
  5. Promise.resolve( x ),
  6. getY( x )
  7. ];
  8. }
  9. Promise.all(
  10. foo( 10, 20 )
  11. )
  12. .then( function(msgs){
  13. var x = msgs[0];
  14. var y = msgs[1];
  15. console.log( x, y );
  16. } );

一个promise的array真的要比传递给一个单独的Promise的值的array要好吗?语法上,它没有太多改进。

但是这种方式更加接近于Promise的设计原理。现在它更易于在未来将xy的计算分开,重构进两个分离的函数中。它更清晰,也允许调用端代码更灵活地安排这两个promise——这里使用了Promise.all([ .. ]),但它当然不是唯一的选择——而不是将这样的细节在foo(..)内部进行抽象。

展开/散开参数

var x = ..var y = ..的赋值依然是一个尴尬的负担。我们可以在一个帮助工具中利用一些函数式技巧(向Reginald Braithwaite致敬,在推特上 @raganwald ):

  1. function spread(fn) {
  2. return Function.apply.bind( fn, null );
  3. }
  4. Promise.all(
  5. foo( 10, 20 )
  6. )
  7. .then(
  8. spread( function(x,y){
  9. console.log( x, y ); // 200 599
  10. } )
  11. )

看起来好些了!当然,你可以内联这个函数式魔法来避免额外的帮助函数:

  1. Promise.all(
  2. foo( 10, 20 )
  3. )
  4. .then( Function.apply.bind(
  5. function(x,y){
  6. console.log( x, y ); // 200 599
  7. },
  8. null
  9. ) );

这个技巧可能很整洁,但是ES6给了我们一个更好的答案:解构(destructuring)。数组的解构赋值形式看起来像这样:

  1. Promise.all(
  2. foo( 10, 20 )
  3. )
  4. .then( function(msgs){
  5. var [x,y] = msgs;
  6. console.log( x, y ); // 200 599
  7. } );

最棒的是,ES6提供了数组参数解构形式:

  1. Promise.all(
  2. foo( 10, 20 )
  3. )
  4. .then( function([x,y]){
  5. console.log( x, y ); // 200 599
  6. } );

我们现在已经接受了“每个Promise一个值”的准则,继续让我们把模板代码最小化!

注意: 更多关于ES6解构形式的信息,参阅本系列的 ES6与未来

单次解析

Promise的一个最固有的行为之一就是,一个Promise只能被解析一次(成功或拒绝)。对于多数异步用例来说,你仅仅取用这个值一次,所以这工作的很好。

但也有许多异步情况适用于一个不同的模型——更类似于事件和/或数据流。表面上看不清Promise能对这种用例适应的多好,如果能的话。没有基于Promise的重大抽象过程,它们完全缺乏对多个值解析的处理。

想象这样一个场景,你可能想要为响应一个刺激(比如事件)触发一系列异步处理步骤,而这实际上将会发生多次,比如按钮点击。

这可能不会像你想的那样工作:

  1. // `click(..)` 绑定了一个DOM元素的 `"click"` 事件
  2. // `request(..)` 是先前定义的支持Promise的Ajax
  3. var p = new Promise( function(resolve,reject){
  4. click( "#mybtn", resolve );
  5. } );
  6. p.then( function(evt){
  7. var btnID = evt.currentTarget.id;
  8. return request( "http://some.url.1/?id=" + btnID );
  9. } )
  10. .then( function(text){
  11. console.log( text );
  12. } );

这里的行为仅能在你的应用程序只让按钮被点击一次的情况下工作。如果按钮被点击第二次,promisep已经被解析了,所以第二个resolve(..)将被忽略。

相反的,你可能需要将模式反过来,在每次事件触发时创建一个全新的Promise链:

  1. click( "#mybtn", function(evt){
  2. var btnID = evt.currentTarget.id;
  3. request( "http://some.url.1/?id=" + btnID )
  4. .then( function(text){
  5. console.log( text );
  6. } );
  7. } );

这种方式会 好用,为每个按钮上的"click"事件发起一个全新的Promise序列。

但是除了在事件处理器内部定义一整套Promise链看起来很丑以外,这样的设计在某种意义上违背了关注/能力分离原则(SoC)。你可能非常想在一个你的代码不同的地方定义事件处理器:你定义对事件的 响应(Promise链)的地方。如果没有帮助机制,在这种模式下这么做很尴尬。

注意: 这种限制的另一种表述方法是,如果我们能够构建某种能在它上面进行Promise链监听的“可监听对象(observable)”就好了。有一些库已经建立这些抽象(比如RxJS——http://rxjs.codeplex.com/),但是这种抽象看起来是如此的重,以至于你甚至再也看不到Promise的性质。这样的重抽象带来一个重要的问题:这些机制是否像Promise本身被设计的一样 可靠。我们将会在附录B中重新讨论“观察者(Observable)”模式。

惰性

对于在你的代码中使用Promise而言一个实在的壁垒是,现存的所有代码都没有支持Promise。如果你有许多基于回调的代码,让代码保持相同的风格容易多了。

“一段基于动作(用回调)的代码将仍然基于动作(用回调),除非一个更聪明,具有Promise意识的开发者对它采取行动。”

Promise提供了一种不同的模式规范,如此,代码的表达方式可能会变得有一点儿不同,某些情况下,则根本不同。你不得不有意这么做,因为Promise不仅只是把那些为你服务至今的老式编码方法自然地抖落掉。

考虑一个像这样的基于回调的场景:

  1. function foo(x,y,cb) {
  2. ajax(
  3. "http://some.url.1/?x=" + x + "&y=" + y,
  4. cb
  5. );
  6. }
  7. foo( 11, 31, function(err,text) {
  8. if (err) {
  9. console.error( err );
  10. }
  11. else {
  12. console.log( text );
  13. }
  14. } );

将这个基于回调的代码转换为支持Promise的代码的第一步该怎么做,是立即明确的吗?这要看你的经验。你练习的越多,它就感觉越自然。但当然,Promise没有明确告知到底怎么做——没有一个放之四海而皆准的答案——所以这要靠你的责任心。

就像我们以前讲过的,我们绝对需要一种支持Promise的Ajax工具来取代基于回调的工具,我们可以称它为request(..)。你可以制造自己的,正如我们已经做过的。但是不得不为每个基于回调的工具手动定义Promise相关的包装器的负担,使得你根本就不太可能选择将代码重构为Promise相关的。

Promise没有为这种限制提供直接的答案。但是大多数Promise库确实提供了帮助函数。想象一个这样的帮助函数:

  1. // 填补的安全检查
  2. if (!Promise.wrap) {
  3. Promise.wrap = function(fn) {
  4. return function() {
  5. var args = [].slice.call( arguments );
  6. return new Promise( function(resolve,reject){
  7. fn.apply(
  8. null,
  9. args.concat( function(err,v){
  10. if (err) {
  11. reject( err );
  12. }
  13. else {
  14. resolve( v );
  15. }
  16. } )
  17. );
  18. } );
  19. };
  20. };
  21. }

好吧,这可不是一个微不足道的工具。然而,虽然他可能看起来有点儿令人生畏,但也没有你想的那么糟。它接收一个函数,这个函数期望一个错误优先风格的回调作为第一个参数,然后返回一个可以自动创建Promise并返回的新函数,然后为你替换掉回调,与Promise的完成/拒绝连接在一起。

与其浪费太多时间谈论这个Promise.wrap(..)帮助函数 如何 工作,还不如让我们来看看如何使用它:

  1. var request = Promise.wrap( ajax );
  2. request( "http://some.url.1/" )
  3. .then( .. )
  4. ..

哇哦,真简单!

Promise.wrap(..) 不会 生产Promise。它生产一个将会生产Promise的函数。某种意义上,一个Promise生产函数可以被看做一个“Promise工厂”。我提议将这样的东西命名为“promisory”(”Promise” + “factory”)。

这种将期望回调的函数包装为一个Promise相关的函数的行为,有时被称为“提升(lifting)”或“promise化(promisifying)”。但是除了“提升过的函数”以外,看起来没有一个标准的名词来称呼这个结果函数,所以我更喜欢“promisory”,因为我认为他更具描述性。

注意: Promisory不是一个瞎编的词。它是一个真实存在的词汇,而且它的定义是含有或载有一个promise。这正是这些函数所做的,所以这个术语匹配得简直完美!

那么,Promise.wrap(ajax)生产了一个我们称为request(..)ajax(..)promisory,而这个promisory为Ajax应答生产Promise。

如果所有的函数已经都是promisory,我们就不需要自己制造它们,所以额外的步骤就有点儿多余。但是至少包装模式是(通常都是)可重复的,所以我们可以把它放进Promise.wrap(..)帮助函数中来支援我们的promise编码。

那么回到刚才的例子,我们需要为ajax(..)foo(..)都做一个promisory。

  1. // 为`ajax(..)`制造一个promisory
  2. var request = Promise.wrap( ajax );
  3. // 重构`foo(..)`,但是为了代码其他部分
  4. // 的兼容性暂且保持它对外是基于回调的
  5. // ——仅在内部使用`request(..)`'的promise
  6. function foo(x,y,cb) {
  7. request(
  8. "http://some.url.1/?x=" + x + "&y=" + y
  9. )
  10. .then(
  11. function fulfilled(text){
  12. cb( null, text );
  13. },
  14. cb
  15. );
  16. }
  17. // 现在,为了这段代码本来的目的,为`foo(..)`制造一个promisory
  18. var betterFoo = Promise.wrap( foo );
  19. // 并使用这个promisory
  20. betterFoo( 11, 31 )
  21. .then(
  22. function fulfilled(text){
  23. console.log( text );
  24. },
  25. function rejected(err){
  26. console.error( err );
  27. }
  28. );

当然,虽然我们将foo(..)重构为使用我们的新request(..)promisory,我们可以将foo(..)本身制成promisory,而不是保留基于会掉的实现并需要制造和使用后续的betterFoo(..)promisory。这个决定只是要看foo(..)是否需要保持基于回调的形式以便于代码的其他部分兼容。

考虑这段代码:

  1. // 现在,`foo(..)`也是一个promisory
  2. // 因为它委托到`request(..)` promisory
  3. function foo(x,y) {
  4. return request(
  5. "http://some.url.1/?x=" + x + "&y=" + y
  6. );
  7. }
  8. foo( 11, 31 )
  9. .then( .. )
  10. ..

虽然ES6的Promise没有为这样的promisory包装提供原生的帮助函数,但是大多数库提供它们,或者你可以制造自己的。不管哪种方法,这种Promise特定的限制是可以不费太多劲儿就可以解决的(当然是和回调地狱的痛苦相比!)。

Promise不可撤销

一旦你创建了一个Promise并给它注册了一个完成和/或拒绝处理器,就没有什么你可以从外部做的事情能停止这个进程,即使是某些其他的事情使这个任务变得毫无意义。

注意: 许多Promise抽象库都提供取消Promise的功能,但这是一个非常坏的主意!许多开发者都希望Promise被原生地设计为具有外部取消能力,但问题是这将允许Promise的一个消费者/监听器影响某些其他消费者监听同一个Promise的能力。这违反了未来值得可靠性原则(外部不可变),另外就是嵌入了“远距离行为(action at a distance)”的反模式(http://en.wikipedia.org/wiki/Action_at_a_distance_%28computer_programming%29)。不管它看起来多么有用,它实际上会直接将你引回与回调地狱相同的噩梦。

考虑我们早先的Promise超时场景:

  1. var p = foo( 42 );
  2. Promise.race( [
  3. p,
  4. timeoutPromise( 3000 )
  5. ] )
  6. .then(
  7. doSomething,
  8. handleError
  9. );
  10. p.then( function(){
  11. // 即使是在超时的情况下也会发生 :(
  12. } );

“超时”对于promisep来说是外部的,所以p本身继续运行,这可能不是我们想要的。

一个选项是侵入性地定义你的解析回调:

  1. var OK = true;
  2. var p = foo( 42 );
  3. Promise.race( [
  4. p,
  5. timeoutPromise( 3000 )
  6. .catch( function(err){
  7. OK = false;
  8. throw err;
  9. } )
  10. ] )
  11. .then(
  12. doSomething,
  13. handleError
  14. );
  15. p.then( function(){
  16. if (OK) {
  17. // 仅在没有超时的情况下发生! :)
  18. }
  19. } );

很难看。这可以工作,但是远不理想。一般来说,你应当避免这样的场景。

但是如果你不能,这种解决方案的丑陋应当是一个线索,说明 取消 是一种属于在Promise之上的更高层抽象的功能。我推荐你找一个Promise抽象库来辅助你,而不是自己使用黑科技。

注意: 我的 asynquence Promise抽象库提供了这样的抽象,还为序列提供了一个abort()能力,这一切将在附录A中讨论。

一个单独的Promise不是真正的流程控制机制(至少没有多大实际意义),而流程控制机制正是 取消 要表达的;这就是为什么Promise取消显得尴尬。

相比之下,一个链条的Promise集合在一起——我称之为“序列”—— 一个流程控制的表达,如此在这一层面的抽象上它就适于定义取消。

没有一个单独的Promise应该是可以取消的,但是一个 序列 可以取消是有道理的,因为你不会将一个序列作为一个不可变值传来传去,就像Promise那样。

Promise性能

这种限制既简单又复杂。

比较一下在基于回调的异步任务链和Promise链上有多少东西在动,很明显Promise有多得多的事情发生,这意味着它们自然地会更慢一点点。回想一下Promise提供的保证信任的简单列表,将它和你为了达到相同保护效果而在回调上面添加的特殊代码比较一下。

更多工作要做,更多的安全要保护,意味着Promise与赤裸裸的,不可靠的回调相比 确实 更慢。这些都很明显,可能很容易萦绕在你脑海中。

但是慢多少?好吧……这实际上是一个难到不可思议的问题,无法绝对,全面地回答。

坦白地说,这是一个比较苹果和橘子的问题,所以可能是问错了。你实际上应当比较的是,带有所有手动保护层的经过特殊处理的回调系统,是否比一个Promise实现要快。

如果说Promise有一种合理的性能限制,那就是它并不将可靠性保护的选项罗列出来让你选择——你总是一下得到全部。

如果我们承认Promise一般来说要比它的非Promise,不可靠的回调等价物 慢一点儿——假定在有些地方你觉得你可以自己调整可靠性的缺失——难道这意味着Promise应当被全面地避免,就好像你的整个应用程序仅仅由一些可能的“必须绝对最快”的代码驱动着?

扪心自问:如果你的代码有那么合理,那么 对于这样的任务,JavaScript是正确的选择吗? 为了运行应用程序JavaScript可以被优化得十分高效(参见第五章和第六章)。但是在Promise提供的所有好处的光辉之下,过于沉迷它微小的性能权衡,真的 合适吗?

另一个微妙的问题是Promise使 所有事情 都成为异步的,这意味着有些应当立即完成的(同步的)步骤也要推迟到下一个Job步骤中(参见第一章)。也就是说一个Promise任务序列要比使用回调连接的相同序列要完成的稍微慢一些是可能的。

当然,这里的问题是:这些关于性能的微小零头的潜在疏忽,和我们在本章通篇阐述的Promise带来的益处相比,还值得考虑吗?

我的观点是,在几乎所有你可能认为Promise的性能慢到了需要被考虑的情况下,完全回避Promise并将它的可靠性和组合性优化掉,实际上是一种反模式。

相反地,你应当默认地在代码中广泛使用它们,然后再记录并分析你的应用程序的热(关键)路径。Promise 真的 是瓶颈?还是它们只是理论上慢了下来?只有在那 之后,拿着实际合法的基准分析观测数据(参见第六章),再将Promise从这些关键区域中重构移除才称得上是合理与谨慎。

Promise是有一点儿慢,但作为交换你得到了很多内建的可靠性,无Zalgo的可预测性,与组合性。也许真正的限制不是它们的性能,而是你对它们的益处缺乏认识?