错误处理
我们已经看过几个例子,Promise拒绝——既可以通过有意调用reject(..)
,也可以通过意外的JS异常——是如何在异步编程中允许清晰的错误处理的。让我们兜个圈子回去,将我们一带而过的一些细节弄清楚。
对大多数开发者来说,最自然的错误处理形式是同步的try..catch
结构。不幸的是,它仅能用于同步状态,所以在异步代码模式中它帮不上什么忙:
function foo() {
setTimeout( function(){
baz.bar();
}, 100 );
}
try {
foo();
// 稍后会从`baz.bar()`抛出全局错误
}
catch (err) {
// 永远不会到这里
}
能有try..catch
当然很好,但除非有某些附加的环境支持,它无法与异步操作一起工作。我们将会在第四章中讨论generator时回到这个话题。
在回调中,对于错误处理的模式已经有了一些新兴的模式,最有名的就是“错误优先回调”风格:
function foo(cb) {
setTimeout( function(){
try {
var x = baz.bar();
cb( null, x ); // 成功!
}
catch (err) {
cb( err );
}
}, 100 );
}
foo( function(err,val){
if (err) {
console.error( err ); // 倒霉 :(
}
else {
console.log( val );
}
} );
注意: 这里的try..catch
仅在baz.bar()
调用立即地,同步地成功或失败时才能工作。如果baz.bar()
本身是一个异步完成的函数,它内部的任何异步错误都不能被捕获。
我们传递给foo(..)
的回调期望通过预留的err
参数收到一个表示错误的信号。如果存在,就假定出错。如果不存在,就假定成功。
这类错误处理在技术上是 异步兼容的,但它根本组织的不好。用无处不在的if
语句检查将多层错误优先回调编织在一起,将不可避免地将你置于回调地狱的危险之中(见第二章)。
那么我们回到Promise的错误处理,使用传递给then(..)
的拒绝处理器。Promise不使用流行的“错误优先回调”设计风格,反而使用“分割回调”的风格;一个回调给完成,一个回调给拒绝:
var p = Promise.reject( "Oops" );
p.then(
function fulfilled(){
// 永远不会到这里
},
function rejected(err){
console.log( err ); // "Oops"
}
);
虽然这种模式表面上看起来十分有道理,但是Promise错误处理的微妙之处经常使它有点儿相当难以全面把握。
考虑下面的代码:
var p = Promise.resolve( 42 );
p.then(
function fulfilled(msg){
// 数字没有字符串方法,
// 所以这里抛出一个错误
console.log( msg.toLowerCase() );
},
function rejected(err){
// 永远不会到这里
}
);
如果msg.toLowerCase()
合法地抛出一个错误(它会的!),为什么我们的错误处理器没有得到通知?正如我们早先解释的,这是因为 这个 错误处理器是为p
promise准备的,也就是已经被值42
完成的那个promise。p
promise是不可变的,所以唯一可以得到错误通知的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(..)
终结,就像这样:
var p = Promise.resolve( 42 );
p.then(
function fulfilled(msg){
// 数字没有字符串方法,
// 所以这里抛出一个错误
console.log( msg.toLowerCase() );
}
)
.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(..)
的拒绝处理器内部的任何异常都作为全局的未捕获错误抛出(基本上扔到开发者控制台):
var p = Promise.resolve( 42 );
p.then(
function fulfilled(msg){
// 数字没有字符串方法,
// 所以这里抛出一个错误
console.log( msg.toLowerCase() );
}
)
.done( null, handleErrors );
// 如果`handleErrors(..)`自身发生异常,它会在这里被抛出到全局
这听起来要比永不终结的链条或随意的超时要吸引人。但最大的问题是,它不是ES6标准,所以不管听起来多么好,它成为一个可靠而普遍的解决方案还有很长的距离。
那我们就卡在这里了?不完全是。
浏览器有一个我们的代码没有的能力:它们可以追踪并确定一个对象什么时候被废弃并可以作为垃圾回收。所以,浏览器可以追踪Promise对象,当它们被当做垃圾回收时,如果在它们内部存在一个拒绝状态,浏览器就可以确信这是一个合法的“未捕获错误”,它可以信心十足地知道应当在开发者控制台上报告这一情况。
注意: 在写作本书的时候,Chrome和Firefox都早已试图实现这种“未捕获拒绝”的能力,虽然至多也就是支持的不完整。
然而,如果一个Promise不被垃圾回收——通过许多不同的代码模式,这极其容易不经意地发生——浏览器的垃圾回收检测不会帮你知道或诊断你有一个拒绝的Promise静静地躺在附近。
还有其他选项吗?有。
成功的深渊
以下讲的仅仅是理论上,Promise 可能 在某一天变成什么样的行为。我相信那会比我们现在拥有的优越许多。而且我想这种改变可能会发生在后ES6时代,因为我不认为它会破坏Web的兼容性。另外,如果你小心行事,它是可以被填补(polyfilled)/预填补(prollyfilled)的。让我们来看一下:
- Promise可以默认为是报告(向开发者控制台)一切拒绝的,就在下一个Job或事件轮询tick,如果就在这时Promise上没有注册任何错误处理器。
- 如果你希望拒绝的Promise在被监听前,将其拒绝状态保持一段不确定的时间。你可以调用
defer()
,它会压制这个Promise自动报告错误。
如果一个Promise被拒绝,默认地它会吵吵闹闹地向开发者控制台报告这个情况(而不是默认不出声)。你既可以选择隐式地处理这个报告(通过在拒绝之前注册错误处理器),也可以选择明确地处理这个报告(使用defer()
)。无论哪种情况,你 都控制着这种误报。
考虑下面的代码:
var p = Promise.reject( "Oops" ).defer();
// `foo(..)`返回Promise
foo( 42 )
.then(
function fulfilled(){
return p;
},
function rejected(err){
// 处理`foo(..)`的错误
}
);
...
我们创建了p
,我们知道我们会为了使用/监听它的拒绝而等待一会儿,所以我们调用defer()
——如此就不会有全局的报告。defer()
单纯地返回同一个promise,为了链接的目的。
从foo(..)
返回的promise 当即 就添附了一个错误处理器,所以这隐含地跳出了默认行为,而且不会有全局的关于错误的报告。
但是从then(..)
调用返回的promise没有defer()
或添附错误处理器,所以如果它被拒绝(从它内部的任意一个解析处理器中),那么它就会向开发者控制台报告一个未捕获错误。
这种设计称为成功的深渊。默认情况下,所有的错误不是被处理就是被报告——这几乎是所有开发者在几乎所有情况下所期望的。你要么不得不注册一个监听器,要么不得不有意什么都不做,并指示你要将错误处理推迟到 稍后;你仅为这种特定情况选择承担额外的责任。
这种方式唯一真正的危险是,你defer()
了一个Promise但是实际上没有监听/处理它的拒绝。
但你不得不有意地调用defer()
来选择进入绝望深渊——默认是成功深渊——所以对于从你自己的错误中拯救你这件事来说,我们能做的不多。
我觉得对于Promise的错误处理还有希望(在后ES6时代)。我希望上层人物将会重新思考这种情况并考虑选用这种方式。同时,你可以自己实现这种方式(给读者们的挑战练习!),或使用一个 聪明 的Promise库来为你这么做。
注意: 这种错误处理/报告的确切的模型已经在我的 asynquence Promise抽象库中实现,我们会在本书的附录A中讨论它。