问题:一个组件内部有很多个子组件,其中一个出错,怎么实现其他组件可以正常显示,而不是页面挂掉?
一、错误边界
可以捕获发生在其子组件树任何位置的 JavaScript 错误,并打印这些错误,同时展示降级 UI,错误边界可以捕获发生在整个子组件树的渲染期间、生命周期方法以及构造函数中的错误。
错误边界无法捕获以下场景中产生的错误:
- 事件处理(了解更多)
- 异步代码(例如
setTimeout
或requestAnimationFrame
回调函数) - 服务端渲染
- 它自身抛出来的错误(并非它的子组件)
错误边界的工作方式类似于 JavaScript 的 catch {}
,不同的地方在于错误边界只针对 React 组件。只有 class 组件才可以成为错误边界组件。大多数情况下, 你只需要声明一次错误边界组件, 并在整个应用中使用它。
注意错误边界仅可以捕获其子组件的错误,它无法捕获其自身的错误。如果一个错误边界无法渲染错误信息,则错误会冒泡至最近的上层错误边界,这也类似于 JavaScript 中 catch {}
的工作机制。
如果一个 class 组件中定义了 static getDerivedStateFromError() 或 componentDidCatch() 这两个生命周期方法中的任意一个(或两个)时,那么它就变成一个错误边界。当抛出错误后,请使用 static getDerivedStateFromError()
渲染备用 UI ,使用 componentDidCatch()
打印错误信息。
错误边界应该放置在哪?
错误边界的粒度由你来决定,可以将其包装在最顶层的路由组件并为用户展示一个 “Something went wrong” 的错误信息,就像服务端框架经常处理崩溃一样。你也可以将单独的部件包装在错误边界以保护应用其他部分不崩溃。
static getDerivedStateFromError(error)
此生命周期会在后代组件抛出错误后被调用。 它将抛出的错误作为参数,并返回一个值以更新 state,在渲染
阶段调用,因此不允许出现副作用。 如遇此类情况,请用 componentDidCatch()
。
componentDidCatch(error, info)
此生命周期在后代组件抛出错误后被调用。 它接收两个参数:
error
—— 抛出的错误。info
—— 带有componentStack
key 的对象,其中包含有关组件引发错误的栈信息。
在“提交”阶段被调用,因此允许执行副作用。
注意:如果发生错误,你可以通过调用 setState
使用 componentDidCatch()
渲染降级 UI,但在未来的版本中将不推荐这样做。 可以使用静态 getDerivedStateFromError()
来处理降级渲染。
1、基本使用
如下:若是没有ErrorBoundary组件,则组件内部报错整个页面会挂掉, 最顶层使用ErrorBoundary,那么一个组件报错整个页面UI会降级显示,若是每个子组件都包裹一层ErrorBoundary,那么一个组件出错,其他可以正常显示,出错的那个组件位置显示降级UI除非return null什么都不显示
import ErrorBoundary from "./components/ErrorBoundary";
import Child1 from "./test/Child1";
import Child2 from "./test/Child2";
import Child3 from "./test/Child3";
const Child = function () {
return (
<ErrorBoundary>
<Child1 />
</ErrorBoundary>
);
};
//父组件中含多个子组件,若一个组件内部出问题,其他组件可以正常显示=》每个子组件包括一层ErrorBoundary进行UI降级或直接return null
function App() {
return (
<div className="App">
<ErrorBoundary>
<Child />
{/* <Child1 /> */}
<Child2 />
<Child3 />
</ErrorBoundary>
</div>
);
}
export default App;
const d: any = {};
const Child1 = memo((props) => {
console.log(d.d.y);
return <p>this is Child1</p>;
});
export default Child1;
const Child2 = (props) => {
const [count, setCount] = useState<number>(0);
return (
<div>
<p>this is Child2</p>
<p>count:{count}</p>
<button onClick={() => setCount((prev) => prev + 1)}>click me</button>
</div>
);
};
export default Child2;
const Child3 = (props) => {
return <p>this is Child3</p>;
};
export default Child3;
import React from "react";
interface Props {
children: React.ReactNode; //ReactElement只能一个根元素 多个用ReactNode
}
interface State {
hasError: boolean;
}
class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: string) {
// 更新 state 使下一次渲染能够显示降级后的 UI
return { hasError: true };
}
componentDidCatch(error: any, errorInfo: any) {
// 你同样可以将错误日志上报给服务器
// logErrorToMyService(error, errorInfo);
console.log("componentDidCatch: ", error, errorInfo);
}
render() {
if (this.state.hasError) {
// 你可以自定义降级后的 UI 并渲染
// return null
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
export default ErrorBoundary;
2、可配置的错误边界
将日志上报的方法以及显示的 UI
通过接受传参的方式进行动态配置,对于传入的UI
,我们可以设置以react
组件的方式 或 是一个React Element
进行接受,而且通过组件的话,我们可以传入参数,这样可以在兜底 UI 中拿到具体的错误信息。
import React from "react";
interface FallbackRenderProps {
error: Error;
}
interface Props {
children: React.ReactNode; //ReactElement只能一个根元素 多个用ReactNode
onError?: (error: Error, errorInfo: string) => void;
fallback?: React.ReactElement;
FallbackComponent?: React.FunctionComponent<FallbackRenderProps>;
}
interface State {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null};
}
static getDerivedStateFromError(error: Error) {
// 更新 state 使下一次渲染能够显示降级后的 UI
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
if (this.props.onError) {
//上报日志通过父组件注入的函数进行执行
this.props.onError(error, errorInfo.componentStack);
}
}
render() {
const { fallback, FallbackComponent } = this.props;
const { error } = this.state;
if (error) {
const fallbackProps = { error };
//判断是否为React Element
if (React.isValidElement(fallback)) {
return fallback;
}
//组件方式传入
if (FallbackComponent) {
return <FallbackComponent {...fallbackProps} />;
}
throw new Error("ErrorBoundary 组件需要传入兜底UI");
}
return this.props.children;
}
}
export default ErrorBoundary;
使用:
import ErrorBoundary from "./components/ErrorBoundary";
import ErrorBoundaryWithConfig from "./components/ErrorBoundaryWithConfig";
import Child1 from "./test/ErrorTest/Child1";
import Child2 from "./test/ErrorTest/Child2";
import Child3 from "./test/ErrorTest/Child3";
interface IErrorUIprops {
error: Error;
}
const ErrorUI: React.FC<IErrorUIprops> = ({ error }) => {
return (
<div>
<p>出错了....</p>
<p>
错误信息:
{JSON.stringify(error, ["message", "arguments", "type", "name"])}
</p>
</div>
);
};
const Child = function () {
const onError = (error: Error, errorInfo: string) => {
console.log("Child error ", error);
console.log("Child errorInfo ", errorInfo);
};
return (
<ErrorBoundaryWithConfig onError={onError} FallbackComponent={ErrorUI}>
<Child1 />
</ErrorBoundaryWithConfig>
);
};
function App() {
return (
<div className="App">
<ErrorBoundary>
<Child />
{/* <Child1 /> */}
<Child2 />
<ErrorBoundaryWithConfig fallback={<p>出错了....</p>}>
<Child3 />
</ErrorBoundaryWithConfig>
</ErrorBoundary>
</div>
);
}
export default App;
进一步优化:有时候会遇到这种情况:服务器突然 503、502 了,前端获取不到响应,这时候某个组件报错了,但是过一会又正常了。比较好的方法是用户点一下被ErrorBoundary
封装的组件中的一个方法来重新加载出错组件,不需要重刷页面,这时候需要兜底的组件中应该暴露出一个方法供ErrorBoundary
进行处理。
import React from "react";
interface FallbackRenderProps {
error: Error;
resetErrorBoundary?: () => void;
}
interface Props {
children: React.ReactNode; //ReactElement只能一个根元素 多个用ReactNode
onError?: (error: Error, errorInfo: string) => void;
fallback?: React.ReactElement;
FallbackComponent?: React.FunctionComponent<FallbackRenderProps>;
onReset?: () => void;
fallbackRender?: (
fallbackRenderProps: FallbackRenderProps
) => React.ReactElement;
}
interface State {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error) {
// 更新 state 使下一次渲染能够显示降级后的 UI
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
if (this.props.onError) {
//上报日志通过父组件注入的函数进行执行
this.props.onError(error, errorInfo.componentStack);
}
}
resetErrorBoundary = () => {
if (this.props.onReset) this.props.onReset();
this.setState({ hasError: false, error: null });
};
render() {
const { fallback, FallbackComponent, fallbackRender } = this.props;
const { error } = this.state;
if (error) {
const fallbackProps = {
error,
resetErrorBoundary: this.resetErrorBoundary,
};
//判断是否为React Element
if (React.isValidElement(fallback)) {
return fallback;
}
//函数方式传入
if (typeof fallbackRender === "function") {
return fallbackRender(fallbackProps);
}
//组件方式传入
if (FallbackComponent) {
return <FallbackComponent {...fallbackProps} />;
}
throw new Error("ErrorBoundary 组件需要传入兜底UI");
}
return this.props.children;
}
}
export default ErrorBoundary;
如上:正常是显示children,children里面报错就会被捕获到,之后进行UI降级, 重置也是使其显示children,若是没有错误了那么正常显示,若是还是有错误还会被捕获到。
import { useState } from "react";
import ErrorBoundary from "./components/ErrorBoundary";
import ErrorBoundaryWithConfig from "./components/ErrorBoundaryWithConfig";
// import Home from "./test/home";
import Child1 from "./test/ErrorTest/Child1";
import Child2 from "./test/ErrorTest/Child2";
import Child3 from "./test/ErrorTest/Child3";
interface IErrorUIprops {
error: Error;
resetErrorBoundary?: () => void;
}
const ErrorUI: React.FC<IErrorUIprops> = ({ error, resetErrorBoundary }) => {
return (
<div>
<p>出错了....</p>
<p>
错误信息:
{JSON.stringify(error, ["message", "arguments", "type", "name"])}
</p>
{resetErrorBoundary && (
<button onClick={resetErrorBoundary}>Try again</button>
)}
</div>
);
};
function App() {
const [count, setCount] = useState(0);
const onReset = () => setCount(0); //点击重置时进行的回调
const onError = (error: Error, errorInfo: string) => {
console.log("Child error ", error);
console.log("Child errorInfo ", errorInfo);
};
// fallback 组件的渲染函数
const renderFallback = (props: IErrorUIprops) => {
return <ErrorUI {...props} />;
};
return (
<div className="App">
<ErrorBoundary>
<section>
<button onClick={() => setCount((count) => count + 1)}>+</button>
<button onClick={() => setCount((count) => count - 1)}>-</button>
</section>
<hr />
{/* <Child1 /> */}
<ErrorBoundaryWithConfig
onError={onError}
onReset={onReset}
fallbackRender={renderFallback}
/*FallbackComponent={ErrorUI}*/
>
<Child1 count={count} />
</ErrorBoundaryWithConfig>
<Child2 />
<ErrorBoundaryWithConfig fallback={<p>出错了....</p>}>
<Child3 count={count} />
</ErrorBoundaryWithConfig>
</ErrorBoundary>
</div>
);
}
export default App;
注意:点击+,当达到2时Child1报错( if (count === 2) throw new Error("count is two");),UI降级,继续点击+,为3时Child3报错(if (count === 3) throw new Error("count is three");)UI降级,此时Child1有重置,点击重置按钮onReset把count重置为0了,Child1 UI也重置了显示正常,而Child3之前显示了降级UI,没有重置或不刷新,页面即使数据正常了UI不会更新,还是降级UI,所以重置按钮在不刷新页面情况下可以解决此类问题。
局限性:触发重置的动作只能在 fallback 里面。假如我的重置按钮不在 fallback 里呢?或者 onReset 函数根本不在这个 App 组件下那怎么办呢?难道要将 onReset 像传家宝一路传到这个 App 再传入 ErrorBoundary 里?
思路1:能不能监听状态的更新,只要状态更新就重置,反正就重新加载组件也没什么损失,这里的状态完全用全局状态管理,放到 Redux 中。
思路2:上面的思路听起来不就和 useEffect 里的依赖项 deps 数组一样嘛,不妨在 props 提供一个 resetKeys
数组,如果这个数组里的东西变了,ErrorBoundary 就重置,这样一控制是否要重置就更灵活了。
假如是由于网络波动引发的异常,那页面当然会显示 fallback 了,如果用上面直接调用 props.resetErrorBoundary 方法来重置,只要用户不点“重置”按钮,那块地方永远不会被重置。又由于是因为网络波动引发的异常,有可能就那0.001 秒有问题,别的时间又好了,所以如果我们将一些变化频繁的值放到 resetKeys
里就很容易自动触发重置。例如,报错后,其它地方的值变了从而更改了 resetKeys
的元素值就会触发自动重置。对于用户来说,最多只会看到一闪而过的 fallback,然后那块地方又正常了。
// 本组件 ErrorBoundary 的 props
interface Props{
...
resetKeys?: Array<unknown>;
onResetKeysChange?: (
prevResetKey: Array<unknown> | undefined,
resetKeys: Array<unknown> | undefined,
) => void;
}
// 检查 resetKeys 是否有变化
const changedArray = (a: Array<unknown> = [], b: Array<unknown> = []) => {
return (
a.length !== b.length || a.some((item, index) => !Object.is(item, b[index]))
);
};
class ErrorBoundary extends React.Component<Props, State> {
...
componentDidUpdate(prevProps: Readonly<React.PropsWithChildren<Props>>) {
const { resetKeys, onResetKeysChange } = this.props;
// 只要 resetKeys 有变化,直接 reset
if (changedArray(prevProps.resetKeys, resetKeys)) {
if (onResetKeysChange) {
onResetKeysChange(prevProps.resetKeys, resetKeys);
}
// 重置 ErrorBoundary 状态,并调用 onReset 回调
this.reset();
}
}
resetErrorBoundary = () => {
if (this.props.onReset) this.props.onReset();
this.reset();
};
reset = () => {
this.setState({ hasError: false, error: null });
};
render() {
...
}
}
上面存在问题:假如某个 key 是触发 error 的元凶,那么就有可能触发二次 error 的情况:
xxxKey
触发了 error,组件报错- 组件报错导致
resetKeys
里的一些东西改了 componentDidUpdate
发现resetKeys
里有东西更新了,不废话,马上重置- 重置完了,显示报错的组件,因为 error 还存在(或者还未解决),报错的组件又再次触发了 error
- ...
如下:假如接口请求失败导致组件报错即xxkey触发组件错误:render渲染children,children报错被getDerivedStateFromError等捕获UI降级,捕获到错误后重新请求导致resetKeys
里面的请求状态又发生改变,componentDidUpdate
就会重置,重置后组件还是报错,就会出现如上循环。=》包括下面案例只是简单举例便于理解,应用场景不符合
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
if (this.props.onError) {
//上报日志通过父组件注入的函数进行执行
this.props.onError(error, errorInfo.componentStack);
}
}
const onError = (error: Error, errorInfo: string) => {
setCount(Math.random() * 3);
};
const Child1: FC<Props> = memo(({ count }) => {
if (count < 3) throw new Error("count is two");
return <p>this is Child1</p>;
});
export default Child1;
这样的情况下就会被该ErrorBoundary的上层错误边界捕获,导致整体UI降级。
优化:有错误才重置,且不是因为错误导致后续连续
componentDidUpdate(prevProps: Readonly<React.PropsWithChildren<Props>>, preState: State) {
const {error} = this.state;
const {resetKeys, onResetKeysChange} = this.props;
// 已经存在错误,并且是第一次由于 error 而引发的 render/update,那么设置 flag=true,不会重置
if (error !== null && !this.updatedWithError) {
this.updatedWithError = true;
return;
}
// 已经存在错误,并且是普通的组件 render,则检查 resetKeys 是否有改动,改了就重置
if (error !== null && preState.error !== null && changedArray(prevProps.resetKeys, resetKeys)) {
if (onResetKeysChange) {
onResetKeysChange(prevProps.resetKeys, resetKeys);
}
this.reset();
}
}
- 用
updatedWithError
作为 flag 判断是否已经由于 error 出现而引发的 render/update - 如果当前没有错误,无论如何都不会重置
- 每次更新:当前存在错误,且第一次由于 error 出现而引发的 render/update,则设置
updatedWithError = true
,不会重置状态 - 每次更新:当前存在错误,且如果
updatedWithError
为true
说明已经由于 error 而更新过了,以后的更新只要resetKeys
里的东西改了,都会被重置
简单案例:
function App() {
const [explode, setExplode] = React.useState(false)
return (
<div>
<button onClick={() => setExplode(e => !e)}>toggle explode</button>
<ErrorBoundary
FallbackComponent={ErrorFallback}
onReset={() => setExplode(false)}
resetKeys={[explode]}
>
{explode ? <Bomb /> : null}
</ErrorBoundary>
</div>
)
}
注意执行逻辑:
初次渲染,执行render,渲染children报错,getDerivedStateFromError捕获错误,导致state变化,重新执行render,UI降级,初次渲染不执行componentDidUpdate。在错误捕获时执行了componentDidCatch,导致resetkey变化,props变化重新执行render,遇到判断error存在还是显示降级UI,那么就不不会再执行getDerivedStateFromError和componentDidCatch,但是props变化了会执行componentDidUpdate,在这里判断若是由于出错导致更新跳过重置=》整体就是出错导致UI降级。
非初次渲染:如初次渲染页面正常,父组件某些操作导致状态变化影响到resetkey变化,props改变执行render,渲染children报错,getDerivedStateFromError捕获错误,导致state变化,重新执行render,UI降级,但此时不是初次渲染props改变要执行componentDidUpdate,在上面判断了判断若是由于出错导致更新跳过重置。但是componentDidCatch执行时又改变resetkey,props改变执行render,此时error存在直接显示降级UI不会再触发getDerivedStateFromError和componentDidCatch,但是props变化了要执行componentDidUpdate,此时已经不是错误导致的更新,componentDidUpdate执行重置(保证重置没有问题否则又会从开始非初次渲染循环,此时可以在componentDidCatch设置this.updatedWithError = false,但是这样就没有意义了)
在 componentDidUpdate 里,只要不是由于 error 引发的组件渲染或更新,而且 resetKeys
有变化了,那么直接重置组件状态来达到自动重置=》只适用于某些场景,使用时注意。
至此,我们拥有了两种可以实现重置的方式了:
方法 | 触发范围 | 使用场景 | 思想负担 |
---|---|---|---|
手动调用 resetErrorBoundary | 一般在 fallback 组件里 | 用户可以在 fallback 里手动点击“重置”实现重置 | 最直接,思想负担较轻 |
更新 resetKeys | 哪里都行,范围更广 | 用户可以在报错组件外部重置、resetKeys 里有报错组件依赖的数据、渲染时自动重置 | 间接触发,要思考哪些值放到 resetKeys 里,思想负担较重 |
以上ErrorBoundary的使用可以整体封装成HOC:
import ErrorBoundary from "../components/ErrorBoundaryWithConfig";
import { Props as ErrorBoundaryProps } from "../components/ErrorBoundaryWithConfig";
/**
* with 写法
* @param Component 业务组件
* @param errorBoundaryProps error boundary 的 props
*/
function withErrorBoundary<P = {}>(
Component: React.ComponentType<P>,
errorBoundaryProps: ErrorBoundaryProps
): React.ComponentType<P> {
const Wrapped: React.ComponentType<P> = (props) => {
return (
<ErrorBoundary {...errorBoundaryProps}>
<Component {...props} />
</ErrorBoundary>
);
};
// DevTools 显示的组件名
const name = Component.displayName || Component.name || "Unknown";
Wrapped.displayName = `withErrorBoundary(${name})`;
return Wrapped;
}
export default withErrorBoundary;
在使用错误边界组件处理普通组件时,错误边界无法捕获异步代码、服务端错误、事件内部错误以及自己错误,所以遇到这种情况可以使用try catch或者异步操作自身的catch捕获,或者直接抛出异常,封装如下:
function useErrorHandler(givenError?: unknown): (error: unknown) => void {
const [error, setError] = React.useState<unknown>(null)
if (givenError != null) throw givenError
if (error != null) throw error
return setError
}
使用:
import { useErrorHandler } from 'react-error-boundary'
function Greeting() {
const [greeting, setGreeting] = React.useState(null)
const handleError = useErrorHandler()
function handleSubmit(event) {
event.preventDefault()
const name = event.target.elements.name.value
fetchGreeting(name).then(
newGreeting => setGreeting(newGreeting),
handleError,
)
}
return greeting ? (
<div>{greeting}</div>
) : (
<form onSubmit={handleSubmit}>
<label>Name</label>
<input id="name" />
<button type="submit">get a greeting</button>
</form>
)
}
参考:
第三方库:react-error-boundary
GitHub - haixiangyan/my-react-error-bounday: 手把手教你实现 react-error-boundary