怎么处理React中的Errors

怎么处理 React 中的 Errors

背景

最近在研究如何在 React 中捕获和处理错误,
try/catchErrorBoundary的用法、模式和警告,什么时候是可行的,什么时候是不行的,以及如何用ErrorBoundary捕获所有错误,包括async错误和来自事件处理程序的错误。

我们都希望我们的应用能够稳定,能够完美地运行,并且能够满足所有可以想象到的情况,但可悲的现实是,我们都是人类(至少我是这么认为的),我们都会犯错误,不存在没有错误的代码。无论我们多么小心,或者我们编写了多少自动化测试,测试同学多么仔细的覆盖,总会出现一些严重错误的情况。当涉及到用户体验时,重要的事情是预测这种可怕的现象(如白屏等),尽可能地明确展示出它,并以一种优雅的方式处理它,直到它真正被修复。

所以今天,让我们来看看React中的错误处理:如果发生错误,我们可以做什么,不同的错误捕捉方法有什么注意事项,以及如何减轻它们产生的影响。


为什么我们要捕获错误?

但是首先,为什么在 React 中有一些错误捕捉解决方案是至关重要的?

答案很简单:从版本 16 开始,在 React 生命周期中抛出的错误如果没有停止,将导致整个应用程序自行卸载。在此之前,组件将被保留在屏幕上,即使是畸形或者错误的。但是现在(React16 之后),在 UI 的某个无关紧要的部分,甚至是一些您无法控制的外部库中,一个未捕获的错误可能会破坏整个页面,并为所有人呈现一个空白屏幕。

这是以前从来没有前端开发人员能带来的毁灭性打击 😅


回顾一下:如何在 Javascript 中捕捉错误

当涉及到在常规 Javascript 中捕获这些令人讨厌的意外时,这些工具非常简单:

我们有一个很好的传统的try/catch语句,它的作用不言自明:
尝试try做一些事情,如果他们失败了 —— 捕获错误catch并做一些事情来处理它:

1
2
3
4
5
6
7
try {
// 如果这里产生了错误,会throw一个错误
doSomething();
} catch (e) {
// 如果错误发生了,catch它并做一些事情,而不停止应用程序
// 就像服务端接口出点什么小问题
}

这也适用于具有相同语法的 async 函数:

1
2
3
4
5
try {
await fetch('/jiekou');
} catch (e) {
// 接口报错啦
}

或者,如果我们要遵循古早的promise,同样也有专门针对它们的捕获方法。因此,如果我们用基于promise的 API 重写之前的获取示例,它将像这样:

1
2
3
4
5
6
fetch('/jiekou').then((result) => {
// 接口成功返回
// 做点什么处理
}).catch((e) => {
// 接口报错,来兜兜底
})

这是相同的概念,只有一些不同的实现,因此在本文的其余部分中,我将仅对所有错误使用try/catch语法。

在 React 中 简单的 try/catch:如何做 以及 如何警告

当捕获错误时,我们需要对此做些事情。那么,除了在某个地方记录它,我们还能做什么?或者,更确切地说:我们能为用户做什么?只要将它们留下一个空白屏幕或报错的界面就完全不是对用户友好的。

最明显,最直观的答案是在我们等待修复程序时渲染一些东西。幸运的是,我们可以在catch声明中做任何我们想做的事情,包括设置state。因此,我们可以做这样的事情:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const SomeComponent = () => {
const [hasError, setHasError] = useState(false);

useEffect(() => {
try {
// 可能在获取数据啥啥的
} catch (e) {
// 获取失败,没数据展示了~
setHasError(true);
}
});

// 发生了错误 => 那就展示一些报错提示页
if (hasError) return <SomeErrorScreen />;

// 无事发生~ => 那就正常展示
return <SomeComponentContent {...dataSomething} />;
};

我们发送一个请求,如果它失败-设置错误状态,如果hasErrortrue,那么我们呈现一个错误组件浮层,为用户提供一些额外的信息,如支持联系号码、联系我司 xxx。

这种方法非常直接,适用于简单、可预测和小部分的组件,如捕获失败的获取请求。但是,如果开发人员希望捕获组件中所有可能发生的错误,那么将面临一些严格的限制

限制 1:使用 useEffect 钩子会遇到麻烦。

如果我们在try/catch中使用useEffect,会完全不生效:

1
2
3
4
5
6
7
try {
useEffect(() => {
throw new Error('妈呀错啦!');
}, [])
} catch(e) {
// useEffect 抛出错误, 这里永远不会被调用到
}

之所以发生这种情况,是因为使用useEffect在呈现后被异步调用,因此从try/catch的角度来看,好像一切都会正常发生。
这与任何Promise都是一样的:如果我们不等待结果,那么 javascript 将继续它的业务,当Promise完成时返回它,只执行useEffect(或Promise的)内部的内容。try /catch块将被执行,并且在那时早就消失了。

所以为了捕捉useEffect中的错误,try/catch也应该放在里面:

1
2
3
4
5
6
7
useEffect(() => {
try {
throw new Error('妈呀错啦!');
} catch(e) {
// 这就能捕获到了
}
}, [])

玩一下这个例子,戳戳

这适用于任何使用useEffecthook或任何异步的东西。因此,不是仅仅一个try/catch就可以包装所有内容,而是必须将其分割成多个块:每个hook一个。

限制 2:子组件

try / catch将无法捕获子组件内发生的任何事情。
你不能这样做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const Component = () => {
let child;

try {
child = <Child />
} catch(e) {
// 对于捕获子组件内的错误无用,不会被触发
}

return child;
}

/**
* 或者这样也不行
**/

const Component = () => {
try {
return <Child />
} catch(e) {
// 对于捕获子组件内的错误还是无用,不会被触发
}
}

玩一下这个例子,戳戳

这是因为当我们写入<Child />时,我们实际上并没有呈现这个组件。我们正在做的是创建一个Component Element,它只不过是一个组件的定义,只是一个包含必要信息的对象,如组件typeprops,这些信息稍后将被 React 本身使用(指触发该组件的渲染)。它会在try/catch block 成功执行后发生,与promisesuseEffect hook的情况完全相同。

如果想了解更详细的元素和组件是如何工作的,下面是这篇文章的链接:The mystery of React Element, children, parents and re-renders

限制 3:在 Render 期间 setting state 是不允许的

如果你试图捕获useEffect和各种回调之外的错误(即在组件的渲染期间),那么正确地处理它们不再那么简单:render期间不允许状态更新。

例如,如果发生错误,像这样的简单代码只会导致重新渲染的无限循环:

1
2
3
4
5
6
7
8
9
10
11
12
13
const Component = () => {
const [hasError, setHasError] = useState(false);

try {
doSomethingWrong();
} catch(e) {
// 这么做就会出现死循环哦
// 不信的话,下面就可以试试
setHasError(true);
}

return <div>i'm good</div>;
}

试试就逝世

当然,我们可以在这里返回错误组件,而不是设置状态:

1
2
3
4
5
6
7
8
const Component = () => {
try {
doSomethingWrong();
} catch(e) {
// 这是木有问题的
return <SomeErrorScreen />
}
}

但是,正如大家感觉的那样,这有点麻烦,并且将迫使我们以不同的方式处理同一个组件中的错误:useEffect的状态、callback,以及直接返回其他的所有内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 虽然它可以工作,但它非常麻烦,很难维护,不推荐这么写
const SomeComponent = () => {
const [hasError, setHasError] = useState(false);

useEffect(() => {
try {
// 处理一些逻辑,如 发送请求
} catch(e) {
// 不能只是在useEffect或回调错误的情况下返回
// 所以必须使用state
setHasError(true);
}
})

try {
// 渲染时候的一些逻辑
} catch(e) {
// 但这里我们不能使用state,所以必须直接返回一些组件以防出现错误
return <SomeErrorScreen />;
}

// 在错误状态的情况下仍然需要返回
if (hasError) return <SomeErrorScreen />

return <SomeComponentContent {...dataSomething} />
}

总结一下:如果我们在 React 中仅仅依赖try/catch,我们要么会错过大部分错误,要么会把每个组件都变成一堆无法理解的代码,这些代码本身可能会导致错误。

幸运的是,还有另一种方法。

React ErrorBoundary 组件

为了减轻上述限制,React 为我们提供了所谓的“错误边界”:一个特殊的 API,以某种方式将常规组件转换为try/catch语句,仅适用于 React 声明性代码。你可以在每个例子中看到的典型用法,包括 React 文档,是这样的:

1
2
3
4
5
6
7
8
const Component = () => {
return (
<ErrorBoundary>
<SomeChildComponent />
<AnotherChildComponent />
</ErrorBoundary>
)
}

现在,如果这些组件或它们的子组件在render期间出现错误,错误将被捕获并处理。

但是 React 并没有给我们组件本身,它只是给了我们一个实现它的工具。最简单的实现是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
// 初始化错误状态
this.state = { hasError: false };
}

// 有错误发生则转换状态
static getDerivedStateFromError(error) {
return { hasError: true };
}

render() {
// 如果错误发生了,返回一个 fallback component
if (this.state.hasError) {
return <>哎妈呀吗,组件出错了!</>
}

return this.props.children;
}
}

我们创建一个常规的类组件(这里是老式的写法,没有用于ErrorBoundary的钩子),并实现getDerivedStateFromError方法:它将组件转换为适当的ErrorBoundary

处理错误时要做的另一件重要的事情是将错误信息发送到可以提醒到开发人员的地方。为此,错误边界提供了componentDidCatch方法:

1
2
3
4
5
6
7
8
class ErrorBoundary extends React.Component {
// 其他都一样

componentDidCatch(error, errorInfo) {
// 这里向外暴露错误
log(error, errorInfo);
}
}

在设置了ErrorBoundary之后,我们可以对它做任何我们想做的事情,就像任何其他组件一样。例如,我们可以使它更具可复用性,并将fallback作为props传递:

1
2
3
4
5
6
7
8
render() {
// 如果又错误发生,返回 fallback component
if (this.state.hasError) {
return this.props.fallback;
}

return this.props.children;
}

用法如下所示:

1
2
3
4
5
6
7
8
const Component = () => {
return (
<ErrorBoundary fallback={<>艾玛,组件出错了!</>}>
<SomeChildComponent />
<AnotherChildComponent />
</ErrorBoundary>
)
}

或者加上我们可能需要的任何其他东西,比如:

  • 重置单击按钮时的状态;
  • 区分错误类型;
  • 将错误推到某个上下文。

看看大概的例子

不过,在这个捕获错误自由的世界中有一个问题:它不能捕获所有内容。

ErrorBoundary 组件的限制

ErrorBoundary只捕捉 React 生命周期中发生的错误。发生在它之外的事情,如Promiseresolve,带 setTimeout 的异步代码,各种回调和事件处理程序,如果不显式地处理,就会被彻底忽略。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const Component = () => {
useEffect(() => {
// 这个报错会被 ErrorBoundary component 捕获
throw new Error('Destroy everything!');
}, [])

const onClick = () => {
// 但是这个没了就没了
throw new Error('我自由了!');
}

useEffect(() => {
// 如果这个接口失败了,也不会被发现
fetch('/jiekou')
}, [])

return <button onClick={onClick}>点我</button>
}

const ComponentWithBoundary = () => {
return (
<ErrorBoundary>
<Component />
</ErrorBoundary>
)
}

这里常见的建议是使用常规的try/catch来处理这类错误。至少在这里我们可以安全地使用 state:事件处理程序的回调正是我们通常setting state的地方。所以从技术上讲,我们可以把两种方法结合起来,做这样的事情:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const Component = () => {
const [hasError, setHasError] = useState(false);

// 这个组件和子组件中的大多数错误都将被ErrorBoundary捕获

const onClick = () => {
try {
// 这个会被catch捕获
throw new Error('我不自由了!');
} catch(e) {
setHasError(true);
}
}

if (hasError) return 'something went wrong';

return <button onClick={onClick}>点我</button>
}

const ComponentWithBoundary = () => {
return (
<ErrorBoundary fallback={"艾玛粗戳了!"}>
<Component />
</ErrorBoundary>
)
}

但是,我们又回到了原点:每个组件都需要保持它的“错误”状态?更重要的是,怎么决定如何处理它。

当然,我们可以不在组件级别上处理这些错误,只是通过propsContext将它们传递到具有ErrorBoundary的父类,这样至少我们可以在一个地方有一个“fallback”组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const Component = ({ onError }) => {
const onClick = () => {
try {
throw new Error('坏了坏了!');
} catch(e) {
// 这里就调用props,而不需要自己维护一个state
onError();
}
}

return <button onClick={onClick}>点我</button>
}

const ComponentWithBoundary = () => {
const [hasError, setHasError] = useState();
const fallback = "艾玛!出错了呀!";

if (hasError) return fallback;

return (
<ErrorBoundary fallback={fallback}>
<Component onError={() => setHasError(true)} />
</ErrorBoundary>
)
}

但是!但是它有太多额外的代码!我们必须对渲染树中的每个子组件都这样做,更不用说我们现在基本上在维护两个错误状态:

  • 在父组件中;
  • 在 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const Component = () => {
// create some random state that we'll use to throw errors
const [state, setState] = useState();

const onClick = () => {
try {
// something bad happened
} catch (e) {
// trigger state update, with updater function as an argument
setState(() => {
// re-throw this error within the updater function
// it will be triggered during state update
throw e;
})
}
}
}

省流版示例,戳戳

这里的最后一步是将hack抽象出来,这样我们就不必在每个组件中创建state。我们可以在这里发挥创意,创建一个hook,作为一个异步错误抛出器:

1
2
3
4
5
6
7
const useThrowAsyncError = () => {
const [state, setState] = useState();

return (error) => {
setState(() => throw error)
}
}

用法如下:

1
2
3
4
5
6
7
8
9
10
const Component = () => {
const throwAsyncError = useThrowAsyncError();

useEffect(() => {
fetch('/jieou').then().catch((e) => {
// 异步抛出错误
throwAsyncError(e)
})
})
}

或者,我们可以像这样包裹一下回调:

1
2
3
4
5
6
7
8
9
10
11
const useCallbackWithErrorHandling = (callback) => {
const [state, setState] = useState();

return (...args) => {
try {
callback(...args);
} catch(e) {
setState(() => throw e);
}
}
}

这样的用法如下:

1
2
3
4
5
6
7
8
9
10
const Component = () => {
const onClick = () => {
// 触发一个报错
throw new Error('毁灭吧!')
}

const onClickWithErrorHandler = useCallbackWithErrorHandling(onClick);

return <button onClick={onClickWithErrorHandler}>点我!</button>
}

或者任何你内心想要的和 app 要求的东西,没有限制!
我从事前端这么多年
我写 bug 赔了这么多钱
不就……
就不会再有错误了。

完整例子在这,戳戳

能用 react-error-boundary 代替吗?

对于那些讨厌重新发明轮子或者只是喜欢用库来解决已经解决的问题的人来说,有一个很好的实现了一个灵活的ErrorBoundary组件,并有一些类似于上面描述的有用的 utils:GitHub - bvaughn/react-error-boundary
是否使用它只是个人偏好、编码风格和组件中的独特情况的问题。

总结

写代码就是为了优雅,这篇文章希望对大家处理 Error 的时候能有所帮助。
省流小知识点:

  • try/catch块不会捕获useEffect等钩子和任何子组件内部的错误
  • ErrorBoundary可以捕获它们,但它不会捕获异步代码事件处理程序中的错误
  • 尽管如此,你可以让ErrorBoundary捕获它们,你只需要先用try/catch捕获它们,然后重新将它们扔回 React 生命周期

希望永无事件发生!

作者: 张熠
文章链接: https://crazyoctopusdan.github.io/2023/03/08/%E6%80%8E%E4%B9%88%E5%A4%84%E7%90%86React%E4%B8%AD%E7%9A%84Errors/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.