JavaScript Promise - 未处理的失败 promise 的追踪 (5/6)
最初版本的 promise 在失败且没有 rejected handler 时,会默默失败。很多人认为这是一个败笔,之后,JavaScript 运行时对这种情况添加了 console warning;而最终,对未处理的失败 promise 的追踪被添加进 JavaScript 规范。本篇介绍如何追踪未处理的失败的 promise。
统一术语:未处理指 unhandled,失败指 rejected。
识别未处理的失败 promise
由于 promise 的本质,检测一个失败的 promise 是否已经被处理并非易事:
const rejected = Promise.reject(2)
// 此时, rejected 未被处理
setTimeout(() => {
rejected.catch((v) => {
// rejected 在这里被处理
console.log(v)
})
}, 1000)
我们可以在任何时候调用 then()
或者 catch()
,所以很难准确知道 promise 何时被处理了。上面的代码中,promise 立即失败,但直到 1 秒之后才被处理。
JavaScript 规范认为:只要一个 promise 调用了 then()
方法,这个 promise 就算是被处理了,不管是否提供了相关的 handler (这里调用 then()
包括包括调用 include()
和 finally()
,因为它们底层都是调用的 then()
)。
每一次对 then()
的调用都会创建一个新的 promise,这个新的 promise 负责调用相关的 handler。
const p1 = new Promise((resolve, reject) => reject(2))
const p2 = p1.then(v => console.log(v))
上面这两行代码中, p1
promise 被认为是已经处理的,因为它调用了 then()
方法。但 p1
其实是一个失败的 promise,它的失败的状态通过 then()
方法被传递给 p2
,而 p2
没有相应的失败的 handler,所以 p1
中的失败就没有被处理。运行时可能会报告 p2
中的失败而丢弃 p1
中的失败,因为运行时并不是追踪所有失败且未处理的 promise,只是追踪链中的最后一个 promise
是否有 handler。
虽然 JavaScript 规范确实指示了如何追踪未处理的失败,但没有指定当发生未处理的失败时运行时应该如何处理。这些细节留给运行时本身,所以不同的运行时 (Node、Deno、Bun) 可能有不同的行为。
追踪浏览器中未处理的失败 promise
浏览器中未处理的失败 promise 的追踪在 HTMl 规范 中做了定义。其要点是通过 globalThis
对象出发两个事件:
unhandledrejection
:当 promise 失败并且在事件循环的一个回合内没有调用失败 handler 时触发。rejectionhandled
:当 promise 失败并且在事件循环的一个回合后调用失败 handler 时触发。
这两个事件被设计为一起使用以准确检测未处理的失败 promise。下面的代码展示了事件的触发时机:
const reject = Promise.reject(new Error("Oops"))
setTimeout(() => {
// 2. 此处触发 `rejectionhanled` 事件
reejcted.catch((reason) => console.error(reason.message))
}, 1000)
// 1. 此处触发 `unhandledrejection` 事件
根据触发时机可以看出,要想准确地检测问题,就需要跟踪触发这些事件的 promise。
unhandledrejection
和 rejectionhandled
这两个事件都接受一个 event
对象作为参数,包含下列属性:
type
:事件的名称promise
:失败的 promise 对象reason
:失败的 promise 的值
通过这些属性我们可以追踪哪些 promise 没有指定失败的 handler:
const rejected = Promise.reject(new Error("Oops"))
setTimeout(() => {
// 2. 此处触发 `rejectionhandled` 事件
rejected.catch((reason) => console.error(reason.message))
}, 1000)
globalThis.onunhandledrejection = (event) => {
console.log(event.type) // unhandledrejection
console.log(event.reason.message) // Oops
console.log(event.promise === rejected) // true
}
globalThis.onrejectionhandled = (event) => {
console.log(event.type) // reejctionhandled
console.log(event.reason.message) // Oops
console.log(event.promise === rejected) // true
}
// 1. 此处触发 `unhandledrejection` 事件
- 可以直接复制上面的代码在浏览器 console 中执行,查看效果
- 上面的代码也可以使用
addEventListener
注册事件
报告浏览器中未处理的失败 promise
虽然 unhandledrejection
和 rejectionhandled
事件有助于识别潜在的问题,但在生产中仅仅依靠它们是不够的。 由于我们不一定要记录每个未处理的失败,因为可能稍后就会添加处理失败的
handler,因此指定一个时间范围是有意义的,我们希望在该时间范围内处理所有失败的 promise。例如,我们可能想记录一分钟内所有未被处理的失败 promise,为此,我们需要跟踪触发了 unhandledrejection
但没有触发 rejectionhandled
的 promise。下面是一种参考方法:
const possiblyUnhandledRejections = new Map()
// 如果失败的 promise 未被处理,添加进 map
globalThis.onunhandledrejection = (event) => {
possiblyUnhandledRejections.set(event.promise, event.reason)
}
// 如果失败的 promise 被处理,从 map 中删除
globalThis.onrejectionhandled = (event) => {
possiblyUnhandledRejections.remove(event.promise)
}
setInterval(() => {
possiblyUnhandledRejections.forEach((reason, promise) => {
console.error("Unhandled rejection")
console.error(promise)
console.error(reason.message ? reason.message : reason)
// 此处处理未处理的失败 promise
})
possiblyUnhandledRejections.clear()
}, 60000)
这里使用 Map 是因为我们需要定期检查哪些 promise 存在,这不是 WeakMap 能做到的事。
避免浏览器中输出 warning
默认情况下,浏览器对于未处理的失败 promise 会在 console 输出 warning,即使我们监听了上面两个事件也不会改变这点。可以通过在 onunhandledrejection
事件处理函数中添加 event.preventDefault()
来阻止浏览器输出 warning:
globalThis.onunhandledrejection = event => {
event.preventDefault()
}
注意,这个操作只会阻止浏览器输出 warning,不影响 rejectionhandled
事件。
处理浏览器中未处理的失败 promise
有个小技巧…
我们可以在 unhandledrejection
事件中处理未处理的 promise,来阻止 rejectionhandled
事件发出:
globalThis.onunhandledrejection = ({ promise, reason }) => {
promise.catch(() => {}) // 处理 rejection
}
// 这个永远不会被调用
globalThis.onrejectionhandled = ({ promise }) => console.error(promise)
这里 rejectionhandled
事件永远不会被触发因为在这个事件发出之前 promise 已经被处理了,没理由再触发这个事件了。
注意,除非显式调用了
event.preventDefault()
,否则上面的代码同样不会阻止浏览器发出 console warning。
追踪 Node.js 中未处理的失败 promise
Node.js 中不使用 globalThis
对象,而是由 process
对象触发事件,相应的两个事件名称为:
unhandledRejection
rejectionHandled
这两个事件也不接受 event
对象作为参数:
unhandledRejection
接受两个参数,分别为 reason 和 promiserejectionHandled
接受一个参数,即 promise
其它运行时如 Deno、Bun 应该大致相同但略有区别,具体来说就触及到知识盲区了。