在 Chrome 开发者工具中捕获预测:为什么很难以及如何改进

Eric Leese
Eric Leese

调试 Web 应用中的异常似乎很简单:在出现问题时暂停执行并进行调查。但 JavaScript 的异步特性使得这一过程出乎意料地复杂。当异常通过 Promise 和异步函数飞行时,Chrome DevTools 如何知道何时何地暂停?

本文深入探讨了捕获预测的挑战:开发者工具能够预测代码中稍后是否会捕获异常。我们将探讨为什么这项工作如此棘手,以及 V8(为 Chrome 提供支持的 JavaScript 引擎)最近的改进如何使其更加准确,从而带来更顺畅的调试体验。

为什么捕获预测很重要

在 Chrome DevTools 中,您可以选择仅针对未捕获的异常暂停代码执行,而跳过已捕获的异常。

Chrome DevTools 提供了分别针对已捕获或未捕获异常的暂停选项

在后台,调试程序会在发生异常时立即停止,以保留上下文。这是一个预测,因为目前无法确定代码稍后是否会捕获异常,尤其是在异步场景中。这种不确定性源于预测程序行为的固有难度,类似于停止问题

请考虑以下示例:调试程序应在何处暂停?(请参阅下一部分,了解答案。)

async function inner() {
  throw new Error(); // Should the debugger pause here?
}

async function outer() {
  try {
    const promise = inner();
    // ...
    await promise;
  } catch (e) {
    // ... or should the debugger pause here?
  }
}

在调试程序中遇到异常时暂停可能会造成干扰,并导致频繁的中断和跳转到陌生的代码。为缓解此问题,您可以选择仅调试未捕获的异常,因为这些异常更有可能表明存在实际 bug。不过,这取决于捕获预测的准确性。

错误的预测会导致用户感到失望:

  • 假负例(预测“未捕获”时实际会被捕获)。调试程序中不必要的停止。
  • 假正例(在未被捕获时预测为“已捕获”)。错失捕获严重错误的机会,可能会迫使您调试所有异常(包括预期的异常)。

减少调试中断的另一种方法是使用忽略列表,该列表可防止在指定第三方代码中出现异常时中断。不过,准确的捕获预测仍然至关重要。如果源自第三方代码的异常逃逸并影响了您自己的代码,您需要能够对其进行调试。

异步代码的运作方式

Promise、asyncawait 以及其他异步模式可能会导致以下场景:在被处理之前,异常或拒绝可能会采用在抛出异常时难以确定的执行路径。这是因为在异常发生之前,无法等待 promise 或添加 catch 处理脚本。我们来看一下上一个示例:

async function inner() {
  throw new Error();
}

async function outer() {
  try {
    const promise = inner();
    // ...
    await promise;
  } catch (e) {
    // ...
  }
}

在此示例中,outer() 会先调用 inner(),后者会立即抛出异常。由此,调试程序可以得出结论,inner() 将返回一个被拒绝的 promise,但目前没有任何代码正在等待或以其他方式处理该 promise。调试程序可以推测 outer() 可能会等待它,并推测它会在当前的 try 代码块中执行此操作,因此会进行处理,但在返回被拒绝的 promise 并最终到达 await 语句之前,调试程序无法确定这一点。

调试程序无法保证捕获预测的准确性,但它会针对常见的编码模式使用各种启发词语来进行正确预测。如需了解这些模式,了解 promise 的运作方式会很有帮助。

在 V8 中,JavaScript Promise 表示为一个对象,该对象可以处于以下三种状态之一:已执行、已拒绝或待处理。如果 promise 处于已执行状态,并且您调用了 .then() 方法,系统会创建一个新的待处理 promise 并安排一个新的 promise 回应任务,该任务将运行处理程序,然后使用处理程序的结果将 promise 设为已执行,或者在处理程序抛出异常时将其设为已拒绝。如果您对已被拒绝的 promise 调用 .catch() 方法,也会发生同样的情况。相反,对已遭拒 promise 调用 .then(),或对已执行 promise 调用 .catch(),将返回处于相同状态的 promise,并且不会运行处理脚本。

待处理 promise 包含一个回应列表,其中每个回应对象都包含一个执行处理脚本或拒绝处理脚本(或两者兼有)和一个回应 promise。因此,对待处理的 promise 调用 .then() 会添加一个带有已处理处理程序的回应,以及一个新的待处理 promise(.then() 将返回该 promise)。调用 .catch() 会添加类似的回应,但会附带拒绝处理脚本。使用两个参数调用 .then() 会创建包含两个处理脚本的回应,而调用 .finally() 或等待 promise 会添加包含两个处理脚本的回应,这两个处理脚本是专门用于实现这些功能的内置函数。

当待处理的 Promise 最终完成或被拒绝时,系统会为其所有已完成的处理脚本或所有已拒绝的处理脚本安排相应回应作业。然后,相应的回应 promise 将更新,可能会触发它们自己的回应作业。

示例

请参考以下代码:

return new Promise(() => {throw new Error();})
    .then(() => console.log('Never happened'))
    .catch(() => console.log('Caught'));

此代码涉及三个不同的 Promise 对象,这可能并不明显。上述代码等同于以下代码:

const promise1 = new Promise(() => {throw new Error();});
const promise2 = promise1.then(() => console.log('Never happened'));
const promise3 = promise2.catch(() => console.log('Caught'));
return promise3;

在此示例中,会发生以下步骤:

  1. 调用 Promise 构造函数。
  2. 系统会创建一个新的待处理 Promise
  3. 运行匿名函数。
  4. 系统会抛出异常。此时,调试程序需要决定是否停止。
  5. promise 构造函数会捕获此异常,然后将其 promise 的状态更改为 rejected,并将其值设置为抛出的错误。它会返回此 promise,该 promise 存储在 promise1 中。
  6. .then() 不会调度任何回应作业,因为 promise1 处于 rejected 状态。而是返回一个新的 promise (promise2),该 promise 也处于被拒绝状态并包含相同的错误。
  7. .catch() 使用提供的处理程序和一个新的待处理回应 promise(以 promise3 的形式返回)安排回应作业。此时,调试程序知道该错误将得到处理。
  8. 运行回应任务时,处理脚本会正常返回,并且 promise3 的状态会更改为 fulfilled

下一个示例的结构类似,但执行方式完全不同:

return Promise.resolve()
    .then(() => {throw new Error();})
    .then(() => console.log('Never happened'))
    .catch(() => console.log('Caught'));

这等同于:

const promise1 = Promise.resolve();
const promise2 = promise1.then(() => {throw new Error();});
const promise3 = promise2.then(() => console.log('Never happened'));
const promise4 = promise3.catch(() => console.log('Caught'));
return promise4;

在此示例中,会发生以下步骤:

  1. 系统会在 fulfilled 状态下创建 Promise,并将其存储在 promise1 中。
  2. 系统会使用第一个匿名函数调度 Promise 回应任务,并将其 (pending) 回应 Promise 作为 promise2 返回。
  3. 将回应添加到 promise2 中,其中包含已执行的处理脚本及其回应 promise,该 promise 会作为 promise3 返回。
  4. promise3 添加了回应,其中包含已被拒绝的处理脚本和另一个回应 promise,该 promise 会作为 promise4 返回。
  5. 系统会运行在第 2 步中安排的回应任务。
  6. 处理程序会抛出异常。此时,调试程序需要决定是否停止。目前,处理脚本是您唯一正在运行的 JavaScript 代码。
  7. 由于任务以异常结束,因此关联的回应 promise (promise2) 会设为已拒绝状态,其值设为抛出的错误。
  8. 由于 promise2 只有一个回应,并且该回应没有被拒绝的处理脚本,因此其回应 promise (promise3) 也设置为 rejected,并包含相同的错误。
  9. 由于 promise3 只有一个回应,并且该回应确实有一个被拒绝的处理脚本,因此系统会使用该处理脚本及其回应 Promise (promise4) 调度 Promise 回应任务。
  10. 当该回应任务运行时,处理脚本会正常返回,并且 promise4 的状态会更改为已执行。

捕获预测的方法

捕获预测有两个可能的信息来源。一个是调用堆栈。对于同步异常,这种做法是合理的:调试程序可以像异常展开代码一样遍历调用堆栈,如果它发现帧位于 try...catch 代码块中,则会停止。对于 Promise 构造函数或从未暂停的异步函数中的被拒 Promise 或异常,调试程序也依赖于调用堆栈,但在这种情况下,其预测在所有情况下都不可靠。这是因为,异步代码会返回被拒绝的异常,而不是向最近的处理程序抛出异常,因此调试程序必须对调用方将如何处理该异常做出一些假设。

首先,调试程序假定接收返回的 promise 的函数可能会返回该 promise 或派生 promise,以便堆栈上层的异步函数有机会等待它。其次,调试程序假定,如果将 promise 返回给异步函数,该函数会立即等待它,而无需先进入或离开 try...catch 代码块。这两个假设都不能保证正确无误,但对于使用异步函数的最常见编码模式,它们足以做出正确的预测。在 Chrome 125 版中,我们添加了另一种启发词语:调试程序会检查调用方是否即将对要返回的值调用 .catch()(或带有两个实参的 .then(),或对 .then().finally() 的调用链,后跟 .catch() 或带有两个实参的 .then())。在这种情况下,调试程序会假定这些是正在跟踪的 Promise 上的方法或与之相关的方法,因此会捕获拒绝。

第二个信息来源是 Promise 回应的树。调试程序从根 Promise 开始。有时,这是一个刚刚调用其 reject() 方法的 Promise。更常见的情况是,当 Promise 回应作业期间发生异常或被拒绝,并且调用堆栈上似乎没有任何内容可以捕获它时,调试程序会从与该回应关联的 Promise 进行跟踪。调试程序会查看待处理 promise 的所有回应,并确定它们是否具有拒绝处理脚本。如果任何回应都没有,它会查看回应 promise 并从中递归跟踪。如果所有回应最终都会导致拒绝处理脚本,则调试程序会认为已捕获 Promise 拒绝。我们还需要涵盖一些特殊情况,例如,不计入 .finally() 调用的内置拒绝处理脚本。

通常,如果有信息,承诺回应树会提供可靠的信息来源。在某些情况下(例如调用 Promise.reject() 或在 Promise 构造函数中,或者在尚未等待任何内容的异步函数中),系统不会跟踪任何反应,调试程序必须仅依赖于调用堆栈。在其他情况下,Promise 回应树通常包含推断捕获预测所需的处理程序,但后续可能会添加更多处理程序,将异常从捕获状态更改为未捕获状态,反之亦然。还有一些 promise,例如由 Promise.all/any/race 创建的 promise,其中组中的其他 promise 可能会影响对拒绝的处理方式。对于这些方法,调试程序会假定如果 promise 仍处于待处理状态,则会转发 promise 拒绝。

请查看以下两个示例:

两个捕获预测示例

虽然这两个捕获的异常示例看起来很相似,但它们需要完全不同的捕获预测启发词语。在第一个示例中,系统会创建一个已解析的 Promise,然后安排一个会抛出异常的 .then() 回应作业,然后调用 .catch() 以将拒绝处理程序附加到回应 Promise。运行回应任务时,系统会抛出异常,并且 Promise 回应树将包含捕获处理脚本,因此系统会检测到异常已被捕获。在第二个示例中,在运行用于添加 catch 处理程序的代码之前,系统会立即拒绝 promise,因此 promise 的响应树中没有拒绝处理程序。调试程序必须查看调用堆栈,但也找不到 try...catch 代码块。为了正确预测这一点,调试程序会在代码的当前位置前面扫描以查找对 .catch() 的调用,并据此假定最终会处理拒绝。

摘要

希望上述说明能让您了解捕获预测在 Chrome 开发者工具中的运作方式、优势和局限性。如果您因预测错误而遇到调试问题,请考虑以下选项:

  • 将编码模式更改为更易于预测的内容,例如使用异步函数。
  • 如果开发者工具未在应停止时停止,请选择在所有异常时中断。
  • 如果调试程序在您不希望的位置停止,请使用“永不在此处暂停”断点或条件断点。

致谢

我们深表谢意,Sofia Emelianova 和 Jecelyn Yeen 在编辑本文时提供了宝贵帮助!