本文来自四火哥的翻译
CommonJS是一组javascript编程规范,而promise是其中之一。
简而言之,promises是一种令代码的异步行为变得更加优雅的软件抽象。在基本的定义中,代码可能一直是这样写的
getTweetsFor("domenic", function (err, results) { // the rest of your code goes here. });
现在你的方法有一个返回值,叫做promise,它代表操作的最终结果。
var promiseForTweets = getTweets("domenic");
这是很重要的因为你可以把promiseForTweets当作一等对象,传值给他们,聚合他们等等,而不是搞一堆耦合在一起的回调函数去完成你逻辑。
我曾经说过我认为promises有多酷,这里就不再赘述。相反,我现在要说的是我看到一个不安的趋势关于最近javascript类库已经加入promise支持,他们完全忽略了promise的关键点。
Then方法和CommonJS Promise/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来处理异常:
1
2
3
4
5
6
7
|
try
{
process.nextTick(
function
() {
// do something
});
}
catch
(err) {
//you can not catch it
}
|
所以Node.js给的使用domain的解决方法是:
1
2
3
4
5
|
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才能处理它。
换言之,下面这段代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
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);
}
);
|
相当于这样的同步代码:
1
2
3
4
5
6
7
8
9
|
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”对象:
1
2
3
|
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对象:
1
2
|
// aaaah, much better
|