错误处理¶
在编程中,错误处理是一个复杂的话题,有许多潜在的陷阱。当你在框架的约束下编写代码时,情况可能会更加令人望而生畏,因为你处理错误的方式需要与框架分发错误的方式相配合,反之亦然。
本文概述了 JavaScript 框架和 Owl 如何处理错误,并提供了一些建议,说明如何以避免常见问题的方式与这些系统进行交互。
JavaScript 中的错误¶
在我们深入探讨 Odoo 中错误处理机制,以及如何和在哪里自定义错误处理行为之前,了解我们所说的“错误”具体指什么,以及 JavaScript 中错误处理的一些特殊之处,是一个不错的主意。
错误类¶
当我们谈到错误处理时,首先想到的是内置的 Error
类,或者继承自它的类。在本文的其余部分,当我们提到此类对象的实例时,我们将使用斜体字 Error object 这一术语。
可以抛出任何内容¶
在 JavaScript 中,你可以抛出任何值。通常会抛出 Error 对象,但也可以抛出其他任何对象,甚至基本类型。虽然我们不建议你抛出任何不是 Error 对象 的内容,但 Odoo JavaScript 框架需要能够处理这些情况,这将帮助你理解我们不得不做出的一些设计决策。
当实例化一个 Error 对象 时,浏览器会收集当前“调用栈”(可能是正常的调用栈,也可能是异步函数和 Promise 继续执行的重构调用栈)的状态信息。此信息称为“堆栈跟踪”,对于调试非常有用。当可用时,Odoo 框架会在错误对话框中显示此堆栈跟踪。
当抛出一个不是 Error 对象 的值时,浏览器仍然会收集当前调用栈的信息,但这些信息在 JavaScript 中不可用:只有在错误未被处理时,才可以在开发者工具控制台中看到。
抛出 错误对象 可以让我们显示更详细的信息,用户在需要时可以将其复制/粘贴用于错误报告。同时,这也能使错误处理更加健壮,因为它允许我们在处理错误时根据错误的类型进行筛选。不幸的是,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 常常被用作存储结果和“错误”之间的一种不相交联合体(disjoint union),并且使用 Promise 拒绝(rejection)作为一种表示软性失败的方式似乎很常见。尽管乍一看这可能是个好主意,但浏览器和 JavaScript 运行时早已开始将被拒绝的 Promise 视为与抛出的错误几乎在所有方面都相同:
在异步函数中抛出异常,其效果与返回一个以抛出值作为拒绝原因的已拒绝 Promise 相同。
异步函数中的 catch 块会捕获在相应 try 块中被等待的已拒绝的 Promise。
运行时收集有关已拒绝承诺的堆栈信息。
一个未被同步捕获的已拒绝 Promise 会在全局/window 对象上分发事件,如果在该事件上未调用
preventDefault
,浏览器会记录错误,而像 Node 这样的独立运行时会终止进程。调试器功能“异常时暂停”在 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 子句。错误的处理方式取决于在回溯调用堆栈时遇到的代码。虽然错误可能从几乎无限多的位置抛出,但进入 JavaScript 框架错误处理代码的路径却只有几种可能。
在模块顶层抛出错误¶
当一个 JS 模块被加载时,该模块顶层的代码会被执行,并可能抛出错误。虽然框架可能会通过对话框报告这些错误,但模块加载是 JavaScript 框架的关键时刻,某些模块在加载时抛出的错误可能会完全阻止框架代码的启动,因此在此阶段的任何错误报告都是“尽力而为”。然而,在模块加载期间抛出的错误应至少在浏览器控制台中记录一条错误消息。由于此类错误至关重要,应用程序无法从中恢复,因此你应该编写代码,使得模块在定义时不可能抛出错误。在此阶段发生的任何错误处理和报告纯粹是为了帮助你(开发者)修复引发错误的代码,我们不提供自定义处理这些错误的机制。
错误服务¶
当一个错误被抛出但未被捕获时,运行时会在全局对象(window
)上分发一个事件。事件的类型取决于错误是同步抛出还是异步抛出:同步抛出的错误会分发一个 error
事件,而从异步上下文中抛出的错误以及被拒绝的 Promise 会分发一个 unhandledrejection
事件。
JS 框架包含一个专门用于处理这些事件的服务:错误服务。当接收到这些事件之一时,错误服务会首先创建一个新的 Error 对象,用于包装被抛出的错误;这是因为任何值都可以被抛出,而 Promise 也可以用任何值进行拒绝,包括 undefined
或 null
,这意味着无法保证该值包含任何信息,或者我们可以在该值上存储任何信息。包装的 Error 对象 用于收集关于被抛出值的一些信息,以便在需要显示任何类型错误信息的框架代码中统一使用。
错误服务会在这个 Error 对象 中存储抛出错误的完整堆栈跟踪,并且当调试模式为 assets
时,会使用源映射(source maps)在堆栈跟踪中添加有关每个堆栈帧所包含函数的源文件信息。每个函数在打包资源中的位置会被保留,因为在某些场景下这可能会很有用。当错误具有 cause
时,此过程还会展开 cause
链以构建完整的复合堆栈跟踪。虽然 cause
字段是 Error 对象 的标准属性,但一些主流浏览器仍然不显示错误链的完整堆栈跟踪。因此,我们手动添加此信息。这在错误在 Owl 钩子(hooks)中被抛出时尤其有用,稍后将详细介绍。
一旦包装错误包含所有必要的信息,我们就开始实际处理错误的过程。为此,错误服务会依次调用 error_handlers
注册表中注册的所有函数,直到其中一个函数返回真值,这表示错误已被处理。在此之后,如果未在错误事件上调用 preventDefault
,并且错误服务能够将堆栈跟踪添加到包装错误对象上,那么错误服务会在错误事件上调用 preventDefault
,并在控制台中记录堆栈跟踪。这是因为如前所述,某些浏览器无法正确显示错误链,而事件的默认行为是浏览器记录错误,因此我们简单地覆盖该行为,以记录更完整的堆栈跟踪。如果错误服务无法收集有关抛出错误的堆栈跟踪信息,我们将不会调用 preventDefault
。这可能发生在抛出非错误值时:字符串、undefined 或其他随机对象。在这种情况下,浏览器会自行记录堆栈跟踪,因为它拥有这些信息,但不会将其暴露给 JS 代码。
error_handlers
注册表¶
error_handlers
注册表是扩展 JS 框架处理“通用”错误方式的主要方法。在此上下文中,“通用”错误指的是可能在多个位置发生,但应统一处理的错误。一些示例:
用户错误:当用户尝试执行 Python 代码认为因业务原因无效的操作时,Python 代码会抛出一个 UserError,RPC 函数会在 JavaScript 中抛出相应的错误。这种错误可能在任何 RPC 调用中发生,我们不希望开发人员在所有这些地方都显式处理此类错误,我们希望在所有地方都保持相同的行为:停止当前执行的代码(通过 throw 实现),并显示一个对话框,向用户解释发生了什么问题。
访问错误:与用户错误的处理方式相同:它可能在任何地方发生,并应以相同的方式显示,无论发生在何处。
断开连接:同样的原因再次出现。
在 Owl 组件中抛出错误¶
注册或修改 Owl 组件是扩展 Web 客户端功能的主要方式。因此,大多数错误都是以某种方式从 Owl 组件中抛出的。可能出现以下几种情况:
在组件的设置阶段或渲染过程中抛出异常
在生命周期钩子中抛出异常
从事件处理程序中抛出异常
从事件处理程序或直接或间接从事件处理程序调用的函数或方法中抛出错误,意味着 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
钩子,而第二个高亮的行告诉你哪个函数抛出了错误。在这种情况下,子组件正在调用它从父组件作为属性接收到的函数,而该函数是父组件的一个方法。这两条信息都可能有用,因为可能是子组件错误地调用了该方法(或在生命周期中不应该调用的时候调用了),也可能是父组件的方法中存在 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
事件,从而触发 JavaScript 框架的错误处理机制,通常会打开一个显示错误信息的对话框。这是动作服务和对话框服务内部用来在报告错误的同时停止渲染损坏的动作或对话框所采用的策略。
错误处理程序在 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;
}
这种代码存在许多问题。首先,因为我们希望变量 someVal
在 try/catch
块之后仍然可访问,所以它必须在该块之前声明,并且不能使用 const
,因为它需要在初始化后进行赋值。这进一步影响了可读性,因为你在后续代码中现在需要留意这个变量可能会被重新赋值。
其次,当我们捕获错误时,必须检查该错误是否是我们预期要捕获的类型,如果不是,则重新抛出该错误。如果我们不这样做,可能会意外地吞没那些*实际*未预期的错误,而不是正确地报告它们。例如,如果底层代码尝试访问 null
或 undefined
上的属性,我们可能会捕获并吞没一个 TypeError。
最后,这不仅非常冗长,而且很容易出错:如果你忘记添加 try/catch
,你很可能会遇到回溯错误。如果你添加了 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 调用中,我们始终希望显示有关用户为何没有访问权限的信息。
某些应始终满足的操作前提未被满足;例如,由于领域无效,视图无法渲染。此类错误通常不打算在任何地方捕获,而是表明代码有误或数据已损坏。抛出异常会强制框架退出,并防止在损坏状态下继续操作。
在遍历某些深度数据结构时,抛出错误可能比手动测试错误并在多层调用中传递错误更加方便且不易出错。实际上,这种情况非常少见,需要权衡本文中提到的所有缺点。