3.5 错误处理
前面已经展示了一些例子,用于说明在异步编程中 Promise 拒绝(调用 reject(..) 有意拒绝或 JavaScript 异常导致的无意拒绝)如何使得错误处理更完善。我们来回顾一下,并明确解释一下前面没有明说的几个细节。
对多数开发者来说,错误处理最自然的形式就是同步的 try..catch 结构。遗憾的是,它只能是同步的,无法用于异步代码模式:
function foo() { setTimeout( function(){ baz.bar(); }, 100 ); } try { foo(); // 后面从 `baz.bar()` 抛出全局错误 } catch (err) { // 永远不会到达这里 }
try..catch 当然很好,但是无法跨异步操作工作。也就是说,还需要一些额外的环境支持,我们会在第 4 章关于生成器的部分介绍这些环境支持。
在回调中,一些模式化的错误处理方式已经出现,最值得一提的是 error-first 回调 风格:
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 ); } } );
只有在 baz.bar() 调用会同步地立即成功或失败的情况下,这里的 try..catch 才能工作。如果 baz.bar() 本身有自己的异步完成函数,其中的任何异步错误都将无法捕捉到。
传给 foo(..) 的回调函数保留第一个参数 err ,用于在出错时接收到信号。如果其存在的话,就认为出错;否则就认为是成功。
严格说来,这一类错误处理是支持异步的,但完全无法很好地组合。多级 error-first 回调交织在一起,再加上这些无所不在的 if 检查语句,都不可避免地导致了回调地狱的风险(参见第 2 章)。
我们回到 Promise 中的错误处理,其中拒绝处理函数被传递给 then(..) 。Promise 没有采用流行的 error-first 回调设计风格,而是使用了分离回调(split-callback)风格。一个回调用于完成情况,一个回调用于拒绝情况:
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){ // 数字没有string函数,所以会抛出错误 console.log( msg.toLowerCase() ); }, function rejected(err){ // 永远不会到达这里 } );
如果 msg.toLowerCase() 合法地抛出一个错误(事实确实如此!),为什么我们的错误处理函数没有得到通知呢?正如前面解释过的,这是因为那个错误处理函数是为 promise p 准备的,而这个 promise 已经用值 42 填充了。promise p 是不可变的,所以唯一可以被通知这个错误的 promise 是从 p.then(..) 返回的那一个,但我们在此例中没有捕捉。
这应该清晰地解释了为什么 Promise 的错误处理易于出错。这非常容易造成错误被吞掉,而这极少是出于你的本意。
如果通过无效的方式使用 Promise API, 并且出现一个错误阻碍了正常的 Promise 构造,那么结果会得到一个立即抛出的异常,而不是一个被拒绝的 Promise。这里是一些错误使用导致 Promise 构造失败的例 子:new Promise(null) 、Promise.all() 、Promise.race(42) ,等等。如果一开始你就没能有效使用 Promise API 真正构造出一个 Promise,那就无法得到一个被拒绝的 Promise !
3.5.1 绝望的陷阱
Jeff Atwood 多年前曾提出:通常编程语言构建的方式是,默认情况下,开发者陷入“绝望的陷阱 ”(pit of despair)(http://blog.codinghorror.com/falling-into-the-pit-of-success ),要为错误付出代价,只有更努力才能做对。他呼吁我们转而构建一个“成功的坑”(pit of success),其中默认情况下你能够得到想要的结果(成功),想出错很难。
毫无疑问,Promise 错误处理就是一个“绝望的陷阱”设计。默认情况下,它假定你想要 Promise 状态吞掉所有的错误。如果你忘了查看这个状态,这个错误就会默默地(通常是绝望地)在暗处凋零死掉。
为了避免丢失被忽略和抛弃的 Promise 错误,一些开发者表示,Promise 链的一个最佳实践就是最后总以一个 catch(..) 结束,比如:
var p = Promise.resolve( 42 ); p.then( function fulfilled(msg){ // 数字没有string函数,所以会抛出错误 console.log( msg.toLowerCase() ); } ) .catch( handleErrors );
因为我们没有为 then(..) 传入拒绝处理函数,所以默认的处理函数被替换掉了,而这仅仅是把错误传递给了链中的下一个 promise。因此,进入 p 的错误以及 p 之后进入其决议(就像 msg.toLowerCase() )的错误都会传递到最后的 handleErrors(..) 。
问题解决了,对吧?没那么快!
如果 handleErrors(..) 本身内部也有错误怎么办呢?谁来捕捉它?还有一个没人处理的 promise:catch(..) 返回的那一个。我们没有捕获这个 promise 的结果,也没有为其注册拒绝处理函数。
你并不能简单地在这个链尾端添加一个新的 catch(..) ,因为它很可能会失败。任何 Promise 链的最后一步,不管是什么,总是存在着在未被查看的 Promise 中出现未捕获错误的可能性,尽管这种可能性越来越低。
看起来好像是个无解的问题吧?
3.5.2 处理未捕获的情况
这不是一个容易彻底解决的问题。还有其他(很多人认为是更好的)一些处理方法。
有些 Promise 库增加了一些方法,用于注册一个类似于“全局未处理拒绝”处理函数的东西,这样就不会抛出全局错误,而是调用这个函数。但它们辨识未捕获错误的方法是定义一个某个时长的定时器,比如 3 秒钟,在拒绝的时刻启动。如果 Promise 被拒绝,而在定时器触发之前都没有错误处理函数被注册,那它就会假定你不会注册处理函数,进而就是未被捕获错误。
在实际使用中,对很多库来说,这种方法运行良好,因为通常多数使用模式在 Promise 拒绝和检查拒绝结果之间不会有很长的延迟。但是这种模式可能会有些麻烦,因为 3 秒这个时间太随意了(即使是经验值),也因为确实有一些情况下会需要 Promise 在一段不确定的时间内保持其拒绝状态。而且你绝对不希望因为这些误报(还没被处理的未捕获错误)而调用未捕获错误处理函数。
更常见的一种看法是:Promsie 应该添加一个 done(..) 函数,从本质上标识 Promsie 链的结束。done(..) 不会创建和返回 Promise,所以传递给 done(..) 的回调显然不会报告一个并不存在的链接 Promise 的问题。
那么会发生什么呢?它的处理方式类似于你可能对未捕获错误通常期望的处理方式:done(..) 拒绝处理函数内部的任何异常都会被作为一个全局未处理错误抛出(基本上是在开发者终端上)。代码如下:
var p = Promise.resolve( 42 ); p.then( function fulfilled(msg){ // 数字没有string函数,所以会抛出错误 console.log( msg.toLowerCase() ); } ) .done( null, handleErrors ); // 如果handleErrors(..)引发了自身的异常,会被全局抛出到这里
相比没有结束的链接或者任意时长的定时器,这种方案看起来似乎更有吸引力。但最大的问题是,它并不是 ES6 标准的一部分,所以不管听起来怎么好,要成为可靠的普遍解决方案,它还有很长一段路要走。
那我们就这么被卡住了?不完全是。
浏览器有一个特有的功能是我们的代码所没有的:它们可以跟踪并了解所有对象被丢弃以及被垃圾回收的时机。所以,浏览器可以追踪 Promise 对象。如果在它被垃圾回收的时候其中有拒绝,浏览器就能够确保这是一个真正的未捕获错误,进而可以确定应该将其报告到开发者终端。
在编写本书时候,Chrome 和 Firefox 对于这种(追踪)未捕获拒绝功能都已经有了早期的实验性支持,尽管还不完善。
但是,如果一个 Promise 未被垃圾回收——各种不同的代码模式中很容易不小心出现这种情况——浏览器的垃圾回收嗅探就无法帮助你知晓和诊断一个被你默默拒绝的 Promise。
还有其他办法吗?有。
3.5.3 成功的坑
接下来的内容只是理论上的,关于未来的 Promise 可以变成什么样。我相信它会变得比现在我们所拥有的高级得多。我认为这种改变甚至可能是后 ES6 的,因为我觉得它不会打破与 ES6 Promise 的 web 兼容性。还有,如果你认真对待的话,它可能是可以 polyfill/prollyfill 的。我们来看一下。
· 默认情况下,Promsie 在下一个任务或时间循环 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 库为实现!
这个错误处理 / 报告的精确模板是在我的 asynquesce Promise 抽象库中实现的。本部分的附录 A 中详细讨论了这个库。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论