错误处理

我们已经看过几个例子,Promise拒绝——既可以通过有意调用reject(..),也可以通过意外的JS异常——是如何在异步编程中允许清晰的错误处理的。让我们兜个圈子回去,将我们一带而过的一些细节弄清楚。

对大多数开发者来说,最自然的错误处理形式是同步的try..catch结构。不幸的是,它仅能用于同步状态,所以在异步代码模式中它帮不上什么忙:

  1. function foo() {
  2. setTimeout( function(){
  3. baz.bar();
  4. }, 100 );
  5. }
  6. try {
  7. foo();
  8. // 稍后会从`baz.bar()`抛出全局错误
  9. }
  10. catch (err) {
  11. // 永远不会到这里
  12. }

能有try..catch当然很好,但除非有某些附加的环境支持,它无法与异步操作一起工作。我们将会在第四章中讨论generator时回到这个话题。

在回调中,对于错误处理的模式已经有了一些新兴的模式,最有名的就是“错误优先回调”风格:

  1. function foo(cb) {
  2. setTimeout( function(){
  3. try {
  4. var x = baz.bar();
  5. cb( null, x ); // 成功!
  6. }
  7. catch (err) {
  8. cb( err );
  9. }
  10. }, 100 );
  11. }
  12. foo( function(err,val){
  13. if (err) {
  14. console.error( err ); // 倒霉 :(
  15. }
  16. else {
  17. console.log( val );
  18. }
  19. } );

注意: 这里的try..catch仅在baz.bar()调用立即地,同步地成功或失败时才能工作。如果baz.bar()本身是一个异步完成的函数,它内部的任何异步错误都不能被捕获。

我们传递给foo(..)的回调期望通过预留的err参数收到一个表示错误的信号。如果存在,就假定出错。如果不存在,就假定成功。

这类错误处理在技术上是 异步兼容的,但它根本组织的不好。用无处不在的if语句检查将多层错误优先回调编织在一起,将不可避免地将你置于回调地狱的危险之中(见第二章)。

那么我们回到Promise的错误处理,使用传递给then(..)的拒绝处理器。Promise不使用流行的“错误优先回调”设计风格,反而使用“分割回调”的风格;一个回调给完成,一个回调给拒绝:

  1. var p = Promise.reject( "Oops" );
  2. p.then(
  3. function fulfilled(){
  4. // 永远不会到这里
  5. },
  6. function rejected(err){
  7. console.log( err ); // "Oops"
  8. }
  9. );

虽然这种模式表面上看起来十分有道理,但是Promise错误处理的微妙之处经常使它有点儿相当难以全面把握。

考虑下面的代码:

  1. var p = Promise.resolve( 42 );
  2. p.then(
  3. function fulfilled(msg){
  4. // 数字没有字符串方法,
  5. // 所以这里抛出一个错误
  6. console.log( msg.toLowerCase() );
  7. },
  8. function rejected(err){
  9. // 永远不会到这里
  10. }
  11. );

如果msg.toLowerCase()合法地抛出一个错误(它会的!),为什么我们的错误处理器没有得到通知?正如我们早先解释的,这是因为 这个 错误处理器是为ppromise准备的,也就是已经被值42完成的那个promise。ppromise是不可变的,所以唯一可以得到错误通知的promise是由p.then(..)返回的那个,而在这里我们没有捕获它。

这应当解释了:为什么Promise的错误处理是易错的。错误太容易被吞掉了,而这很少是你有意这么做的。

警告: 如果你以一种不合法的方式使用Promise API,而且有错误阻止正常的Promise构建,其结果将是一个立即被抛出的异常,而不是一个拒绝Promise。这是一些导致Promise构建失败的错误用法:new Promise(null)Promise.all()Promise.race(42)等等。如果你没有足够合法地使用Promise API来首先实际构建一个Promise,你就不能得到一个拒绝Promise!

绝望的深渊

几年前Jeff Atwood曾经写到:编程语言总是默认地以这样的方式建立,开发者们会掉入“绝望的深渊”(http://blog.codinghorror.com/falling-into-the-pit-of-success/ )——在这里意外会被惩罚——而你不得不更努力地使它正确。他恳求我们相反地创建“成功的深渊”,就是你会默认地掉入期望的(成功的)行为,而如此你不得不更努力地去失败。

毫无疑问,Promise的错误处理是一种“绝望的深渊”的设计。默认情况下,它假定你想让所有的错误都被Promise的状态吞掉,而且如果你忘记监听这个状态,错误就会默默地凋零/死去——通常是绝望的。

为了回避把一个被遗忘/抛弃的Promise的错误无声地丢失,一些开发者宣称Promise链的“最佳实践”是,总是将你的链条以catch(..)终结,就像这样:

  1. var p = Promise.resolve( 42 );
  2. p.then(
  3. function fulfilled(msg){
  4. // 数字没有字符串方法,
  5. // 所以这里抛出一个错误
  6. console.log( msg.toLowerCase() );
  7. }
  8. )
  9. .catch( handleErrors );

因为我们没有给then(..)传递拒绝处理器,默认的处理器会顶替上来,它仅仅简单地将错误传播到链条的下一个promise中。如此,在p中发生的错误,与在p之后的解析中(比如msg.toLowerCase())发生的错误都将会过滤到最后的handleErrors(..)中。

问题解决了,对吧?没那么容易!

要是handleErrors(..)本身也有错误呢?谁来捕获它?这里还有一个没人注意的promise:catch(..)返回的promise,我们没有对它进行捕获,也没注册拒绝处理器。

你不能仅仅将另一个catch(..)贴在链条末尾,因为它也可能失败。Promise链的最后一步,无论它是什么,总有可能,即便这种可能性逐渐减少,悬挂着一个困在未被监听的Promise中的,未被捕获的错误。

听起来像一个不可解的迷吧?

处理未被捕获的错误

这不是一个很容易就能完全解决的问题。但是有些接近于解决的方法,或者说 更好的方法

一些Promise库有一些附加的方法,可以注册某些类似于“全局的未处理拒绝”的处理器,全局上不会抛出错误,而是调用它。但是他们识别一个错误是“未被捕获的错误”的方案是,使用一个任意长的计时器,比如说3秒,从拒绝的那一刻开始计时。如果一个Promise被拒绝但没有错误处理在计时器被触发前注册,那么它就假定你不会注册监听器了,所以它是“未被捕获的”。

实践中,这个方法在许多库中工作的很好,因为大多数用法不会在Promise拒绝和监听这个拒绝之间有很明显的延迟。但是这个模式有点儿麻烦,因为3秒实在太随意了(即便它是实证过的),还因为确实有些情况你想让一个Promise在一段不确定的时间内持有它的拒绝状态,而且你不希望你的“未捕获错误”处理器因为这些误报(还没处理的“未捕获错误”)而被调用。

另一种常见的建议是,Promise应当增加一个done(..)方法,它实质上标志着Promise链的“终结”。done(..)不会创建并返回一个Promise,所以传递给done(..)的回调很明显地不会链接上一个不存在的Promise链,并向它报告问题。

那么接下来会发什么?正如你通常在未处理错误状态下希望的那样,在done(..)的拒绝处理器内部的任何异常都作为全局的未捕获错误抛出(基本上扔到开发者控制台):

  1. var p = Promise.resolve( 42 );
  2. p.then(
  3. function fulfilled(msg){
  4. // 数字没有字符串方法,
  5. // 所以这里抛出一个错误
  6. console.log( msg.toLowerCase() );
  7. }
  8. )
  9. .done( null, handleErrors );
  10. // 如果`handleErrors(..)`自身发生异常,它会在这里被抛出到全局

这听起来要比永不终结的链条或随意的超时要吸引人。但最大的问题是,它不是ES6标准,所以不管听起来多么好,它成为一个可靠而普遍的解决方案还有很长的距离。

那我们就卡在这里了?不完全是。

浏览器有一个我们的代码没有的能力:它们可以追踪并确定一个对象什么时候被废弃并可以作为垃圾回收。所以,浏览器可以追踪Promise对象,当它们被当做垃圾回收时,如果在它们内部存在一个拒绝状态,浏览器就可以确信这是一个合法的“未捕获错误”,它可以信心十足地知道应当在开发者控制台上报告这一情况。

注意: 在写作本书的时候,Chrome和Firefox都早已试图实现这种“未捕获拒绝”的能力,虽然至多也就是支持的不完整。

然而,如果一个Promise不被垃圾回收——通过许多不同的代码模式,这极其容易不经意地发生——浏览器的垃圾回收检测不会帮你知道或诊断你有一个拒绝的Promise静静地躺在附近。

还有其他选项吗?有。

成功的深渊

以下讲的仅仅是理论上,Promise 可能 在某一天变成什么样的行为。我相信那会比我们现在拥有的优越许多。而且我想这种改变可能会发生在后ES6时代,因为我不认为它会破坏Web的兼容性。另外,如果你小心行事,它是可以被填补(polyfilled)/预填补(prollyfilled)的。让我们来看一下:

  • Promise可以默认为是报告(向开发者控制台)一切拒绝的,就在下一个Job或事件轮询tick,如果就在这时Promise上没有注册任何错误处理器。
  • 如果你希望拒绝的Promise在被监听前,将其拒绝状态保持一段不确定的时间。你可以调用defer(),它会压制这个Promise自动报告错误。

如果一个Promise被拒绝,默认地它会吵吵闹闹地向开发者控制台报告这个情况(而不是默认不出声)。你既可以选择隐式地处理这个报告(通过在拒绝之前注册错误处理器),也可以选择明确地处理这个报告(使用defer())。无论哪种情况, 都控制着这种误报。

考虑下面的代码:

  1. var p = Promise.reject( "Oops" ).defer();
  2. // `foo(..)`返回Promise
  3. foo( 42 )
  4. .then(
  5. function fulfilled(){
  6. return p;
  7. },
  8. function rejected(err){
  9. // 处理`foo(..)`的错误
  10. }
  11. );
  12. ...

我们创建了p,我们知道我们会为了使用/监听它的拒绝而等待一会儿,所以我们调用defer()——如此就不会有全局的报告。defer()单纯地返回同一个promise,为了链接的目的。

foo(..)返回的promise 当即 就添附了一个错误处理器,所以这隐含地跳出了默认行为,而且不会有全局的关于错误的报告。

但是从then(..)调用返回的promise没有defer()或添附错误处理器,所以如果它被拒绝(从它内部的任意一个解析处理器中),那么它就会向开发者控制台报告一个未捕获错误。

这种设计称为成功的深渊。默认情况下,所有的错误不是被处理就是被报告——这几乎是所有开发者在几乎所有情况下所期望的。你要么不得不注册一个监听器,要么不得不有意什么都不做,并指示你要将错误处理推迟到 稍后;你仅为这种特定情况选择承担额外的责任。

这种方式唯一真正的危险是,你defer()了一个Promise但是实际上没有监听/处理它的拒绝。

但你不得不有意地调用defer()来选择进入绝望深渊——默认是成功深渊——所以对于从你自己的错误中拯救你这件事来说,我们能做的不多。

我觉得对于Promise的错误处理还有希望(在后ES6时代)。我希望上层人物将会重新思考这种情况并考虑选用这种方式。同时,你可以自己实现这种方式(给读者们的挑战练习!),或使用一个 聪明 的Promise库来为你这么做。

注意: 这种错误处理/报告的确切的模型已经在我的 asynquence Promise抽象库中实现,我们会在本书的附录A中讨论它。