Menu

JavaScript 异步错误处理:try/catch、Promise 与 unhandledrejection

错误在异步 JavaScript 中究竟如何传递:async/await 配合 try/catch、Promise 的 .catch,以及那些会悄悄吞掉失败的陷阱。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

异步代码中的错误和同步错误不一样

在同步 JavaScript 中,抛出的错误会沿着调用栈向上冒泡,直到被某个 try/catch 捕获,否则程序就会崩溃。异步代码打破了这个模型。等到网络请求失败时,发起它的那个函数早就返回了,已经没有调用栈可以让错误向上冒泡。

Promise 解决这个问题的办法,是给错误开辟一条专门的通道。一个 Promise 要么带着一个值兑现,要么带着一个原因被拒绝。拒绝就是异步版的抛出错误。本页讲的所有内容,都是为了确保拒绝落到你能掌控的地方,而不是凭空消失。

try/catch 运行完毕,干净利落地退出。拒绝发生在 50 毫秒之后,那时 try 块早已结束。没有任何东西捕获它。这就是陷阱所在。

有了 await,try/catch 又能用了

一旦你 await 一个 Promise,它的拒绝就会在 async 函数内部变成一个抛出的错误。外层的 try/catch 会像捕获普通同步错误一样捕获它:

这是你应该首先考虑的写法。await 把异步世界重新接回熟悉的 try/catch 结构。把可能失败的 await 调用放进 try,在 catch 中处理它们。

有一个细节值得注意:只有被 await 的调用才受到保护。如果你启动了一个 Promise 却没有 await 它,错误仍然会逃出去。

最常见的 bug:忘了 await

如果你调用 async 函数时没有加 await(也没有返回它的 Promise),拒绝就会绕过外层的 try/catch:

try 块顺利执行完毕。拒绝发生在下一个 tick,没有任何东西捕获它。你会在控制台中看到一条“unhandled promise rejection”(未处理的 Promise 拒绝)警告。

修复方法永远是一样的:await 这个调用,或者 return 这个 Promise,让调用方去 await 它。

.catch() 是同一枚硬币的另一面

不用 async/await,也可以通过链式调用 .catch() 来处理拒绝:

.catch(fn) 是 .then(undefined, fn) 的简写。它会处理链条中前面任何一处的拒绝。链条末尾的 .catch() 相当于异步版的顶层 try/catch:在拒绝变成“未处理”之前的最后一道防线。

混用这两种风格完全没问题。常见的做法是在函数内部使用 async/await,让调用方加上 .catch():

fetch 遇到 HTTP 错误时不会拒绝

这一点几乎每个人都会中招一次。fetch 只在网络层面失败时才会拒绝:DNS 查询失败、连接被拒绝、请求被中止。404 或 500 响应都被视为一次成功的 fetch。Promise 会兑现,只是兑现得到的响应对象的 ok 为 false。

如果你希望 HTTP 错误进入 catch 块,就检查 res.ok 并显式抛出错误:

一旦你发现自己写了两遍这段样板代码,就值得把它提取成一个辅助函数。

Promise.all 快速失败,Promise.allSettled 不会

Promise.all 接收一个 Promise 数组,兑现为一个结果数组;但只要有一个被拒绝,它就会立即带着那个错误拒绝。其他 Promise 会继续运行,但它们的结果会被丢弃。

当你需要每一个结果、而任何一个失败都会让整个操作失去意义时,快速失败就是正确的行为。当你不管成败都想拿到每个结果时,比如“尝试这五个上传,告诉我哪些成功了、哪些失败了”,就用 Promise.allSettled:

allSettled 永远不会拒绝。每个条目要么是 {status: "fulfilled", value},要么是 {status: "rejected", reason}。

重新抛出与精确捕获

并不是所有错误都应该交给同一个处理程序。一种常见的做法是:捕获、检查,再把意料之外的错误重新抛出:

用一个空的 catch (err) {} 吞掉所有错误,会掩盖真正的 bug。捕获那些你能有意义地处理的错误,其余的重新抛出。

未处理的拒绝是你的安全网

即使代码写得再小心,迟早也会有漏网之鱼。Node.js 和浏览器都提供了一个全局钩子,用来处理没人捕获的拒绝:

// Browser
window.addEventListener("unhandledrejection", event => {
    console.error("unhandled:", event.reason);
    event.preventDefault(); // suppress the default console warning
});

// Node.js
process.on("unhandledRejection", reason => {
    console.error("unhandled:", reason);
});

它不能替代正确的错误处理,只是最后兜底的日志或遥测钩子。在现代 Node.js 中,未处理的拒绝默认会让进程崩溃,这在生产环境中通常正是你想要的。记录错误,然后让进程退出并干净地重启。

实用检查清单

当一个 async 函数涉及任何可能失败的操作时,问问自己:

  • 每个有风险的 await 是否都在 try/catch 里面,或者返回的 Promise 是否由调用方用 .catch() 处理?
  • 我真的 await 了这个调用吗?还是不小心写成了“发出去就不管”?
  • 特别是对 fetch,我在信任响应之前检查 res.ok 了吗?
  • 并行运行多个任务时,Promise.all 是合适的工具,还是我其实需要 Promise.allSettled?
  • 有没有顶层的 .catch() 或 unhandledrejection 处理程序,确保没有错误悄悄消失?

把这五点做好,你的异步代码就不会再让错误莫名其妙地消失在事件循环里。

下一步:ES 模块

异步错误处理为异步这一章画上了句号。接下来,我们要看 JavaScript 代码如何组织到多个文件中:import、export,以及支撑每个现代项目的模块系统。

常见问题

如何处理 async 函数中的错误?

把 await 调用包在 try/catch 块中。被 await 的 Promise 一旦被拒绝,就会变成一个抛出的错误,由 catch 接收。你也可以让错误继续传播,在调用处对返回的 Promise 使用 .catch() 来处理。

为什么我的 try/catch 捕获不到错误?

通常是因为错误发生在没有被 await 的代码里。如果你调用 async 函数时没有加 await(也没有返回它的 Promise),任何拒绝都会逃出外层的 try/catch。想捕获哪个 Promise 的错误,就一定要 await 或 return 它。

如果一个 Promise 被拒绝了却没人捕获,会发生什么?

你会得到一个未处理的拒绝。Node.js 会触发 unhandledRejection 事件,在较新的版本中默认还会让进程崩溃。浏览器会触发 window.onunhandledrejection 并打印一条警告。无论哪种情况,都要加上 .catch(),或者在 await 外面用 try/catch 处理。

Promise.all 如何处理错误?

只要任意一个输入的 Promise 被拒绝,Promise.all 就会立即拒绝,其他 Promise 会继续运行,但它们的结果会被丢弃。如果不管有没有失败,你都想拿到每一个结果,就用 Promise.allSettled:它会兑现为一个由 {status, value} 或 {status, reason} 条目组成的数组。

Coddy 编程语言插图

和 Coddy 一起学编程

开始