错误处理¶
在编程中,错误处理是一个复杂且充满陷阱的主题,而当您在框架的约束下编写代码时,这一问题变得更加棘手,因为您处理错误的方式需要与框架分发错误的方式相匹配,反之亦然。
本文概述了 JavaScript 框架和 Owl 处理错误的方式,并提供了一些建议,说明如何以避免常见问题的方式与这些系统进行交互。
JavaScript 中的错误¶
在深入探讨 Odoo 中错误处理的方式以及如何和在何处自定义错误处理行为之前,有必要确保我们对“错误”的定义以及 JavaScript 错误处理的一些特性有一致的理解。
Error
类¶
当我们谈论错误处理时,首先想到的可能是内置的 Error
类或其扩展类。在本文的其余部分中,当我们提到此类的对象实例时,将使用斜体的术语 Error 对象。
任何内容都可以抛出¶
在 JavaScript 中,您可以抛出任何值。通常会抛出 Error 对象,但也可以抛出其他对象,甚至是原始类型。虽然我们不建议您抛出非 Error 对象 的内容,但 Odoo JavaScript 框架需要能够处理这些情况,这有助于您理解我们做出的一些设计决策。
当实例化一个 Error 对象 时,浏览器会收集有关当前“调用栈”状态的信息(无论是正常的调用栈还是为异步函数和 Promise 延续重建的调用栈)。这些信息称为“堆栈跟踪”,对调试非常有用。Odoo 框架会在可用时在错误对话框中显示此堆栈跟踪。
当抛出的值不是 Error 对象 时,浏览器仍然会收集有关当前调用栈的信息,但这些信息在 JavaScript 中不可用:仅在未处理错误时可通过开发者工具控制台查看。
抛出 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 通常被视为存储结果和“错误”分离联合的一种方式,并且经常使用 Promise 拒绝来表示软失败。尽管乍一看这似乎是个好主意,但浏览器和 JavaScript 运行时早已开始在几乎所有方面将被拒绝的 Promise 视为与抛出的错误相同:
在异步函数中抛出错误的效果与返回一个以抛出值作为拒绝原因的被拒绝的 Promise 相同。
异步函数中的 catch 块会捕获在相应 try 块中 await 的被拒绝的 Promise。
运行时会收集关于被拒绝的 Promise 的堆栈信息。
未被同步捕获的被拒绝的 Promise 会在全局/窗口对象上触发事件,如果未对该事件调用
preventDefault
,浏览器会记录错误,独立运行时(如 Node.js)会终止进程。调试器功能“在异常处暂停”会在 Promise 被拒绝时暂停
基于这些原因,Odoo 框架以完全相同的方式对待被拒绝的 Promise 和抛出的错误。不要在不会抛出错误的地方创建被拒绝的 Promise,并始终使用 Error 对象 作为拒绝原因来拒绝 Promise。
error
事件不是错误¶
除了窗口上的 error
事件外,其他对象(例如 <media>
、<audio>
、<img>
、<script>
和 <link>
元素或 XMLHttpRequest 对象)上的 error
事件并不是错误。在本文中,“错误”特指抛出的值和被拒绝的 Promise。如果您需要处理这些元素上的错误或希望将它们视为错误,则需要为这些事件显式添加事件监听器:
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 对象 来包装被抛出的错误;这是因为可以抛出任何值,Promise 也可以使用任何值(包括 undefined
或 null
)被拒绝,这意味着它不一定包含任何信息,或者我们可以在该值上存储任何信息。这个包装的 Error 对象 用于收集有关抛出值的一些信息,以便在需要显示任何类型错误信息的框架代码中统一使用。
错误服务会在这个包装的 Error 对象 上存储被抛出错误的完整堆栈跟踪,并且当调试模式为 assets
时,使用源映射在此堆栈跟踪中添加有关包含每个堆栈帧函数的源文件的信息。函数在打包资源中的位置会被保留,因为在某些场景下这可能是有用的。当错误具有 cause
时,此过程还会展开 cause
链以构建完整的复合堆栈跟踪。尽管 Error 对象 上的 cause
字段是标准的,但一些主流浏览器仍然不会显示错误链的完整堆栈跟踪。因此,我们会手动添加这些信息。这在 Owl 钩子中抛出错误时特别有用,稍后会详细介绍。
一旦包装的错误包含了所有必需的信息,我们就开始实际处理错误的过程。为此,错误服务会依次调用注册在 error_handlers
注册表中的所有函数,直到其中一个函数返回真值,这表示错误已被处理。在此之后,如果未在错误事件上调用 preventDefault
,并且错误服务能够在包装的错误对象上添加堆栈跟踪,则错误服务会在错误事件上调用 preventDefault
并在控制台中记录堆栈跟踪。这是因为,正如之前提到的,一些浏览器无法正确显示错误链,默认情况下事件的行为是浏览器记录错误,因此我们只是覆盖了该行为以记录更完整的堆栈跟踪。如果错误服务无法收集有关抛出错误的堆栈跟踪信息,则不会调用 preventDefault
。这种情况可能发生在抛出非错误值时:字符串、undefined
或其他随机对象。在这些情况下,浏览器会自行记录堆栈跟踪,因为它拥有这些信息,但不会将其暴露给 JS 代码。
error_handlers
注册表¶
error_handlers
注册表是扩展 JS 框架处理“通用”错误的主要方式。在此上下文中,通用错误是指可能在许多地方发生的、但应统一处理的错误。以下是一些示例:
UserError:当用户尝试执行 Python 代码认为出于业务原因无效的操作时,Python 代码会引发 UserError,而 rpc 函数会在 JavaScript 中抛出相应的错误。这种情况可能发生在任何地方的任何 rpc 上,我们不希望开发者必须在所有这些地方显式处理这种错误,并且我们希望行为保持一致:停止当前正在执行的代码(通过抛出实现),并显示一个对话框向用户解释发生了什么问题。
AccessError:与用户错误的逻辑相同:它可能在任何时候发生,并且无论发生在哪里,都应该以相同的方式显示。
LostConnection:再次遵循相同的逻辑。
在 Owl 组件中抛出错误¶
注册或修改 Owl 组件是扩展 Web 客户端功能的主要方式。因此,大多数抛出的错误都是以某种方式从 Owl 组件中抛出的。以下是几种可能的场景:
在组件的 setup 或渲染期间抛出错误
从生命周期钩子中抛出错误
从事件处理程序中抛出错误
从事件处理程序或直接或间接由事件处理程序调用的函数或方法中抛出错误意味着 Owl 的代码和 JS 框架的代码都不在调用栈中。如果您没有捕获错误,它将直接进入错误服务。
当在组件的 setup 或渲染期间抛出错误时,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
钩子,而第二行高亮内容告诉您哪个函数抛出了错误。在这种情况下,子组件调用了从父组件接收到的一个函数,而该函数是父组件的方法。这两部分信息都很有用,因为可能是子组件错误地调用了该方法(或者在生命周期的某个不应调用的时刻调用了它),但也可能是父组件的方法中存在错误。
标记错误为已处理¶
在前面的部分中,我们讨论了注册错误处理程序的两种方式:一种是将它们添加到 error_handlers
注册表中,另一种是在 Owl 中使用 onError
钩子。在这两种情况下,处理程序都需要决定是否将错误标记为已处理。
onError
¶
对于使用 onError
在 Owl 中注册的处理程序,除非您重新抛出错误,否则 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 运行时通常基于异常很少发生的假设进行优化,因此通常会假设代码不会抛出错误进行编译,并在确实抛出错误时回退到较慢的代码路径。
抛出错误会使调试更加困难¶
例如,Chrome 和 Firefox 开发者工具中包含的 JavaScript 调试器具有在抛出异常时暂停执行的功能。您可以选择仅在捕获的异常上暂停,还是在捕获和未捕获的异常上都暂停。
当您在由 Owl 或 JavaScript 框架调用的代码中抛出错误时(例如在字段、视图、动作、组件等中),由于它们管理资源,因此需要捕获错误并检查它们,以决定错误是否严重到足以导致应用程序崩溃,或者错误是否是预期的并应以特定方式处理。
因此,几乎所有的 JavaScript 代码中抛出的错误都会在某个时刻被捕获,即使它们在无法处理时可能会被重新抛出。这意味着在 Odoo 中工作时,“在未捕获的异常上暂停”功能实际上毫无用处,因为它总是在 JavaScript 框架代码中暂停,而不是在最初抛出错误的代码附近暂停。
然而,“在捕获的异常上暂停”功能仍然非常有用,因为它会在每个抛出语句和被拒绝的 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;
}
这段代码存在许多问题。首先,由于我们希望变量 someVal
在 try/catch
块之后仍然可访问,因此它需要在该块之前声明,并且不能是 const
,因为它需要在初始化后赋值。这会损害代码的可读性,因为您现在必须留意此变量是否可能在代码的后续部分被重新赋值。
其次,当我们捕获错误时,必须检查该错误是否确实是我们希望捕获的错误类型,如果不是,则需要重新抛出错误。如果我们不这样做,可能会吞掉那些 实际上 意外的错误,而不是正确报告它们。例如,如果底层代码尝试访问 null
或 undefined
上的属性,我们可能会捕获并吞掉一个 TypeError。
最后,这不仅非常冗长,而且很容易出错:如果您忘记添加 try/catch
,很可能会导致回溯(traceback)。如果您添加了 try/catch
块但忘记重新抛出意外错误,您将吞掉无关的错误。如果您想避免重新赋值变量,可能会将使用该变量的整个代码块移到 try
块中。try
块中的代码越多,越有可能捕获到无关的错误,并且如果忘记按错误类型进行过滤,还会吞掉这些错误。它还会为整个代码块增加一层缩进,甚至可能导致嵌套的 try/catch
块。最后,这会让确定哪一行代码实际上预期会抛出错误变得更加困难。
以下部分概述了一些可以用来替代使用错误的替代方法。
返回 null
或 undefined
¶
如果函数返回一个原始值或对象,通常可以使用 null
或 undefined
来表示它未能完成预期任务。这在大多数情况下已足够。代码最终看起来像这样:
const someVal = someFunction();
// further
if (someVal !== null) { /* do something */ }
如您所见,这要简单得多。
返回对象或数组¶
在某些情况下,null
或 undefined
是预期返回值的一部分。在这种情况下,您可以返回一个包装对象或包含返回值或错误的两元素数组:
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 中发生,我们总是希望显示用户为何没有访问权限的信息。
某些操作应始终满足的前提条件未被满足;例如,视图无法渲染是因为域无效。这类错误通常不打算在任何地方被捕获,而是表明代码有误或数据已损坏。抛出错误会迫使框架退出,并防止在损坏状态下运行。
在递归遍历某些深层数据结构时,抛出错误可能比手动测试错误并通过多层调用传递错误更为便捷且不易出错。这种情况在实践中应该非常罕见,并且需要权衡本文提到的所有缺点。