JavaScript Promise - 未处理的失败 promise 的追踪 (5/6)

6 阅读5分钟

最初版本的 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。

unhandledrejectionrejectionhandled 这两个事件都接受一个 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

虽然 unhandledrejectionrejectionhandled 事件有助于识别潜在的问题,但在生产中仅仅依靠它们是不够的。 由于我们不一定要记录每个未处理的失败,因为可能稍后就会添加处理失败的 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 和 promise
  • rejectionHandled 接受一个参数,即 promise

其它运行时如 Deno、Bun 应该大致相同但略有区别,具体来说就触及到知识盲区了。