react多组件出错其他正常显示

问题:一个组件内部有很多个子组件,其中一个出错,怎么实现其他组件可以正常显示,而不是页面挂掉?

一、错误边界

可以捕获发生在其子组件树任何位置的 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)

此生命周期在后代组件抛出错误后被调用。 它接收两个参数:

  1. error —— 抛出的错误。
  2. 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 的情况:

  1. xxxKey 触发了 error,组件报错
  2. 组件报错导致 resetKeys 里的一些东西改了
  3. componentDidUpdate 发现 resetKeys 里有东西更新了,不废话,马上重置
  4. 重置完了,显示报错的组件,因为 error 还存在(或者还未解决),报错的组件又再次触发了 error
  5. ...

如下:假如接口请求失败导致组件报错即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();
    }
  }
  1. 用 updatedWithError 作为 flag 判断是否已经由于 error 出现而引发的 render/update
  2. 如果当前没有错误,无论如何都不会重置
  3. 每次更新:当前存在错误,且第一次由于 error 出现而引发的 render/update,则设置 updatedWithError = true,不会重置状态
  4. 每次更新:当前存在错误,且如果 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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值