作者:21Pilots


一次代码CR引发的困惑


“你这块的代码,没有做异常捕获呀,要是抛出了异常,可能会影响后续的代码流程”。这是一段出自组内代码CR群的聊天记录。代码类似如下:

const asyncErrorThrow = () => {
  return new Promise((resolve, reject) => {
    // 业务代码...
    // 假设这里抛出了错误
    throw new Error('抛出错误');
    // 业务代码...
  })
}
const testFun = async () => {
  await asyncErrorThrow();
  console.log("async 函数中的后续流程"); // 不会执行
}
testFun();


testFun 函数中,抛出错误后,await 函数中后续流程不会执行。

仔细回想一下,在我的前端日常开发中,对于错误捕获,还基本停留在使用 Promise时用 catch 捕获一下 Promise 中抛出的错误或者 reject,或者最基本的,在使用 JSON.parseJSON.stringfy等容易出错的方法中,使用 try..catch... 方法捕获一下可能出现的错误。

后来,这个同学将代码改成了:


const asyncErrorThrow = () => {
  return new Promise((resolve, reject) => {
    // 业务代码...
    throw new Error('抛出错误');
    // 业务代码...
  })
}
const testFun = async () => {
  try {
    await asyncErrorThrow();
    console.log("async 函数中的后续流程"); // 不会执行
  } catch (error) {
    console.log("若错误发生 async 函数中的后续流程"); // 会执行
  } 
}
testFun();


而这次不同的是,这段修改后的代码中使用了 try...catch...来捕获 async...await... 函数中的错误,这着实让我有些困惑,让我来写的话,我可能会在 await 函数的后面增加一个 catch:await asyncErrorThrow().catch(error => {})。因为我之前已经对 try..catch 只能捕获发生在当前执行上下文的错误(或者简单理解成同步代码的错误)有了一定的认知,但是 async...await... 其实还是异步的代码,只不过用的是同步的写法,为啥用在这里就可以捕获到错误了呢?在查阅了相当多的资料之后,才清楚了其中的一些原理。


<顺便吆喝一句内推,技术大厂捞人捞人,前后端测试,东莞深圳等地急招~感兴趣来>


Promise 中的错误


我们都知道,一个 Promise 必然处于以下几种状态之一:

  • 待定(pending):初始状态,既没有被兑现,也没有被拒绝。
  • 已兑现(fulfilled):意味着操作成功完成。
  • 已拒绝(rejected):意味着操作失败。

当一个 Promise 被 reject 时,该 Promise 会变为 rejected 状态,控制权将移交至最近的 rejection 处理程序。最常见的 rejection 处理程序就是 catch handler或者 then 函数的第二个回调函数。而如果在 Promise 中抛出了一个错误。这个 Promise 会直接变成 rejected 状态,控制权移交至最近的 error 处理程序。


const function myExecutorFunc = () => {
  // 同步代码
  throw new Error();
};
new Promise(myExecutorFunc);


Promise 的构造函数需要传入的 Executor 函数参数,实际上是一段同步代码。在我们 new 一个新的 Promise 时,这个 Executor 就会立即被塞入到当前的执行上下文栈中进行执行。但是,在 Executor 中 throw 出的错误,并不会被外层的 try...catch 捕获到。


const myExecutorFunc = () => {
  // 同步代码
  throw new Error();
};
try {
  new Promise(myExecutorFunc);
} catch (error) {
  console.log('不会执行: ', error);
}
console.log('会执行的'); // 打印


其原因是因为,在 Executor 函数执行的过程中,实际上有一个隐藏的机制,当同步抛出错误时,相当于执行了 reject 回调,让该 Promise 进入 rejected 状态。而错误不会影响到外层的代码执行。


const myExecutorFunc = () => {
  throw new Error();
  // 等同于
  reject(new Error());
};
new Promise(myExecutorFunc);
console.log('会执行的'); // 打印


同理 then 回调函数也是这样的,抛出的错误同样会变成 reject。

在一个普通脚本执行中,我们知道抛出一个错误,如果没有被捕获掉,会影响到后续代码的执行,而在 Promise 中,这个错误不会影响到外部代码的执行。对于 Promise 没有被捕获的错误,我们可以通过特定的事件处理函数来观察到。


new Promise(function() {
  throw new Error("");
}); // 没有用来处理 error 的 catch
// Web 标准实现
window.addEventListener('unhandledrejection', function(event) {
  console.log(event);
  // 可以在这里采取其他措施,如日志记录或应用程序关闭
});
// Node 下的实现
process.on('unhandledRejection', (event) => {
  console.log(event);
  // 可以在这里采取其他措施,如日志记录或应用程序关闭
});


Promise 是这样实现的,我们可以想一想为什么要这样实现。我看到一个比较好的回答是这个:

传送门。我也比较赞成他的说法,我觉得,Promise 的诞生是为了解决异步函数过多而形成的回调地狱,使用了微任务的底层机制来实现异步链式调用。理论上是可以将同步的错误向上冒泡抛出然后用 try...catch... 接住的,异步的一些错误用 catch handler 统一处理,但是这样做的话会使得 Promise 的错误捕获使用起来不够直观,如果同步的错误也进行 reject 的话,实际上我们处理错误的方式就可以统一成 Promise catch handler 了,这样其实更直观也更容易让开发者理解和编写代码。


async await 的问题


那么回到我们最开始的问题,在这个里面,为什么 try catch 能够捕获到错误?

jconst asyncErrorThrow = () => {
  return new Promise((resolve, reject) => {
    // 业务代码...
    throw new Error('抛出错误');
    // 业务代码...
  })
}
const testFun = async () => {
  try {
    await asyncErrorThrow();
    console.log("async 函数中的后续流程"); // 不会执行
  } catch (error) {
    console.log("若错误发生 async 函数中的后续流程"); // 会执行
  } 
}
testFun();


我思考了很久,最后还是从黄玄大佬的知乎回答中窥见的一部分原理。


这...难道就是浏览器底层帮我们处理的事儿吗,不然也没法解释了。唯一能够解释的事就是,async await 原本就是为了让开发者使用同步的写法编写异步代码,目的是消除过多的 Promise 调用链,我们在使用 async await 时,最好就是不使用 .catch 来捕获错误了,而直接能使用同步的 try...catch... 语法来捕获错误。即使 .catch 也能做同样的事情。只是说,代码编写风格统一性的问题让我们原本能之间用同步语法捕获的错误,就不需要使用 .catch 链式调用了,否则代码风格看起来会有点“异类”。

这就是为什么 async MDN 中会有这样一句解释:



参考文档:

《使用Promise进行错误治理》- zh.javascript.info/promise-err…

《为什么try catch能捕捉 await 后 promise 错误? 和执行栈有关系吗?》www.zhihu.com/question/52…

还没有评论,抢个沙发!