错误处理¶
在编程中,错误处理是一个复杂且充满陷阱的话题,而在框架的约束下编写代码时,它可能更加令人望而生畏,因为你处理错误的方式需要与框架分派错误的方式相配合,反之亦然。
本文概述了 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 与抛出的错误同等对待:
在 async 函数中抛出异常,效果等同于返回一个以所抛出的值作为拒绝原因而被拒绝的 Promise。
async 函数中的 catch 块会捕获在相应 try 块中通过 await 等待的、被拒绝的 Promise。
运行时会收集被拒绝 Promise 的堆栈信息。
未被同步捕获的被拒绝 Promise 会在 global/window 对象上分派一个事件;如果未在该事件上调用
preventDefault,浏览器会记录一条错误,而像 node 这样的独立运行时会终止进程。调试器的“在异常时暂停”功能会在 Promise 被拒绝时暂停
出于这些原因,Odoo 框架将拒绝的 Promise 与抛出的错误完全同等对待。不要在不会抛出错误的地方创建被拒绝的 Promise,并且始终使用 Error 对象 作为拒绝原因来拒绝 Promise。
error 事件不是错误¶
除了 window 上的 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 object,其中包含该钩子注册位置的堆栈信息,并将其 cause 设置为最初抛出的值。这是因为原始错误的堆栈跟踪不包含任何关于是哪个组件、在何处注册了该钩子的信息,只包含调用该钩子的信息。由于钩子由 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 传入的函数,而该函数是父组件的方法。这两条信息都可能有用,因为该方法可能被子组件错误地调用(或在不该调用的生命周期阶段被调用),但也可能是父组件的方法本身存在缺陷。
将错误标记为已处理¶
在前面的小节中,我们介绍了两种注册错误处理器的方式:一种是将它们添加到 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
});
}
}
这会使浏览器在 window 上派发一个 unhandledrejection 事件,从而触发 JS 框架的错误处理机制来处理该错误,大多数情况下会打开一个包含错误信息的对话框。动作服务和对话框服务在内部就采用这一策略:停止渲染已损坏的动作或对话框,同时仍然报告该错误。
error_handlers 注册表中的处理器¶
添加到 error_handlers 注册表中的处理器可以通过两种方式将错误标记为已处理,两者的含义不同。
第一种方式是处理器返回一个真值,这表示处理器已处理该错误并触发了某些动作,因为它收到的错误类型与其能够处理的类型相匹配。这通常意味着它打开了一个对话框或通知,以提醒用户注意该错误。这样会阻止错误服务调用序号更高的后续处理器。
另一种方式是在错误事件上调用 preventDefault:这具有不同的含义。在确定自己能够处理该错误之后,处理器需要判断所收到的错误是否属于正常操作中允许发生的情况,如果是,就应当调用 preventDefault。这通常适用于业务错误,例如访问错误或验证错误:用户可能与他人分享自己无权访问的资源的链接,用户也可能尝试保存处于无效状态的记录。
如果不调用 preventDefault,该错误就会被视为意外错误;在测试期间任何此类情况都会导致测试失败,因为它通常表明代码存在缺陷。
尽可能避免抛出错误¶
错误会在许多方面引入复杂性,以下是应当避免抛出错误的一些原因。
错误的代价很高¶
由于错误在抛出时需要展开调用栈并沿途收集信息,因此抛出错误的速度很慢。此外,JavaScript 运行时通常基于“异常很少发生”的假设进行优化,因此一般会假设代码不会抛出异常来编译它,一旦真的抛出,就回退到较慢的代码路径。
抛出错误会增加调试难度¶
JavaScript 调试器(例如 Chrome 和 Firefox 开发者工具中内置的调试器)具有一项功能,可以在抛出异常时暂停执行。你还可以选择仅在已捕获的异常上暂停,还是在已捕获和未捕获的异常上都暂停。
当你在由 Owl 或 JavaScript 框架调用的代码中抛出错误时(例如在字段、视图、动作、组件中……),由于这些框架管理资源,它们需要捕获并检查错误,以判断该错误是否属于严重错误、应用是否应当崩溃,还是该错误属于预期情况、应以特定方式处理。
因此,JavaScript 代码中抛出的几乎所有错误都会在某个环节被捕获;尽管在无法处理时它们可能会被重新抛出,但这意味着在 Odoo 中工作时,“在未捕获异常时暂停”这一功能实际上毫无用处,因为它总是在 JavaScript 框架代码中暂停,而不是在最初抛出错误的代码附近暂停。
不过,“在已捕获异常时暂停”功能仍然非常有用,因为它会在每个 throw 语句和被拒绝的 Promise 上暂停执行。这使开发者可以在发生异常情况时随时停下来检查执行上下文。
不过,这只是假设异常很少被抛出的情况下才成立。如果异常被频繁抛出,页面中的任何操作都可能导致调试器停止执行,开发者可能需要单步穿过许多“例行”异常,才能到达他们真正关心的异常场景。在某些情况下,由于点击调试器中的播放按钮会使页面失去焦点,甚至可能导致无法复现所关注的抛出场景,除非使用恢复执行的键盘快捷键,这会导致糟糕的开发者体验。
抛出错误会破坏代码的正常流程¶
抛出错误时,看似总会执行的代码可能会被跳过,这会导致许多隐蔽的 bug 和内存泄漏。下面是一个简单的例子:
eventTarget.addEventListener("event", handler);
someFunction();
eventTarget.removeEventListener("event", handler);
在这段代码中,我们先向一个事件目标添加了事件监听器,然后调用一个可能在该目标上派发事件的函数。函数调用之后,我们再移除该事件监听器。
如果 someFunction 抛出错误,该事件监听器将永远不会被移除。这意味着与此事件监听器关联的内存实际上已经泄漏,除非 eventTarget 本身被释放,否则这些内存永远不会被回收。
除了内存泄漏之外,处理器仍然处于挂载状态,这意味着它可能会因并非由 someFunction 调用所派发的事件而被触发。这是一个 bug。
为了解决这个问题,需要将调用包裹在 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 上,而我们始终希望显示用户为何没有访问权限的信息。
某项操作应当始终满足的前置条件未得到满足;例如,由于域无效而无法渲染视图。这类错误通常并不打算在任何地方被捕获,它表明代码不正确或数据已损坏。抛出错误会迫使框架中止,从而避免在损坏的状态下继续运行。
在递归遍历某些深层数据结构时,抛出错误可能比手动检查错误并在多层调用之间逐层传递更符合人体工程学,也更不容易出错。实践中这种情况应当非常少见,并且需要与本文提到的所有缺点进行权衡。