怎么处理 React 中的 Errors
背景
最近在研究如何在 React 中捕获和处理错误,try/catch
和ErrorBoundary
的用法、模式和警告,什么时候是可行的,什么时候是不行的,以及如何用ErrorBoundary
捕获所有错误,包括async
错误和来自事件处理程序的错误。
我们都希望我们的应用能够稳定,能够完美地运行,并且能够满足所有可以想象到的情况,但可悲的现实是,我们都是人类(至少我是这么认为的),我们都会犯错误,不存在没有错误的代码。无论我们多么小心,或者我们编写了多少自动化测试,测试同学多么仔细的覆盖,总会出现一些严重错误的情况。当涉及到用户体验时,重要的事情是预测这种可怕的现象(如白屏等),尽可能地明确展示出它,并以一种优雅的方式处理它,直到它真正被修复。
所以今天,让我们来看看React
中的错误处理:如果发生错误,我们可以做什么,不同的错误捕捉方法有什么注意事项,以及如何减轻它们产生的影响。
为什么我们要捕获错误?
但是首先,为什么在 React 中有一些错误捕捉解决方案是至关重要的?
答案很简单:从版本 16 开始,在 React 生命周期中抛出的错误如果没有停止,将导致整个应用程序自行卸载。在此之前,组件将被保留在屏幕上,即使是畸形或者错误的。但是现在(React16 之后),在 UI 的某个无关紧要的部分,甚至是一些您无法控制的外部库中,一个未捕获的错误可能会破坏整个页面,并为所有人呈现一个空白屏幕。
这是以前从来没有前端开发人员能带来的毁灭性打击 😅
回顾一下:如何在 Javascript 中捕捉错误
当涉及到在常规 Javascript 中捕获这些令人讨厌的意外时,这些工具非常简单:
我们有一个很好的传统的try/catch
语句,它的作用不言自明:
尝试try
做一些事情,如果他们失败了 —— 捕获错误catch
并做一些事情来处理它:
1 | try { |
这也适用于具有相同语法的 async 函数:
1 | try { |
或者,如果我们要遵循古早的promise
,同样也有专门针对它们的捕获方法。因此,如果我们用基于promise
的 API 重写之前的获取示例,它将像这样:
1 | fetch('/jiekou').then((result) => { |
这是相同的概念,只有一些不同的实现,因此在本文的其余部分中,我将仅对所有错误使用try/catch
语法。
在 React 中 简单的 try/catch:如何做 以及 如何警告
当捕获错误时,我们需要对此做些事情。那么,除了在某个地方记录它,我们还能做什么?或者,更确切地说:我们能为用户做什么?只要将它们留下一个空白屏幕或报错的界面就完全不是对用户友好的。
最明显,最直观的答案是在我们等待修复程序时渲染一些东西。幸运的是,我们可以在catch
声明中做任何我们想做的事情,包括设置state
。因此,我们可以做这样的事情:
1 | const SomeComponent = () => { |
我们发送一个请求,如果它失败-设置错误状态,如果hasError
为true
,那么我们呈现一个错误组件浮层,为用户提供一些额外的信息,如支持联系号码、联系我司 xxx。
这种方法非常直接,适用于简单、可预测和小部分的组件,如捕获失败的获取请求。但是,如果开发人员希望捕获组件中所有可能发生的错误,那么将面临一些严格的限制:
限制 1:使用 useEffect 钩子会遇到麻烦。
如果我们在try/catch
中使用useEffect
,会完全不生效:
1 | try { |
之所以发生这种情况,是因为使用useEffect
在呈现后被异步调用,因此从try/catch
的角度来看,好像一切都会正常发生。
这与任何Promise
都是一样的:如果我们不等待结果,那么 javascript 将继续它的业务,当Promise
完成时返回它,只执行useEffect
(或Promise
的)内部的内容。try /catch
块将被执行,并且在那时早就消失了。
所以为了捕捉useEffect
中的错误,try/catch
也应该放在里面:
1 | useEffect(() => { |
这适用于任何使用useEffect
的hook
或任何异步的东西。因此,不是仅仅一个try/catch
就可以包装所有内容,而是必须将其分割成多个块:每个hook
一个。
限制 2:子组件
try / catch
将无法捕获子组件内发生的任何事情。
你不能这样做:
1 | const Component = () => { |
这是因为当我们写入<Child />
时,我们实际上并没有呈现这个组件。我们正在做的是创建一个Component Element
,它只不过是一个组件的定义,只是一个包含必要信息的对象,如组件type
和props
,这些信息稍后将被 React 本身使用(指触发该组件的渲染)。它会在try/catch
block 成功执行后发生,与promises
和useEffect hook
的情况完全相同。
如果想了解更详细的元素和组件是如何工作的,下面是这篇文章的链接:The mystery of React Element, children, parents and re-renders
限制 3:在 Render 期间 setting state 是不允许的
如果你试图捕获useEffect
和各种回调之外的错误(即在组件的渲染期间),那么正确地处理它们不再那么简单:render
期间不允许状态更新。
例如,如果发生错误,像这样的简单代码只会导致重新渲染的无限循环:
1 | const Component = () => { |
当然,我们可以在这里返回错误组件,而不是设置状态:
1 | const Component = () => { |
但是,正如大家感觉的那样,这有点麻烦,并且将迫使我们以不同的方式处理同一个组件中的错误:useEffect
的状态、callback
,以及直接返回其他的所有内容。
1 | // 虽然它可以工作,但它非常麻烦,很难维护,不推荐这么写 |
总结一下:如果我们在 React 中仅仅依赖try/catch
,我们要么会错过大部分错误,要么会把每个组件都变成一堆无法理解的代码,这些代码本身可能会导致错误。
幸运的是,还有另一种方法。
React ErrorBoundary 组件
为了减轻上述限制,React 为我们提供了所谓的“错误边界”:一个特殊的 API,以某种方式将常规组件转换为try/catch
语句,仅适用于 React 声明性代码。你可以在每个例子中看到的典型用法,包括 React 文档,是这样的:
1 | const Component = () => { |
现在,如果这些组件或它们的子组件在render
期间出现错误,错误将被捕获并处理。
但是 React 并没有给我们组件本身,它只是给了我们一个实现它的工具。最简单的实现是这样的:
1 | class ErrorBoundary extends React.Component { |
我们创建一个常规的类组件(这里是老式的写法,没有用于ErrorBoundary
的钩子),并实现getDerivedStateFromError
方法:它将组件转换为适当的ErrorBoundary
。
处理错误时要做的另一件重要的事情是将错误信息发送到可以提醒到开发人员的地方。为此,错误边界提供了componentDidCatch
方法:
1 | class ErrorBoundary extends React.Component { |
在设置了ErrorBoundary
之后,我们可以对它做任何我们想做的事情,就像任何其他组件一样。例如,我们可以使它更具可复用性,并将fallback
作为props
传递:
1 | render() { |
用法如下所示:
1 | const Component = () => { |
或者加上我们可能需要的任何其他东西,比如:
- 重置单击按钮时的状态;
- 区分错误类型;
- 将错误推到某个上下文。
不过,在这个捕获错误自由的世界中有一个问题:它不能捕获所有内容。
ErrorBoundary 组件的限制
ErrorBoundary
只捕捉 React 生命周期中发生的错误。发生在它之外的事情,如Promise
的resolve
,带 setTimeout 的异步代码,各种回调和事件处理程序,如果不显式地处理,就会被彻底忽略。
1 | const Component = () => { |
这里常见的建议是使用常规的try/catch
来处理这类错误。至少在这里我们可以安全地使用 state:事件处理程序的回调正是我们通常setting state
的地方。所以从技术上讲,我们可以把两种方法结合起来,做这样的事情:
1 | const Component = () => { |
但是,我们又回到了原点:每个组件都需要保持它的“错误”状态?更重要的是,怎么决定如何处理它。
当然,我们可以不在组件级别上处理这些错误,只是通过props
或Context
将它们传递到具有ErrorBoundary
的父类,这样至少我们可以在一个地方有一个“fallback
”组件:
1 | const Component = ({ onError }) => { |
但是!但是它有太多额外的代码!我们必须对渲染树中的每个子组件都这样做,更不用说我们现在基本上在维护两个错误状态:
- 在父组件中;
- 在 ErrorBoundary 本身中。
ErrorBoundary
已经有了所有的机制来将错误传播到树上,我们在这里做了双重工作。
难道我们不能用ErrorBoundary
从异步代码和事件处理程序中捕获这些错误吗?
使用 ErrorBoundary 捕获异步错误
有趣的是,我们可以用ErrorBoundary
来捕获它们!国外大佬 Dan Abramov 分享了一个很酷的 hack 来实现这一点: Throwing Error from hook not caught in error boundary · Issue #14981 · facebook/react.。
这里的技巧是先用try/catch
捕获这些错误,然后在catch
语句中触发正常的React re-render
,然后重新将这些错误扔回re-render
生命周期。这样ErrorBoundary
就可以像捕获其他错误一样捕获它们。由于状态更新是触发重新呈现的方式,而state set函数
实际上可以接受一个updater函数
作为参数,解决方案真的让人喊一声大佬牛*:
1 | const Component = () => { |
这里的最后一步是将hack
抽象出来,这样我们就不必在每个组件中创建state
。我们可以在这里发挥创意,创建一个hook
,作为一个异步错误抛出器:
1 | const useThrowAsyncError = () => { |
用法如下:
1 | const Component = () => { |
或者,我们可以像这样包裹一下回调:
1 | const useCallbackWithErrorHandling = (callback) => { |
这样的用法如下:
1 | const Component = () => { |
或者任何你内心想要的和 app 要求的东西,没有限制!
我从事前端这么多年
我写 bug 赔了这么多钱
不就……
就不会再有错误了。
能用 react-error-boundary 代替吗?
对于那些讨厌重新发明轮子或者只是喜欢用库来解决已经解决的问题的人来说,有一个很好的实现了一个灵活的ErrorBoundary
组件,并有一些类似于上面描述的有用的 utils:GitHub - bvaughn/react-error-boundary
是否使用它只是个人偏好、编码风格和组件中的独特情况的问题。
总结
写代码就是为了优雅,这篇文章希望对大家处理 Error 的时候能有所帮助。
省流小知识点:
try/catch
块不会捕获useEffect
等钩子和任何子组件内部的错误ErrorBoundary
可以捕获它们,但它不会捕获异步代码和事件处理程序中的错误- 尽管如此,你可以让
ErrorBoundary
捕获它们,你只需要先用try/catch
捕获它们,然后重新将它们扔回 React 生命周期
希望永无事件发生!