你没有抓住 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

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

17,046 次阅读

4 thoughts on “你没有抓住 Promises 的要点

  1. Pingback: 读jQuery

发表评论

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

back to top