我们都希望我们的应用程序是稳定的,能够完美地工作,并满足每一个可以想象到的情况,但是!我们都是人啊,是人就会犯错,没有人敢说他写的代码一直都是完美的。不管我们有多小心,也不管我们写了多少自动化测试的脚本,总会有出错的情况发生。重要的是,当涉及到用户体验时,我们可以预测那个可怕的东西,尽可能地把它本地化,并以一种优雅的方式处理它,直到它能够被真正地修复。 所以今天,让我们来看看React中的错误处理:如果发生了错误,我们可以做什么,不同的错误捕捉方法有哪些注意事项,以及如何减轻它们。
为什么要在react中捕获错误呢?
为什么在React中拥有一些错误捕获解决方案是极其重要的呢? 答案很简单:从16版开始,在React生命周期中抛出的错误,如果不停止的话,将导致整个应用自行卸载。在此之前,组件会被保留在屏幕上,即使是一些错误的形态。现在,在UI中一些无关紧要的部分,甚至是一些你无法控制的外部库中,一个不幸的未被捕获的错误就可以导致整个页面白屏!这是我们开发的时候所无法接受的
如何在js中进行错误捕获?
我们可以使用try/catch在javascript中进行错误捕获处理
try {
doSomething();
} catch (e) {
console.log(e)
}
这也同样适用于异步函数
try {
await fetch('/bla-bla');
} catch (e) {
console.log(e)
}
或者我们在遇到promise时调用它的catch方法进行捕获
fetch('/bla-bla').then((result) => {
// if a promise is successful, the result will be here
}).catch((e) => {
console.log(e)
})
在react中如何使用try/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} />
}
我们正在尝试发送一个fetch请求,如果失败了,则设置错误状态,如果错误状态为true,则渲染一个带有一些附加信息的错误屏幕,以供用户参考。
这种方法非常简单直观,并且非常适合简单、可预测且范围狭窄的用例,例如捕获一个失败的fetch请求。
但是,如果您想捕获组件中可能发生的所有错误,您将面临一些挑战和严重的限制。
限制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的角度来看,一切都成功了。这与任何Promise的情况都相同:如果我们不等待结果,那么JavaScript将继续执行并在Promise完成时返回,然后只执行useEffect(或Promise的then)中的内容。此时,try/catch块已经执行完毕。
为了捕获useEffect内部的错误,也需要在其中放置try/catch块:
useEffect(() => {
try {
throw new Error('Hulk smash!');
} catch(e) {
// this one will be caught
}
}, [])
这适用于任何使用useEffect的钩子或任何异步的东西。因此,你必须把它分成多个块,而不是只用一个try/catch包裹所有东西。
限制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;
}
发生这种情况是因为当我们写时,我们实际上并没有渲染这个组件。我们所做的是创建一个组件元素,这只不过是一个组件的定义。它只是一个包含必要信息的对象,比如组件的type和props,以后会被React本身使用,它将实际触发这个组件的渲染。这将在try/catch块成功执行后发生,与promises和useEffect钩子的情况完全一样。
如果你很想更详细地了解元素和组件的工作原理,这里有一篇文章适合你。The mystery of React Element, children, parents and re-renders
限制3:在渲染过程中设置状态是不被允许的
如果你想在useEffect和各种回调之外捕捉错误(即在组件的渲染过程中),那么正确处理它们就不是那么简单了:渲染过程中的状态更新是不允许的。
例如,像这样简单的代码,如果发生错误,就会导致无限循环的重新渲染。
const Component = () => {
const [hasError, setHasError] = useState(false);
try {
doSomethingComplicated();
} catch(e) {
// don't do that! will cause infinite loop in case of an error
// see codesandbox below with live example
setHasError(true);
}
}
当然,我们可以在这里直接返回一个错误组件,而不是设置状态。
const Component = () => {
try {
doSomethingComplicated();
} catch(e) {
// this allowed
return <SomeErrorScreen />
}
}
但是,正如你所想象的,这有点麻烦,并且将迫使我们以不同的方式处理同一组件中的错误: useEffect 和 callback 的 state,以及其他所有内容直接 return。
// 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 ErrorBoundary component
为了减轻上面的限制,React给了我们所谓的 "错误边界":一个特殊的API,它把一个普通的组件变成一个try/catch语句的方式,只用于React声明性代码。你可以在那边的每个例子中看到的典型用法,包括React文档,会是这样的。
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方法,将该组件变成一个适当的错误边界。
在处理错误时,另一件重要的事情是将错误信息发送到某个地方,让它能够唤醒所有正在值班的人。为此,错误边界给了我们componentDidCatch方法。
class ErrorBoundary extends React.Component {
// everything else stays the same
componentDidCatch(error, errorInfo) {
// send error to somewhere here
log(error, errorInfo);
}
}
在建立了错误边界之后,我们可以对它进行任何我们想要的操作,就像任何其他组件一样。例如,我们可以让它更具可重用性,并将后备系统作为一个支柱:
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 component的限制
错误边界只捕获在 React 生命周期中发生的错误。在它之外发生的事情,如已resolve的promise、带有setTimeout的异步代码、各种回调和事件处理程序,如果不明确处理,将直接消失。
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>
)
}
但是。我们又回到了原点:每个组件都需要保持其 "错误 "状态,更重要的是——决定如何处理它。
当然,我们可以不在组件层面上处理这些错误,而只是通过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中捕获异步错误
有趣的是——我们可以用ErrorBoundary把它们都抓起来!大家最喜欢的Dan Abramov和我们分享了一个很酷的黑客技术。大家最喜欢的Dan Abramov与我们分享了一个很酷的黑科技,正是为了实现这一点。从钩子中抛出的错误未被错误边界捕获 Throwing Error from hook not caught in error boundary · Issue #14981 · facebook/react.
这里的诀窍是先用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;
})
}
}
}
这里的最后一步将是提取出公共的方法,所以我们不必在每个组件中创建随机状态。我们可以在这里发挥创意,做一个钩子,给我们一个异步错误抛出器。
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可以捕捉它们,但它不会捕捉异步代码和事件处理程序中的错误。
- 不过,你可以让ErrorBoundary捕捉这些错误,你只需要先用try/catch捕捉它们,然后再把它们重新扔回React生命周期中。