错误处理

在编程中,错误处理是一个复杂且充满陷阱的话题,当你在框架的约束下编写代码时,它可能更加令人望而生畏,因为你处理错误的方式需要与框架调度错误的方式相协调,反之亦然。

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

JavaScript 中的错误

在深入探讨Odoo如何处理错误以及如何及在哪里自定义错误处理行为之前,确保我们在“错误”的确切含义以及JavaScript中错误处理的一些特性上达成共识,是一个不错的主意。

Error

当我们谈论错误处理时,首先想到的可能是内置的 Error 类或其扩展类。在本文的其余部分,当我们提到该类的一个实例对象时,我们将使用斜体的术语 Error object

任何东西都可以被抛出

在 JavaScript 中,你可以抛出任何值。通常我们会抛出 Error 对象,但实际上也可以抛出其他对象,甚至是原始值。虽然我们不建议你抛出任何非 Error 对象 的内容,但 Odoo JavaScript 框架需要能够处理这些情况,这将帮助你理解我们不得不做出的一些设计决策。

当实例化一个 Error 对象 时,浏览器会收集有关当前 “调用堆栈” 状态的信息(无论是正常的调用堆栈,还是为异步函数和 promise 延续重建的调用堆栈)。 此信息称为 “堆栈跟踪”,对于调试非常有用。Odoo 框架在可用时会在错误对话框中显示此堆栈跟踪。

当抛出的值不是 Error 对象 时,浏览器仍然会收集当前调用堆栈的信息,但这些信息在 JavaScript 中不可用:只有在未处理错误时,才能在开发者工具控制台中查看这些信息。

抛出 Error objects 使我们能够显示更详细的信息,用户可以在需要时复制/粘贴以用于错误报告,但它也使错误处理更加健壮,因为它允许我们在处理错误时根据其类别进行过滤。不幸的是,JavaScript 在 catch 子句中没有语法支持按错误类别进行过滤,但你可以相对容易地自己实现:

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

Promise rejections 是错误

在 Promise 被广泛采用的早期,Promises 常被视为一种存储结果与“错误”不相交联合的方式,并且使用 Promise 拒绝来作为软故障的信号相当普遍。虽然乍一看这似乎是个好主意,但浏览器和 JavaScript 运行时早已开始以几乎相同的方式对待被拒绝的 Promise 和抛出的错误:

  • 在异步函数中抛出异常的效果等同于返回一个被拒绝的 Promise ,其拒绝原因即为抛出的值。

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

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

  • 一个未被同步捕获的 rejected Promise 会在全局/窗口对象上派发一个事件,如果未对该事件调用 preventDefault,浏览器会记录一个错误,而像 node 这样的独立运行时则会终止进程。

  • 调试器功能 在异常时暂停 会在 Promises 被拒绝时暂停

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

error events are not errors

除了窗口上的 error 事件外,其他对象(如 <media><audio> <img><script><link> 元素)或 XMLHttpRequest 对象上的 error 事件并不被视为错误。在本文中,”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 事件,而从异步上下文中抛出的错误以及被拒绝的 Promise 会派发一个 unhandledrejection 事件。

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

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

一旦包装错误包含所有必要信息,我们便开始实际处理错误的过程。为此,错误服务依次调用注册在 error_handlers 注册表中的所有函数,直到其中一个函数返回真值,这表示错误已被处理。之后,如果未在错误事件上调用 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: the following error occurred in <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 的函数,而该函数是父组件的一个方法。这两条信息都可能有用,因为该方法可能是被子组件错误调用的(或在生命周期中不应该调用的时刻),但也可能是父组件的方法本身存在 bug。

将错误标记为已处理

在前面的章节中,我们讨论了两种注册错误处理程序的方法:一种是将它们添加到 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 框架的错误处理机制介入并处理错误,在大多数情况下,会打开一个包含错误信息的对话框。这是动作服务和对话框服务内部使用的策略,用于停止渲染损坏的动作或对话框,同时仍然报告错误。

Handler in the 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 抛出异常,事件监听器将永远不会被移除。这意味着与该事件监听器相关的内存实际上被泄漏了,除非事件目标本身被释放,否则这些内存将永远不会被释放。

除了内存泄漏之外,处理程序仍然被附加意味着它可能会因为调用 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 上,我们总是希望显示有关用户为何没有访问权限的信息。

  • 某些操作应始终满足的前提条件未得到满足;例如,由于域无效而无法渲染视图。这些类型的错误通常不打算在任何地方捕获,并且表明代码不正确或数据已损坏。抛出错误会强制框架退出,并防止在损坏的状态下运行。

  • 在递归遍历某些深层数据结构时,抛出错误可能比手动测试错误并通过多层调用传递错误更加符合人体工程学且不易出错。这种情况在实践中应非常罕见,并且需要权衡本文提到的所有缺点。