全局的 Promise 拒绝处理

Promise 最有争议的方面之一就是:当一个 Promise 被拒绝时若缺少拒绝处理函数,就会静默失败。有人认为这是规范中最大的缺陷,因为这是 JS 语言所有组成部分中唯一不让错误清晰可见的。

由于 Promise 的本质,判断一个 Promise 的拒绝是否已被处理并不直观。例如,研究以下示例:

let rejected = Promise.reject(42);

// at this point, rejected is unhandled

// some time later...
rejected.catch(function(value) {
    // now rejected has been handled
    console.log(value);
});

无论 Promise 是否已被解决,你都可以在任何时候调用 then() 或 catch() 并使它们正确工作,这导致很难准确知道一个 Promise 何时会被处理。此例中的 Promise 被立刻拒绝,但它后来才被处理。

虽然下个版本的 ES 可能会处理此问题,不过浏览器与 Node.js 已经实施了变更来解决开发者的这个痛点。这些变更不是 ES6 规范的一部分,但却是使用 Promise 时的宝贵工具。

Node.js 的拒绝处理

在 Node.js 中,process 对象上存在两个关联到 Promise 的拒绝处理的事件:

  • unhandledRejection:当一个 Promise 被拒绝、而在事件循环的一个轮次中没有任何拒绝处理函数被调用,该事件就会被触发;

  • rejectionHandled:若一个 Promise 被拒绝、并在事件循环的一个轮次之后再有拒绝处理函数被调用,该事件就会被触发。

这两个事件旨在共同帮助识别已被拒绝但未曾被处理 promise。

unhandledRejection 事件处理函数接受的参数是拒绝原因(常常是一个错误对象)以及已被拒绝的 Promise。以下代码展示了 unhandledRejection 的应用:

let rejected;

process.on("unhandledRejection", function(reason, promise) {
    console.log(reason.message);            // "Explosion!"
    console.log(rejected === promise);      // true
});

rejected = Promise.reject(new Error("Explosion!"));

此例创建了一个带有错误对象的已被拒绝的 Promise,并监听了 unhandledRejection 事件。事件处理函数接收了该错误对象作为第一个参数, 原 Promise 则是第二个参数。

rejectionHandled 事件处理函数则只有一个参数,即已被拒绝的 Promise。例如:

let rejected;

process.on("rejectionHandled", function(promise) {
    console.log(rejected === promise);              // true
});

rejected = Promise.reject(new Error("Explosion!"));

// wait to add the rejection handler
setTimeout(function() {
    rejected.catch(function(value) {
        console.log(value.message);     // "Explosion!"
    });
}, 1000);

此处的 rejectionHandled 事件在拒绝处理函数最终被调用时触发。若在 rejected 被创建后直接将拒绝处理函数附加到它上面,那么此事件就不会被触发。因为立即附加的拒绝处理函数在 rejected 被创建的事件循环的同一个轮次内就会被调用,这样 rejectionHandled 就不会起作用。

为了正确追踪潜在的未被处理的拒绝,使用 rejectionHandled 与 unhandledRejection 事件就能保持包含这些 Promise 的一个列表,之后等待一段时间再检查此列表。例如:

let possiblyUnhandledRejections = new Map();

// when a rejection is unhandled, add it to the map
process.on("unhandledRejection", function(reason, promise) {
    possiblyUnhandledRejections.set(promise, reason);
});

process.on("rejectionHandled", function(promise) {
    possiblyUnhandledRejections.delete(promise);
});

setInterval(function() {

    possiblyUnhandledRejections.forEach(function(reason, promise) {
        console.log(reason.message ? reason.message : reason);

        // do something to handle these rejections
        handleRejection(promise, reason);
    });

    possiblyUnhandledRejections.clear();

}, 60000);

对于未处理的拒绝,这只是个简单追踪器。它使用了一个 Map 来储存 Promise 及其拒绝原因,每个 Promise 都是键,而它的拒绝原因就是相关的值。每当 unhandledRejection 被触发,Promise 及其拒绝原因就会被添加到此 Map 中。而每当 rejectionHandled 被触发,已被处理的 Promise 就会从这个 Map 中被移除。这样一来,possiblyUnhandledRejections 就会随着事件的调用而扩展或收缩。setInterval() 的调用会定期检查这个列表,查看可能未被处理的拒绝,并将其信息输出到控制台(在现实情况下,你可能会想做点别的事情, 以便记录或处理该拒绝)。此例使用了一个 Map 而不是 Weak Map,这是因为你需要定期检查此 Map 来查看哪些 Promise 存在,而这是使用 Weak Map 所无法做到的。

尽管此例仅针对 Node.js,但浏览器也实现了类似的机制来将未处理的拒绝通知给开发者。

浏览器的拒绝处理

浏览器同样能触发两个事件,来帮助识别未处理的拒绝。这两个事件会被 window 对象触发,并完全等效于 Node.js 的相关事件:

  • unhandledrejection:当一个 Promise 被拒绝、而在事件循环的一个轮次中没有任何拒绝处理函数被调用,该事件就会被触发;

  • rejectionHandled:若一个 Promise 被拒绝、并在事件循环的一个轮次之后再有拒绝处理函数被调用,该事件就会被触发。

Node.js 的实现会传递分离的参数给事件处理函数,而浏览器事件的处理函数则只会接收到包含下列属性的一个对象:

  • type:事件的名称("unhandledrejection" 或 "rejectionhandled") ;

  • promise:被拒绝的 Promise 对象;

  • reason:Promise 中的拒绝值(拒绝原因) 。

浏览器的实现中存在的另一个差异就是:拒绝值(reason)在两种事件中都可用。 例如:

let rejected;

window.onunhandledrejection = function(event) {
    console.log(event.type);                    // "unhandledrejection"
    console.log(event.reason.message);          // "Explosion!"
    console.log(rejected === event.promise);    // true
};

window.onrejectionhandled = function(event) {
    console.log(event.type);                    // "rejectionhandled"
    console.log(event.reason.message);          // "Explosion!"
    console.log(rejected === event.promise);    // true
};

rejected = Promise.reject(new Error("Explosion!"));

此代码使用了 DOM 0 级写法的 onunhandledrejection 与 onrejectionhandled,对两个事件处理函数都进行了赋值(若你喜欢,也可以使用 addEventListener("unhandledrejection") 与 addEventListener("rejectionhandled"))。每个事件处理函数都接收一个事件对象,其中包含与被拒绝的 Promise 有关的信息,type、promise 与 reason 属性都可用。

以下代码在浏览器中追踪未被处理的拒绝,与 Node.js 的代码非常相似:

let possiblyUnhandledRejections = new Map();

// when a rejection is unhandled, add it to the map
window.onunhandledrejection = function(event) {
    possiblyUnhandledRejections.set(event.promise, event.reason);
};

window.onrejectionhandled = function(event) {
    possiblyUnhandledRejections.delete(event.promise);
};

setInterval(function() {

    possiblyUnhandledRejections.forEach(function(reason, promise) {
        console.log(reason.message ? reason.message : reason);

        // do something to handle these rejections
        handleRejection(promise, reason);
    });

    possiblyUnhandledRejections.clear();

}, 60000);

这个实现与 Node.js 的实现几乎一模一样。使用了相同方法在 Map 中存储 Promise 及其拒绝值,并在此后进行检查。唯一真正的区别就是在事件处理函数中信息是从何处被提取出来的。

处理 Promise 的拒绝可能很麻烦,但你才刚开始见识 Promise 实际上到底有多强大。现在是时候更进一步了——把几个 promises 串联在一起使用。