你没有抓住Promises的要点

你没有抓住Promises的要点注:这篇文章翻译自《You're Missing the Point of Promises》,阅读这篇文章,你首先需要对于JavaScript中的Promises是什么有了解,否则,你可以先看一看这篇文章(英文),或者这篇文章(中文)掌握基础。有一些修改,另受水平所限,翻译的不当之处请参阅原文。

Promises是一种令代码异步行为更加优雅的抽象。如果用最基本的编码方式,代码是这种连续的形式:

getTweetsFor("domenic", function (err, results) {
    // the rest of your code goes here.
});

现在这样的方法返回一个被称作promise的值,它表示的是一个操作的最终执行结果。

var promiseForTweets = getTweetsFor("domenic");

这个就很有用了,因为你可以把promise当做一等公民来对待了:传值给他,聚合对它们的调用等等,而不是搞一堆耦合在一起的回调函数来完成你的逻辑。

我已经讲过了promises有多酷,所以我现在不说这个了,我现在要说的是一个现今JavaScript库中非常令人不安的趋势:声称支持promise,却根本没有抓住它的要点。

 

Then方法和CommonJS的Promises/A规范

如果有人说promise是JavaScript的上下文,那么他至少指的是CommonJS的Promises/A规范。这大概是我见过的最简陋的规范了,基本上只是对于这一类函数的行为做了简单说明:

promise是一种以函数来作为then属性值的对象:

then(fulfilledHandler, errorHandler, progressHandler)

添加fulfilledHandler、errorHandler和progressHandler后,promise对象就构成了。fulfilledHandler是在promise被装载数据的时候调用,errorHandler在promise失败的时候调用,progressHandler则在progress事件触发的时候调用。所有的参数都是可选的,并且非function的参数都会被忽略掉。有时progressHandler并不只是一个可选参数,但是progress事件确是纯粹的可选参数而已。promise模式的实现者并不一定要每次都调用progressHandler(因为它可以被忽略掉),只有这个参数传入的时候才会发生调用。

这个方法在fulfilledHandler或者errorHandler回调完成之后,得返回一个新的promise对象。这样一来,promise操作就可以形成链式调用。回调handler的返回值是一个promise对象。如果回调抛出异常,这个返回的promise对象就会把状态设为失败。

人们一般都理解第一段话,基本上可以归结为回调函数的聚合。

通过then方法来关联起回调函数和promise对象,不管是成功、失败还是进行中。当promise对象改变状态时(这超出了这篇短小文档讨论的范围),回调函数会被执行,我觉得这很有用。

但是人们不怎么理解的第二段,恰恰是最重要的。

 

那么Promises的要点是啥?

最重要的是,promises根本就不是简单的回调函数聚合。promises并不是那么简单的东西,它是一种为同步函数和异步函数提供直接一致性的模式。

啥意思呢?我们先来看同步函数两个非常重要的特性:

  • 它们都有返回值
  • 它们都可以有异常抛出

这两个都是必不可少的。你可以把一个函数的返回值作为参数传给下一个函数,再把下一个函数的返回值作为参数传给下下个,一直重复下去。现在,如果中间出现失败的情况,那个函数的链会抛出异常,异常会向上传播,直到有人可以来处理它为止。

在异步编程的世界里,你没法“返回”一个值了,它没法被及时地读取到。相似的,你也没法抛出异常了,因为没有人回去捕获它。所以我们踏入了“回调的地狱”,返回值嵌套了回调,错误需要手动传给原有的调用链,这样你就得引入类似于像domain这样疯狂的东西了。

下面四火对domain做一个小的说明:

异步编程中,你没法简单地通过try-catch来处理异常:

try {
  process.nextTick(function () {
    // do something
  });
} catch (err) {
  //you can not catch it
}

所以Node.js给的使用domain的解决方法是:

var doo = domain.create();
// listen to error event
doo.on('error', function (err) {
  // you got an error
});

当然,这个方法并不完美,还是会存在堆栈丢失等问题。

promises现在需要给我们异步世界里的函数组成和错误冒泡机制。现在假使你的函数要返回一个promise对象,它包含两种情况:

  • 被某个数据装载(fulfill)
  • 被某个异常的抛出中断了

如果你正确遵照Promises/A规范实现,fulfillment或者rejection部分的代码就像同步代码的副本一样,在整个调用链中,fulfillment部分会执行,也会在某个时候被rejection中断,但是只有预先声明了的handler才能处理它。

换言之,下面这段代码:

getTweetsFor("domenic") // promise-returning function
  .then(function (tweets) {
    var shortUrls = parseTweetsForUrls(tweets);
    var mostRecentShortUrl = shortUrls[0];
    return expandUrlUsingTwitterApi(mostRecentShortUrl); // promise-returning function
  })
  .then(httpGet) // promise-returning function
  .then(
    function (responseBody) {
      console.log("Most recent link text:", responseBody);
    },
    function (error) {
      console.error("Error with the twitterverse:", error);
    }
  );

相当于这样的同步代码:

try {
  var tweets = getTweetsFor("domenic"); // blocking
  var shortUrls = parseTweetsForUrls(tweets);
  var mostRecentShortUrl = shortUrls[0];
  var responseBody = httpGet(expandUrlUsingTwitterApi(mostRecentShortUrl)); // blocking x 2
  console.log("Most recent link text:", responseBody);
} catch (error) {
  console.error("Error with the twitterverse: ", error);
}

不管错误怎样发生,都必须要有显式的错误捕获处理机制。在将要到来的ECMAScript 6的版本中,使用了一些内部技巧,大多数情况下代码还是一样的。

 

第二段话

第二段话其实是完全有必要的:

这个方法在fulfilledHandler或者errorHandler回调完成之后,得返回一个新的promise对象。这样一来,promise操 作就可以形成链式调用。回调handler的返回值是一个promise对象。如果回调抛出异常,这个返回的promise对象就会把状态设为失败。

换言之,then方法并没有一个机制去把一堆回调方法附着到某个集合中去,它的机制只不过是把原有对象转换成promise对象,以及生成新的promise对象。

这就解释了第一段的关键:函数应当返回一个新的promise对象。JQuery(1.8以前的版本)却不这么做。他们只是继续使用原有的promise对象,但是把它的状态改变一下而已。这就意味着如果你把promise对象给客户了,他们其实是可以可以改变它的状态的。为了说明这一点有多荒谬,你可以想一想一个同步的例子:如果你把一个函数的返回值给了两个人,其中一个可以改变一下返回值里面的东西,然后这两个人手里的返回值居然就抛出异常来了!事实上,Promises/A规范其实已经说明了这一点:

一旦promise装载数据完成或者失败了,promise的值就不可以再改变了,就像JavaScript中的数值、原语类型、对象ID等等,都是不可以被改变的。

现在考虑其中的最后两句话,它们说出了promise是怎样被创建的:

  • 如果handler返回了一个值,那么新的promise就要装载那个值。
  • 如果handler抛出异常,那么新的promise就要用一个异常来表示拒绝继续往后执行。

我们根据promise的不同状态把这个场景分解一下,就可以知道为什么这几句话那么重要了:

  • 数据装填完成,fulfillment handler返回了一个值值:简单的函数转换
  • 数据装填完成,但是fulfillment handler抛出了异常:获取数据,然后再抛出异常
  • 数据装填失败,rejection handler返回了一个值:必须得用一个catch子句捕获异常并处理
  • 数据装填失败,但是rejection handler抛出了异常:必须得用一个catch子句捕获并重新抛出(可以重新抛出一个新的异常)

如果没有这些,你就失去了同步/异步并行处理的威力,那么你的所谓的“promises”也就变成了简单的回调函数聚合而已了。这也是JQuery当前对promises的实现的问题所在,它只实现了上面说的第一个场景而已。这也是Node.js 0.1中基于EventEmitter的promise的问题之一。

更进一步说,捕获异常并转换状态,我们需要处理预期和非预期的异常,这和写同步代码没什么区别。如果你在某个handler里面写一个叫做aFunctionThatDoesNotExist()的函数,你的promise对象失败以后会抛出异常,接着你的异常向上冒泡,外面最近的一个rejection handler会处理它,这看起来就像你在那里手写了new Error("bad data")一样。看吧,没有domain。

 

那又如何

也许你现在被我这样一波一波的解释感到压力陡增,想不明白为什么我会对那些写出这些糟糕行为的类库那么恼火。

现在我告诉你为什么:

promise对象是一个被定义为拥有一个then方法的返回值的对象。

对于Promises/A规范实现类库的作者,我们必须做到:凡是写出then方法这样机制的promise,都得去完全地符合Promises/A规范。

如果你也认为这样的话是对的,那么你也可以写出这样的扩展库,不管是Q、when.js,或者是WinJS,你可以使用Promises/A规范中最基本的规则定义,去构建promise的行为。比如这个,一个可以和一切真正满足Promises/A规范的类库一起工作的retry函数。

然而,不幸的是,像JQuery这样的类库却破坏了这条守则,它迫使丑陋的hack代码去检测这些冒充promises的对象——虽然JQuery依然在API文档里面号称这是“promise”对象:

if (typeof assertion._obj.pipe === "function") {
    throw new TypeError("Chai as Promised is incompatible with jQuery's so-called “promises.” Sorry!");
}

如果API的使用者坚持使用JQuery promises的话,你大概只有两种选择:在执行过程中莫名其妙地、令人困惑地失败,或者彻底失败,并且阻塞你继续使用整个类库。这可真糟糕啊。

 

继续向前

这就是我为什么尽可能地避免在Ember中使用回调函数聚合器了,这也是我写这篇文章的原因,而且,你可以看一下我写的这个准确兼容Promises/A规范的套件,这样我们就可以在认识层面上达成一致了。

这个测试套件发布以后,promise操作性和可理解性都有了进步。rsvp.js发布的其中一个目标就是要提供对Promises/A的支持。不过最棒的是这个Promises/A+组织的开源项目,一个松耦合的实现,用清晰的和测试完备的方式呈现扩展了原有Promises/A规范,成为Promises/A+规范

当然,还有很多工作要做。值得注意的是,在写这篇文章的时候,JQuery的最新版本是1.9.1,它的promises在错误处理上的实现是完全错误的。我希望在接下去的JQuery 2.0版本中参考Promises/A+的文档,修正这个问题。

同时,这些类库是非常好地遵照Promises/A+标准的,我现在毫无保留地推荐给你:

  • Q:Kris Kowal和我写的,一个promise特性完全实现的类库,有丰富的API、Node.js的支持、处理流支持,以及初步的对于长堆栈的支持。
  • RSVP.js:Yehuda Katz写的,非常轻量的promise的完全实现。
  • when.js:Brian Cavalier写的,一个任务管理的中间库,可以部署和取消任务执行。

如果你对使用JQuery残废的promise感到不爽,我推荐你使用上面类库的工具方法来实现你同样的目的(一般都是一个叫做when的方法),把这个残废的promise对象变成一个健全的promise对象:

var promise = Q.when($.get("https://github.com/kriskowal/q"));
// aaaah, much better

文章未经特殊标明皆为本人原创,未经许可不得用于任何商业用途,转载请保持完整性并注明来源链接《四火的唠叨》

分享到:

4 comments

  1. [...] 你没有抓住Promises的要点 | 四火的唠叨 Related content: node-sqlite3 in Action (Part 1) [...]

  2. 读jQuery 说道:

    [...] 重点应该在最后返回的是 `deferred.promise()` 这是deferred的简化版本(丢掉了写入功能),防止外部程序程序通过这个返回值来修改Deferred对象的状态等属性,造成紊乱.而实现when功能的重点应该仍然在Deferred这一”队列”功能的设置,多个动作调用同一个回调函数是通过简单的循环来实现的.​ 还有一点稍微有点离题却又值得一提的是,jQuery的promise并不完全符合CommonJS的Promises/A规范,但是这和jQuery源码没有关系,详见四火的唠叨. [...]

  3. [...] 文章系本人原创,转载请保持完整性并注明出自《四火的唠叨》 [...]

  4. [...] 文章系本人原创,转载请保持完整性并注明出自《四火的唠叨》 [...]

发表评论

电子邮件地址不会被公开。

您可以使用这些 HTML 标签和属性: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>


Preview on Feedage: