如何正确的捕获React中的错误
为什么我们需要在React中捕获错误?
在 React16 之前,当组件内部抛出异常时,整个组件树都会被卸载,这也就意味着即使在组件中发生一个小错误,或者是一些第三方依赖库抛出的错误,都会导致整个应用都被卸载,对于用户和开发者来说都是一个非常糟糕的体验。
在 React16 之后,React 引入了一种新的错误边界(Error Boundary)的概念,可以在组件树中的某些位置捕获异常,并防止它们向上冒泡导致整个组件树被卸载,后面会详细进行介绍。
因此一个好的错误捕获机制能够很好的提升开发者和用户的体验,同时能够减少用户对程序的不良印象。
如何捕获JavaScript中的错误?
在 JavaScript 中我们通常会使用 try/catch 去捕获代码执行过程中抛出的错误,从它的字面意思可以看出:尝试去做一些事情,如果他们失败了就抓住错误并做一些事情来减轻影响。
try {
// if we're doing something wrong, this might throw an error
doSomething();
} catch (e) {
// if error happened, catch it and do something with it without stopping the app
// like sending this error to some logging service
}
这种样也适用于具有相同语法的异步函数:
try {
await fetch('/bla-bla');
} catch (e) {
// oh no, the fetch failed! We should do something about it!
}
同时针对一些传统的 promise 语法我们也有专门的 catch 方法,因此我们可以重写一下上面的 fetch 请求:
fetch('/bla-bla').then((result) => {
// if a promise is successful, the result will be here
// we can do something useful with it
}).catch((e) => {
// oh no, the fetch failed! We should do something about it!
})
最终的效果是相同的,只是实现方式略有不同,因此在下面的内容中对所有的错误处理都会使用 try/catch
方法。
如何在React中使用try/catch
当我们捕获到错误时我们需要对其进行处理,那么除了将其记录在某个地方之外,我们还能做些什么呢?或者更准确地说当捕捉到错误时我们能为用户做些什么?只给用户留下一个空白的屏幕或者损坏的页面不是很友好。
最直观的答案是在等待开发者修复时渲染一些提示信息,幸运的是我们可以在 catch 语句中去做一些我们想做的事,包括设置状态,如下所示:
const SomeComponent = () => {
const [hasError, setHasError] = useState(false);
useEffect(() => {
try {
// do something like fetching some data
} catch(e) {
// oh no! the fetch failed, we have no data to render!
setHasError(true);
}
})
// something happened during fetch, lets render some nice error screen
if (hasError) return <SomeErrorScreen />
// all's good, data is here, let's render it
return <SomeComponentContent {...datasomething} />
}
我们正在尝试发送一个获取信息的请求,如果它失败了就设置错误状态,如果错误状态为真,那么我们会为用户呈现一个错误屏幕,其中包含一些额外的信息,比如支持联系电话。
这种方法非常简单,非常适合简单、可预测的用例,例如捕获失败的提取请求,但是如果我们想捕获组件中可能发生的所有错误,就将面临一些挑战和严重的限制。
1、useEffect 内部错误的捕获
如果我们直接用 try/catch
包裹 useEffect 去捕获他内部的错误是无法生效的:
try {
useEffect(() => {
throw new Error('Hulk smash!');
}, [])
} catch(e) {
// useEffect throws, but this will never be called
}
这是因为 useEffect 是异步调用的,他会在组件渲染之后才会执行,但是 try/catch
是同步执行的,所以从 try/catch
的角度来看一些都是成功的,因此为了捕获 useEffect 内部的错误,try/catch
也应该放在里面:
useEffect(() => {
try {
throw new Error('Hulk smash!');
} catch(e) {
// this one will be caught
}
}, [])
总的来说,我们不应该使用一个 try/catch
去包裹组件的所有东西,而是需要拆成多个块,每个 hook 一个。
2、子组件的错误捕获
try/catch 无法捕获子组件内发生的任何事情,你不能只这样做:
const Component = () => {
let child;
try {
child = <Child />
} catch(e) {
// useless for catching errors inside Child component, won't be triggered
}
return child;
}
或者这样
const Component = () => {
try {
return <Child />
} catch(e) {
// still useless for catching errors inside Child component, won't be triggered
}
}
这是因为当我们编写 时实际上并没有渲染或者说执行这个组件,react 实际会将这个组件转换为一个 jsx 对象用来描述这个组件,最终这个 jsx 对象会交由 react 本身使用,从而渲染该组件,而这个渲染过程会在 try/catch 成功执行完之后发生,这与上面 useEffect 的例子相似。
3、在组件渲染期间设置状态是不被允许的
如果我们试图在 useEffect 和各种回调函数之外(即组件渲染期间)捕获错误,那么想要正确的捕获这些错误不在那么简单了,因为 react 不允许我们在组件渲染期间设置状态,如下所示:
const Component = () => {
const [hasError, setHasError] = useState(false);
try {
doSomethingComplicated();
} catch(e) {
// don't do that! will cause infinite loop in case of an error
setHasError(true);
}
}
当然,我们可以在捕获到错误之后直接返回一个错误信息展示组件而不是设置状态:
const Component = () => {
try {
doSomethingComplicated();
} catch(e) {
// this allowed
return <SomeErrorScreen />
}
}
但是,正如我们看到的这样处理是非常麻烦的,这回迫使我们以不同的方式处理组件中的错误:useEffect、回调函数、组件渲染期间的错误等,如下所示:
// while it will work, it's super cumbersome and hard to maitain, don't do that
const SomeComponent = () => {
const [hasError, setHasError] = useState(false);
useEffect(() => {
try {
// do something like fetching some data
} catch(e) {
// can't just return in case of errors in useEffect or callbacks
// so have to use state
setHasError(true);
}
})
try {
// do something during render
} catch(e) {
// but here we can't use state, so have to return directly in case of an error
return <SomeErrorScreen />;
}
// and still have to return in case of error state here
if (hasError) return <SomeErrorScreen />
return <SomeComponentContent {...datasomething} />
}
总的来说,如果我们在 react 中仅仅依靠 try/catch
要们我们会错过大部分错误,要么就会将组件变成一团难以理解的代码,难道就没有更好的方式来捕获 react 中的错误吗?
如何使用React ErrorBoundary组件捕获错误
为了减轻上述限制,从 react16 开始,react 引入了 error boundary(错误边界)的概念,我们可以在组件树的任何位置设置 error boundary,用于捕获子组件抛出的错误。当一个组件抛出错误时,react 会沿着组件树向上查找最近的 error boundary,并将错误传递给 boundary 处理。
const Component = () => {
return (
<ErrorBoundary>
<SomeChildComponent />
<AnotherChildComponent />
</ErrorBoundary>
)
}
如果在渲染期间这些组件中的任何一个或他们的子组件出现问题,错误都会被捕获并处理。但是 react 并没有实际的给出这个组件,而是给了我们实现它的工具,最简单的实现是这样的:
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
// initialize the error state
this.state = { hasError: false };
}
// if an error happened, set the state to true
static getDerivedStateFromError(error) {
return { hasError: true };
}
render() {
// if error happened, return a fallback component
if (this.state.hasError) {
return <>Oh no! Epic fail!</>
}
return this.props.children;
}
}
我们创建了一个常规的类组件并实现了 getDerivedStateFromError 方法,此时这个组件就变成了一个正确的 ErrorBoundary
。处理错误时要做的另一件事就是将错误信息发送出去,比如存储到日志中,方便开发者定位问题,为此 ErrorBoundary
为我们提供了 componentDidCatch 方法:
class ErrorBoundary extends React.Component {
// everything else stays the same
componentDidCatch(error, errorInfo) {
// send error to somewhere here
log(error, errorInfo);
}
}
设置好 ErrorBoundary
之后我们就能像组件一样使用它,例如为了让这个组件更具可重用性可以将 fallback 作为 props 传入:
render() {
// if error happened, return a fallback component
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
使用的时候如下:
const Component = () => {
return (
<ErrorBoundary fallback={<>Oh no! Do something!</>}>
<SomeChildComponent />
<AnotherChildComponent />
</ErrorBoundary>
)
}
不过 ErrorBoundary
也有一些局限性,他不能捕获所有的错误。
ErrorBoundary使用限制
ErrorBoundary
只能捕获在 React 生命周期中发生的错误,比如像异步代码(promise、setTimeOut、requestAnimationFrame)、事件处理程序,如果不明确处理就会消失。
const Component = () => {
useEffect(() => {
// this one will be caught by ErrorBoundary component
throw new Error('Destroy everything!');
}, [])
const onClick = () => {
// this error will just disappear into the void
throw new Error('Hulk smash!');
}
useEffect(() => {
// if this one fails, the error will also disappear
fetch('/bla')
}, [])
return <button onClick={onClick}>click me</button>
}
const ComponentWithBoundary = () => {
return (
<ErrorBoundary>
<Component />
</ErrorBoundary>
)
}
对于这些限制通常我们可以结合 try/catch
进行处理,如下所示:
const Component = () => {
const [hasError, setHasError] = useState(false);
// most of the errors in this component and in children will be caught by the ErrorBoundary
const onClick = () => {
try {
// this error will be caught by catch
throw new Error('Hulk smash!');
} catch(e) {
setHasError(true);
}
}
if (hasError) return 'something went wrong';
return <button onClick={onClick}>click me</button>
}
const ComponentWithBoundary = () => {
return (
<ErrorBoundary fallback={"Oh no! Something went wrong"}>
<Component />
</ErrorBoundary>
)
}
在事件处理程序中使用 try/catch
捕获抛出的错误同时设置错误状态,这样就能很好的弥补 ErrorBoundary
的缺陷。
但是我们仔细想想,这样做的话每个组件都要设置他的错误状态,更重要的是还需要决定如何去处理这个错误。当然我们可以不在组件级别去处理这些错误,我们可以通过 props 或者 Context 将错误统一传到具有 ErrorBoundary
的父级组件:
const Component = ({ onError }) => {
const onClick = () => {
try {
throw new Error('Hulk smash!');
} catch(e) {
// just call a prop instead of maintaining state here
onError();
}
}
return <button onClick={onClick}>click me</button>
}
const ComponentWithBoundary = () => {
const [hasError, setHasError] = useState();
const fallback = "Oh no! Something went wrong";
if (hasError) return fallback;
return (
<ErrorBoundary fallback={fallback}>
<Component onError={() => setHasError(true)} />
</ErrorBoundary>
)
}
但是相应的也带来了很不好的影响:
- 我们必须为每个子组件添加这些操作(接收回调函数并将错误传递到对应的组件)。
- 需要维护两种错误状态——父组件以及
ErrorBoundary
本身。 - 重复的错误传播机制,因为
ErrorBoundary
已经具备了错误传递的所有机制,我们这里做了双重工作。
那我们能不能只使用 ErrorBoundary
从异步代码和事件处理程序中捕获这些错误吗?
使用ErrorBoundary捕获异步错误
Dan Abramov 在 github 中给我们分享了一个很酷的 hack 方法能够帮助我们实现这个功能,这个 hack 的技巧是首先使用 try/catch
捕获这些错误,然后再 catch 语句中触发正常的 React 重新渲染,然后将这些错误重新抛给重新渲染生命周期,这样 ErrorBoundary
就能够捕获到这些错误。同时由于状态更新是触发重新渲染的方式,并且更新状态的函数可以接受的一个新的函数作为参数,因此这个解决方案就是纯粹的魔法:
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 抽象出来,比如说封装在一个 hook 中:
const useThrowAsyncError = () => {
const [state, setState] = useState();
return (error) => {
setState(() => throw error)
}
}
使用的时候可以这样:
const Component = () => {
const throwAsyncError = useThrowAsyncError();
useEffect(() => {
fetch('/bla').then().catch((e) => {
// throw async error here!
throwAsyncError(e)
})
})
}
或者,我们可以像这样为回调创建一个包装器:
const useCallbackWithErrorHandling = (callback) => {
const [state, setState] = useState();
return (...args) => {
try {
callback(...args);
} catch(e) {
setState(() => throw e);
}
}
}
使用的时候可以这样
const Component = () => {
const onClick = () => {
// do something dangerous here
}
const onClickWithErrorHandler = useCallbackWithErrorHandling(onClick);
return <button onClick={onClickWithErrorHandler}>click me!</button>
}
总结
这就是今天的全部内容,希望从现在开始,如果您的应用程序出现问题,您将能够轻松优雅地处理这种情况,并且记住下面这几条:
try/catch
不会捕获像 useEffect 这样的钩子内部和任何子组件内部的错误。ErrorBoundary
可以捕获 useEffect 以及子组件中的错误,但它不会捕获异步代码和事件处理程序中的错误。- 如果要让
ErrorBoundary
捕获异步代码和事件处理程序中的错误,只需要先使用try/catch
捕获它们,然后将它们重新扔回 React 生命周期。
参考
转载自:https://juejin.cn/post/7202541740934479927