错误处理

在编程中,错误处理是一个复杂的主题,存在许多陷阱,当您在框架的限制内编写代码时,它可能会更加令人畏惧,因为处理错误的方式需要与框架调度错误的方式相一致,反之亦然。

本文概述了 JavaScript 框架和 Owl 如何处理错误,并就如何以避免常见问题的方式与这些系统进行交互提供了一些建议。

JavaScript 中的错误

在我们深入了解 Odoo 中如何处理错误以及如何以及在何处自定义错误处理行为之前,最好确保我们在“错误”的确切含义以及 JavaScript 中错误处理的一些特殊性方面达成共识。

Error

当我们谈论错误处理时,首先想到的可能是内置的 Error 类或扩展它的类。在本文的其余部分中,当我们引用作为此类实例的对象时,我们将使用斜体字术语“错误对象”。

什么东西都可以扔

在 JavaScript 中,您可以抛出任何值。通常抛出*错误对象*,但也可以抛出任何其他对象,甚至基元。虽然我们不建议您抛出任何不是 Error 对象 的内容,但 Odoo JavaScript 框架需要能够处理这些场景,这将帮助您理解我们必须做出的一些设计决策。

当实例化 *Error 对象*时,浏览器收集有关“调用堆栈”当前状态的信息(正确的调用堆栈,或者异步函数和 Promise 延续的重构调用堆栈)。此信息称为“堆栈跟踪”,对于调试非常有用。 Odoo 框架在错误对话框中显示此堆栈跟踪(如果可用)。

当抛出一个不是 Error 对象 的值时,浏览器仍然会收集有关当前调用堆栈的信息,但此信息在 JavaScript 中不可用:如果错误未处理,则仅在 devtools 控制台中可用。

抛出 Error 对象 使我们能够显示更详细的信息,如果错误报告需要,用户将能够复制/粘贴这些信息,但它也使错误处理更加健壮,因为它允许我们在处理错误时根据错误的类来过滤错误。不幸的是,JavaScript 不支持在 catch 子句中按错误类进行过滤,但您可以相对轻松地自己完成此操作:

try {
  doStuff();
} catch (e) {
  if (!(e instanceof MyErrorClass)) {
    throw e; // caught an error we can't handle, rethrow
  }
  // handle MyErrorClass
}

拒绝承诺是错误

在采用 Promise 的早期,Promise 通常被视为存储结果和“错误”的不相交并集的一种方式,并且使用 Promise 拒绝作为表示软故障的方式非常常见。虽然乍一看这似乎是一个好主意,但浏览器和 JavaScript 运行时早已开始在几乎所有方面以与抛出错误相同的方式对待被拒绝的 Promise:

  • 抛出一个异步函数与返回一个被拒绝的 Promise 具有相同的效果,并以抛出的值作为其拒绝原因。

  • 异步函数中的 catch 块捕获在相应的 try 块中等待的被拒绝的 Promise。

  • 运行时收集有关被拒绝的承诺的堆栈信息。

  • 未捕获的被拒绝的 Promise 会同步在全局/窗口对象上调度一个事件,如果该事件未调用 preventDefault ,浏览器会记录错误,并且像节点这样的独立运行时会终止该进程。

  • 当 Promise 被拒绝时,调试器功能“异常暂停”会暂停

由于这些原因,Odoo 框架以与抛出错误完全相同的方式处理被拒绝的 Promise。不要在不会抛出错误的地方创建被拒绝的 Promise,并且始终拒绝以 Error 对象 作为拒绝原因的 Promise。

error 事件不是错误

除了窗口上的 error 事件外,其他对象(例如 <media><audio> <img><script><link> 元素或 XMLHttpRequest 对象)上的 error 事件都不是错误。就本文而言,“错误”特指仅指抛出的值和被拒绝的承诺。如果您需要处理这些元素上的错误或希望将它们视为错误,则需要为所述事件显式添加事件侦听器:

const scriptEl = document.createElement("script");
scriptEl.src = "https://example.com/third_party_script.js";
return new Promise((resolve, reject) => {
  scriptEl.addEventListener("error", reject);
  scriptEl.addEventListener("load", resolve);
  document.head.append(scriptEl);
});

Odoo JS 框架内的错误生命周期

抛出的错误展开其调用堆栈以查找可以处理它们的 catch 子句。处理错误的方式取决于展开调用堆栈时遇到的代码。虽然实际上有无数个地方可能引发错误,但进入 JS 框架的错误处理代码的可能路径却很少。

在模块的顶层抛出错误

当加载 JS 模块时,会执行该模块顶层的代码,并且可能会抛出异常。虽然框架可能会通过对话框报告这些错误,但模块加载对于 JavaScript 框架来说是关键时刻,某些模块抛出错误可能会阻止框架代码完全启动,因此此阶段的任何错误报告都是“尽力而为”。然而,模块加载期间抛出的错误至少应该始终在浏览器控制台中记录一条错误消息。因为这种类型的错误很严重,所以应用程序无法恢复,并且您应该以模块在定义期间不可能抛出异常的方式编写代码。在此阶段发生的任何错误处理和报告纯粹是为了帮助您(开发人员)修复引发错误的代码,并且我们不提供自定义如何处理这些错误的机制。

错误服务

当引发错误但从未捕获错误时,运行时会在全局对象 (window) 上调度一个事件。事件的类型取决于错误是同步抛出还是异步抛出:同步抛出的错误调度 error 事件,从异步上下文中抛出的错误以及拒绝的 Promises 调度 unhandledrejection 事件。

JS 框架包含一个专门处理这些事件的服务:错误服务。当接收到这些事件之一时,错误服务首先创建一个新的 Error 对象,用于包装抛出的错误;这是因为可以抛出任何值,并且可以使用任何值拒绝 Promise,包括 undefinednull,这意味着不能保证它包含任何信息,或者我们可以存储有关该值的任何信息。包装*Error对象*用于收集有关抛出值的一些信息,以便可以在需要显示任何类型错误信息的框架代码中统一使用。

错误服务在此包装器*错误对象*上存储抛出错误的完整堆栈跟踪,并且当调试模式为 assets 时,使用源映射在此堆栈跟踪中添加有关包含每个堆栈帧的函数的源文件的信息。该函数在捆绑资产中的位置被保留,因为它在某些情况下可能很有用。当错误具有 cause 时,此过程还会展开 cause 链以构建完整的复合堆栈跟踪。虽然 Error 对象 上的 cause 字段是标准的,但一些主要浏览器仍然不显示错误链的完整堆栈跟踪。因此,我们手动添加此信息。当 Owl 钩子中抛出错误时,这特别有用,稍后会详细介绍。

一旦包装器错误包含所有必需的信息,我们就开始实际处理错误的过程。为此,错误服务会连续调用在 error_handlers 注册表中注册的所有函数,直到其中一个函数返回 true 值,这表明错误已被处理。此后,如果未在错误事件上调用 preventDefault,并且错误服务能够在包装器错误对象上添加堆栈跟踪,则错误服务会在错误事件上调用 preventDefault,并将堆栈跟踪记录在控制台中。这是因为,如前所述,某些浏览器无法正确显示错误链,而事件的默认行为是浏览器记录错误,因此我们只需覆盖该行为即可记录更完整的堆栈跟踪。如果错误服务无法收集有关引发的错误的堆栈跟踪信息,我们不会调用 preventDefault。当抛出非错误值时可能会发生这种情况:字符串、未定义或其他随机对象。在这些情况下,浏览器会自行记录堆栈跟踪,因为它拥有该信息,但不会将其公开给 JS 代码。

error_handlers 注册表

error_handlers 注册表是扩展 JS 框架处理“通用”错误方式的主要方式。在这种情况下,通用错误意味着可能在许多地方发生的错误,但应该统一处理。一些例子:

  • UserError:当用户尝试执行 python 代码因业务原因而认为无效的操作时,python 代码会引发 UserError,rpc 函数会在 JavaScript 中抛出相应的错误。这有可能发生在任何地方的任何 rpc 上,我们不希望开发人员必须在所有这些地方显式处理这种错误,并且我们希望在任何地方都发生相同的行为:停止当前正在执行的代码(这是通过 throw 实现的),并显示一个对话框,向用户解释出了什么问题。

  • AccessError:与用户错误的推理相同:它可以在任何时候发生,并且无论发生在哪里都应该以相同的方式显示

  • LostConnection:同样的推理。

在 Owl 组件中抛出错误

注册或修改 Owl 组件是扩展 Web 客户端功能的主要方式。因此,大多数抛出的错误都是以某种方式从 Owl 组件中抛出的。有几种可能的情况:

  • 放入组件的设置或渲染期间

  • 从生命周期钩子中抛出

  • 从事件处理程序抛出

从事件处理程序或从事件处理程序直接或间接调用的函数或方法抛出错误意味着 Owl 的代码和 JS 框架的代码都不在调用堆栈中。如果您没有捕获错误,它会直接进入错误服务。

当在组件的设置或渲染过程中抛出错误时,Owl 会捕获错误并向上移动组件层次结构,允许已使用 onError 挂钩注册错误处理程序的组件尝试处理错误。如果其中任何一个都没有处理错误,Owl 就会破坏应用程序,因为它可能处于损坏状态。

在 Odoo 内部,有些地方我们不希望整个应用程序在出现错误时崩溃,因此框架有几个地方使用 onError 钩子。操作服务将操作和视图包装在处理错误的组件中。如果客户端操作或视图在渲染期间抛出错误,它会尝试返回到上一个操作。该错误将被分派到错误服务,以便无论如何都可以显示错误对话框。在框架调用“用户”代码的大多数地方都使用类似的策略:我们通常停止显示有问题的组件并显示错误对话框。

当在钩子的回调函数中抛出错误时,Owl 会创建一个新的 Error 对象,其中包含有关钩子注册位置的堆栈信息,并将其原因设置为最初抛出的值。这是因为原始错误的堆栈跟踪不包含有关哪个组件在何处注册此钩子的信息,它仅包含有关调用该钩子的信息。因为钩子是由 Owl 代码调用的,所以大多数信息“通常”对开发人员来说不是很有用,但了解钩子在哪里注册以及由哪个组件注册是非常有用的。

当读取提及“OwlError:<hookName> 中发生以下错误”的错误时,请确保读取复合堆栈跟踪的两个部分:

Error: The following error occurred in onMounted: "My error"
  at wrapError
  at onMounted
  at MyComponent.setup
  at new ComponentNode
  at Root.template
  at MountFiber._render
  at MountFiber.render
  at ComponentNode.initiateRender

Caused by: Error: My error
  at ParentComponent.someMethod
  at MountFiber.complete
  at Scheduler.processFiber
  at Scheduler.processTasks

第一个突出显示的行告诉您哪个组件注册了 onMounted 挂钩,而第二个突出显示的行告诉您哪个函数引发了错误。在这种情况下,子组件正在调用从其父组件作为 prop 接收的函数,并且该函数是父组件的方法。这两条信息都可能有用,因为该方法可能被子级错误地调用(或者在生命周期中不应该调用的某个点),但也可能是父级的方法包含错误。

将错误标记为已处理

在前面的章节中,我们讨论了两种注册错误处理程序的方法:一种是将它们添加到 error_handlers 注册表中,另一种是使用 owl 中的 onError 钩子。在这两种情况下,处理程序都必须决定是否将错误标记为已处理。

onError

对于在 Owl 中使用 onError 注册的处理程序,该错误将被 Owl 视为已处理,除非您重新抛出该错误。无论您在 onError 中做什么,用户界面都可能与应用程序的状态不同步,因为错误阻止了 owl 完成某些工作。如果您无法处理该错误,则应该重新抛出该错误,并让其余代码处理它。

如果不重新抛出错误,则需要更改某些状态,以便应用程序可以以无错误的方式再次呈现。此时,如果不重新抛出该错误,则不会报告该错误。在某些情况下这是可取的,但在大多数情况下,您应该做的是在 Owl 外部的单独调用堆栈中分派此错误。最简单的方法是简单地创建一个被拒绝的 Promise,并将错误作为其拒绝原因:

import { Component, onError } from "@odoo/owl";
class MyComponent extends Component {
  setup() {
    onError((error) => {
      // implementation of this method is left as an exercise for the reader
      this.removeErroringSubcomponent();
      Promise.reject(error); // create a rejected Promise without passing it anywhere
    });
  }
}

这会导致浏览器在窗口上调度 unhandledrejection 事件,从而导致 JS 框架的错误处理启动并处理错误,在大多数情况下,通过打开一个包含错误信息的对话框来处理。这是操作服务和对话框服务在内部使用的策略,用于停止呈现损坏的操作或对话框,同时仍然报告错误。

error_handlers 注册表中的处理程序

添加到 error_handlers 注册表的处理程序可以将错误标记为正在以两种方式处理,具有不同的含义。

第一种方法是处理程序可以返回一个真值,这意味着处理程序已经处理了错误并发生了一些事情,因为它收到的错误与其能够处理的错误类型相匹配。这通常意味着它已打开一个对话框或通知来警告用户有关错误的信息。这可以防止错误服务调用以下具有更高序列号的处理程序。

另一种方法是在错误事件上调用 preventDefault:这有不同的含义。在确定它能够处理错误后,处理程序需要确定它收到的错误是否是正常操作期间允许发生的错误,如果是,则应调用 preventDefault。这通常适用于业务错误,例如访问错误或验证错误:用户可以与其他用户共享他们无权访问的资源的链接,并且用户可以尝试保存处于无效状态的记录。

当不调用 preventDefault 时,错误将被视为意外错误,测试期间发生任何此类错误都会导致测试失败,因为它通常表示有缺陷的代码。

尽可能避免抛出错误

错误以多种方式引入复杂性,以下是您应该避免抛出错误的一些原因。

错误的代价是昂贵的

由于错误需要展开调用堆栈并收集信息,因此抛出错误的速度很慢。此外,JavaScript 运行时通常是在假设异常很少发生的情况下进行优化的,因此通常会在不会抛出异常的情况下编译代码,如果抛出异常,则回退到较慢的代码路径。

抛出错误使调试变得更加困难

JavaScript 调试器(例如 Chrome 和 Firefox 开发工具中包含的调试器)具有一项功能,允许您在引发异常时暂停执行。您还可以选择是仅在捕获的异常上暂停,还是在捕获的异常和未捕获的异常上都暂停。

当您在 Owl 或 JavaScript 框架(例如在字段、视图、操作、组件等)调用的代码中抛出错误时,因为它们管理资源,所以需要捕获错误并检查错误以确定错误是否严重以及应用程序是否应该崩溃,或者错误是否是预期的并且应该以特定方式处理。

因此,几乎所有在 JavaScript 代码中抛出的错误都会在某个时刻被捕获,尽管如果无法处理它们可能会被重新抛出,但这意味着在 Odoo 中使用“未捕获异常时暂停”功能实际上是无用的,因为它总是在 JavaScript 框架代码中暂停,而不是在最初抛出错误的代码附近暂停。

然而,“捕获异常时暂停”功能仍然非常有用,因为它会在每个 throw 语句和拒绝的 Promise 上暂停执行。这允许开发人员在发生异常情况时停止并检查执行上下文。

然而,这仅在假设很少抛出异常的情况下成立。如果例行抛出异常,页面中的任何操作都可能导致调试器停止执行,开发人员可能需要单步执行许多“例行”异常,然后才能到达他们感兴趣的实际异常场景。在某些情况下,由于单击调试器中的播放按钮会从页面上移除焦点,甚至可能导致在不使用键盘快捷键恢复执行的情况下无法访问有趣的抛出场景,从而导致开发人员体验不佳。

抛出破坏了代码的正常流程

当抛出错误时,看起来应该总是执行的代码可能会被跳过,这可能会导致许多微妙的错误和内存泄漏。这是一个简单的例子:

eventTarget.addEventListener("event", handler);
someFunction();
eventTarget.removeEventListener("event", handler);

在此代码块中,我们将事件侦听器添加到事件目标,然后调用可以在该目标上调度事件的函数。函数调用后,我们删除事件监听器。

如果 someFunction 抛出异常,事件监听器将永远不会被删除。这意味着与此事件侦听器关联的内存实际上被泄漏,并且永远不会被释放,除非 eventTarget 本身被释放。

除了内存泄漏之外,仍然附加的处理程序意味着可能会因调用 someFunction 以外的原因而调用它来调度事件。这是一个错误。

为了解决这个问题,需要将调用包装在 try 块中,并将清理包装在 finally 块中:

eventTarget.addEventListener("event", handler);
try {
  someFunction();
} finally {
  eventTarget.removeEventListener("event", handler);
}

虽然现在可以避免上述问题,但这不仅需要更多代码,还需要了解该函数可能会抛出的异常。将所有可能抛出 try/finally 块的代码包装起来将是难以管理的。

捕获错误

有时,您需要调用已知会引发错误的代码,并且您想要处理其中一些错误。有两件重要的事情需要记住:

  • 重新抛出不是您期望的错误类型的错误。这通常应该通过 instanceof 检查来完成

  • 保持 try 块尽可能小。这可以避免捕获不是您想要捕获的错误。一般来说,try 块应该恰好包含 一个 语句。

let someVal;
try {
  someVal = someFunction();
  // do not start working with someVal here.
} catch (e) {
  if (!(e instanceof MyError)) {
    throw e;
  }
  someVal = null;
}
// start working with someVal here

虽然这对于 try/catch 来说很简单,但在使用 Promise.catch 时,更容易意外地将大部分代码包装在 catch 子句中:

someFunction().then((someVal) => {
  // work with someVal
}).catch((e) => {
  if (!(e instanceof MyError)) {
    throw e;
  }
  return null;
});

在这个例子中,catch块实际上是捕获整个then块中的错误,这不是我们想要的。在这个特定的示例中,因为我们根据错误类型进行了正确的过滤,所以我们不会吞掉错误,但是您可以看到,如果我们期望单个错误类型并决定不进行instanceof检查,那么这样做可能会容易得多。但请注意,与前面的示例不同,null 不会通过使用 someVal 的代码路径。为了避免这种情况,catch 子句通常应该尽可能接近可能抛出的 Promise,并且应该始终过滤错误类型。

无错误控制流程

由于上述原因,您应该避免在执行常规操作时抛出错误,特别是控制流。如果一个函数预计无法定期完成其工作,则它应该在不引发异常的情况下传达该失败。考虑示例代码:

let someVal;
try {
  someVal = someFunction();
} catch (e) {
  if (!(e instanceof MyError)) {
    throw e;
  }
  someVal = null;
}

这段代码有很多问题。首先,因为我们希望变量 someValtry/catch 块之后可以访问,所以需要在该块之前声明它,并且它不能是 const,因为它需要在初始化之后赋值。这会进一步损害可读性,因为您现在必须留意该变量可能会在代码中稍后重新分配。

其次,当我们捕获错误时,我们必须检查该错误是否确实是我们期望捕获的错误类型,如果不是,则重新抛出该错误。如果我们不这样做,我们最终可能会吞掉“实际上”意外的错误,而不是正确地报告它们,例如如果底层代码尝试访问 nullundefined 上的属性,我们可能会捕获并吞掉 TypeError。

最后,这不仅非常冗长,而且很容易错误地执行此操作:如果您忘记添加 try/catch,则可能会得到回溯。如果添加 try/catch 块但忘记重新抛出意外错误,则您将吞掉不相关的错误。如果您想避免重新分配变量,您可以将使用该变量的整个块移动到 try 块内。 try 块中的代码越多,您就越有可能捕获不相关的错误,如果您忘记按错误类型进行过滤,则吞下它们。它还为整个块添加了缩进级别,您甚至可能最终得到嵌套的 try/catch 块。最后,它使得识别实际预期哪一行会抛出错误变得更加困难。

以下部分概述了您可以使用的一些替代方法,而不是使用错误。

返回 nullundefined

如果函数返回基元或对象,通常可以使用 nullundefined 来表示它无法完成其预期工作。这在大多数情况下就足够了。代码最终看起来像这样:

const someVal = someFunction();
// further
if (someVal !== null) { /* do something */ }

正如您所看到的,这要简单得多。

返回一个对象或数组

在某些情况下,nullundefined 值是预期返回值的一部分。在这些情况下,您可以返回一个包装对象或包含返回值或错误的二元素数组:

const { val: someVal, err } = someFunction();
if (err) {
  return;
}
// do something with someVal as it is known to be valid

或者使用数组:

const [err, someVal] = someFunction();
if (err) {
  return;
}
// do something with someVal as it is known to be valid

注解

使用二元素数组时,建议将错误放在第一个元素,这样解构时就更难忽略错误。需要显式添加占位符或逗号来跳过错误,而如果错误是第二个元素,则很容易简单地仅解构第一个元素并错误地忘记处理错误。

何时抛出错误

前面几节给出了避免抛出错误的许多充分理由,那么抛出错误是最佳操作方案的一些例子是什么?

  • 通用错误可能发生在很多地方,但在任何地方都应该得到相同的对待;例如,访问错误基本上可以发生在任何 RPC 上,并且我们总是希望显示有关用户没有访问权限的原因的信息。

  • 某些操作应始终满足的某些前提条件未满足;例如,由于域无效,无法呈现视图。这些类型的错误通常不会在任何地方被捕获,也不会发出代码不正确或数据损坏的信号。抛出会迫使框架跳出并防止在损坏状态下运行。

  • 当递归地遍历某些深层数据结构时,与必须手动测试错误并通过多个级别的调用转发错误相比,抛出错误可能更符合人体工程学并且更不容易出错。这在实践中应该是非常罕见的,需要权衡本文提到的所有缺点。