ErrorBoundary(错误边界)不起作用

196 阅读10分钟

前言

本文的背景是这样的:这两天在搞前端错误监控,然后就在代码的某处随便 console.log(xxx),xxx 是未定义的变量。期望错误边界能 catch 到对应的错误,从而渲染出备用的 ui,当然,按照故事的一般套路,结果肯定不是我所期待的,所以才有了这篇文章,不然到这里不就大结局了吗??哈哈哈哈~~ 那么接下来就由我带领大家,来探究 ErrorBoundary 的适用条件和不适用的场景,废话不多说,我们开始吧~

**什么是错误边界(Error Boundaries) **

我们可以从 React 官网 看到相应的概念:

部分 UI 的 JavaScript 错误不应该导致整个应用崩溃,为了解决这个问题,React 16 引入了一个新的概念 —— 错误边界。 错误边界是一种 React 组件,这种组件可以捕获发生在其子组件树任何位置的 JavaScript 错误,并打印这些错误,同时展示降级 UI,而并不会渲染那些发生崩溃的子组件树。错误边界在渲染期间、生命周期方法和整个组件树的构造函数中捕获错误。

实质上 ErrorBoundary 就是有实例方法 componentDidCatch 或静态方法 getDerivedStateFromError 的 class 组件。

其模板代码如下(我们之后的所有例子都使用该 ErrorBoundary 组件,后续代码不再贴出):


class ErrorBoundary extends Component {

  state = { error: null }

  // 1.通过componentDidCatch

  componentDidCatch(error: any, errorInfo: any) {

    this.setState({ error })

    console.log('捕获到错误', error, errorInfo)

  }

  // 2.通过 static getDerivedStateFromError

  //static getDerivedStateFromError(error: Error) {

  //  return { error }

  //}

  render() {

    if (this.state.error) {

      return <div>我是备用ui</div>

    }

  


    return this.props.children

  }

}

  


function App() {

  return (

    <ErrorBoundary>

      <Child/>

    </ErrorBoundary>

  );

}

  


特别注意上面加粗部分,我们下面会通过多个实例,同时结合源码探究 错误边界 的适用的条件以及不适用的场景。

渲染期间报错

组件 render 期间发生错误,比如:

读取某个对象的属性,但该对应是 null 或 undefined,那么就会报空指针错误

声明了不存在的变量,那么执行到相应代码就报错


function Child() {

  // Uncaught ReferenceError: xxx is not defined

  console.log(xxx)

  return <div>child</div>;

}

function App() {

  return (

    <ErrorBoundary>

      <Child/>

    </ErrorBoundary>

  );

}

简单源码解析

相应源码是在构造组件树(实质是 fiber 树)的过程中:


do {

  try {

    // 构造fiber树的过程

    workLoopConcurrent();

    break;

  } catch (thrownValue) {

    handleError(root, thrownValue);

  }

} while (true);

当执行到 Child 的 console.log(xxx) 时抛出了错误,那么就会被 catch 到,从而进入 handleError 中。handleError 中有当前 Fiber,对应上面例子就是 Child 对应的 WIPFiber(WIP 即为 workInProgress,是一个全局变量,意为当前正在构造的 fiber,不知道 fiber 是什么的,本文你可以简单理解为组件),那么就会从该 fiber 往上一直找呀找呀,直到发现父组件是类组件,且带有 componentDidCatch 或静态方法 getDerivedStateFromError,那么该父组件即为 ErrorBoundary。

// 错误边界是class组件


case ClassComponent:

      // 报错信息

      const errorInfo = value;

      // ErrorBoundary类

      const ctor = workInProgress.type;

      // ErrorBoundary实例

      const instance = workInProgress.stateNode;

      /**

       * 1.如果静态属性上有getDerivedStateFromError

       * 2.或者实例组件上有componentDidCatch

       * 则该fiber是错误边界,打上ShouldCapture的flag

       */

      if (

        (workInProgress.flags & DidCapture) === NoFlags &&

        (typeof ctor.getDerivedStateFromError === 'function' ||

          (instance !== null &&

            typeof instance.componentDidCatch === 'function' &&

            !isAlreadyFailedLegacyErrorBoundary(instance)))

      ) {

        // 打上ShouldCapture,之后可以在completeUnitOfWork中的unwindWork识别到是错误边界的fiber

        workInProgress.flags |= ShouldCapture;

        ...

        // 创建错误边界的update,重新render会使用这个update

        const update = createClassErrorUpdate(

          workInProgress,

          errorInfo,

          lane,

        );

        enqueueCapturedUpdate(workInProgress, update);

        // 找到错误边界后直接return,不再找

        return;

      }

      break;

createClassErrorUpdate 即创建 class 组件的错误 update,有可能包含:

1.  getDerivedStateFromError 的 payload

  1. componentDidCatch 的 callback

function createClassErrorUpdate(

  fiber: Fiber,

  errorInfo: CapturedValue<mixed>,

  lane: Lane,

): Update<mixed> {

  const update = createUpdate(NoTimestamp, lane);

  // update的tag打上CaptureUpdate

  update.tag = CaptureUpdate;

  // 拿静态属性getDerivedStateFromError

  const getDerivedStateFromError = fiber.type.getDerivedStateFromError;

  if (typeof getDerivedStateFromError === 'function') {

    /**

     * 如果是函数,则将其加入到update的payload中,比如

     * static getDerivedStateFromError(error) {

     * return { hasError: true };

     * }

     */

    const error = errorInfo.value;

    update.payload = () => {

      logCapturedError(fiber, errorInfo);

      // 参照上面例子,这里用return后,payload的值即为{ hasError: true }

      return getDerivedStateFromError(error);

    };

  }

  // 获取实例

  const inst = fiber.stateNode;

  if (inst !== null && typeof inst.componentDidCatch === 'function') {

    /**

     * 如果有componentDidCatch,如,将其放入update的callback中:

     * componentDidCatch(error, errorInfo) {

     *  logErrorToMyService(error, errorInfo);

     * }

     */

    update.callback = function callback() {

  


      if (typeof getDerivedStateFromError !== 'function') {

        ...

        logCapturedError(fiber, errorInfo);

      }

      const error = errorInfo.value;

      const stack = errorInfo.stack;

      this.componentDidCatch(error, {

        componentStack: stack !== null ? stack : '',

      });

  


    };

  }

  return update;

}

找到后,就从该错误边界类组件开始构造组件树,发现该组件上面有一个 update,如果有 getDerivedStateFromError,则获取 return 的 state;如果有 componentDidCatch,则可以 setState,上面两个方法之一都可以把 error 设置为非空(这里每个人写的不一样,你也可以声明一个 state 是 hasError,那么当 catch 到就设置为 true 也可以),再次 render 时this.state.error满足条件,从而就渲染出备用的 ui 了。

以上结合第一个例子,顺便讲解了错误边界的原理,下面例子就不再赘述。

生命周期报错

componentDidMountcomponentDidUpdate

componentDidMount 例子:


class ClassChild extends Component {

  componentDidMount() {

    // Uncaught ReferenceError: xxx is not defined

    console.log('componentDidMount');

    console.log(xxx);

  }

  render() {

    return <div>classChild</div>

  }

}

  


export default function App() {

  return (

    <ErrorBoundary>

      <ClassChild />}

    </ErrorBoundary>

  );

}

componentDidUpdate 例子:


class ClassChild extends Component {

  componentDidUpdate() {

    // Uncaught ReferenceError: xxx is not defined

    console.log('componentDidUpdate');

    console.log(xxx);

  }

  render() {

    return <div>classChild</div>

  }

}

  


export default function App() {

  const [count, addCount] = useCount();

  


  return (

    <ErrorBoundary>

      <div>count: {count}  <button onClick={addCount}>点击+1</button></div>

      <ClassChild />

    </ErrorBoundary>

  );

}

componentDidMount 和 componentDidUpdate 都是在 react 的 commit 中的 Layout 阶段,具体源码如下:


try {

    commitLayoutEffectOnFiber(root, current, fiber, committedLanes);

  } catch (error) {

    // 捕获到某个fiber发生错误,那么往上找错误边界,找到了就渲染备用ui

    captureCommitPhaseError(fiber, fiber.return, error);

  }

  


function commitLayoutEffectOnFiber(

  finishedRoot: FiberRoot,

  current: Fiber | null,

  finishedWork: Fiber,

  committedLanes: Lanes,

): void {

  // 忽略一些无关代码

  ...

  case ClassComponent: {

    // class组件

    const instance = finishedWork.stateNode;

        // 根据有无current来决定的didMount还是didUpdate

        if (current === null) {

            // 真正调用componentDidMount的地方

            instance.componentDidMount();

        } else {

            // 真正调用componentDidUpdate的地方

            instance.componentDidUpdate(

              prevProps,

              prevState,

              instance.__reactInternalSnapshotBeforeUpdate,

            );

          }

        }

  ...

}

captureCommitPhaseError 的原理和上面的简单源码解析的逻辑基本一致,也是从该子组件往上找错误边界的父组件。

componentWillUnmount

例子如下:


class ClassChild extends Component {

  componentWillUnmount() {

     console.log('componentWillUnmount');

     console.log(xxx);

  }

  render() {

    return <div>classChild</div>

  }

}

  


  


function App() {

  const [hide, setHide] = useState(false);

  return (

    <ErrorBoundary>

      <div><button onClick={() => setHide(true)}>点击卸载ClassChild</button></div>

      {!hide && <ClassChild />}

    </ErrorBoundary>

  );

}

componentWillUnmount是在 react 的 commit 中的 commitMutationtation 阶段,具体源码如下:

// 类组件


case ClassComponent: {

  // 获取实例

  const instance = current.stateNode;

  if (typeof instance.componentWillUnmount === 'function') {

    safelyCallComponentWillUnmount(

      current,

      nearestMountedAncestor,

      instance,

    );

  }

  return;

}

function safelyCallComponentWillUnmount(

  current: Fiber,

  nearestMountedAncestor: Fiber | null,

  instance: any,

) {

  try {

    callComponentWillUnmountWithTimer(current, instance);

  } catch (error) {

    // 本例捕获到class组件componentWillUnmount报错,那么往上找错误边界,找到了就渲染备用ui

    captureCommitPhaseError(current, nearestMountedAncestor, error);

  }

}

  


const callComponentWillUnmountWithTimer = function(current, instance) {

  instance.props = current.memoizedProps;

  ...

  // 真正调用componentWillUnmount的地方

  instance.componentWillUnmount();

  


};

useEffect

useEffect 是在 commit 阶段异步调度的,具体执行回调函数(create)和销毁函数的源码如下:


function flushPassiveEffectsImpl() {

  ...

  // 先执行销毁函数

  commitPassiveUnmountEffects(root.current);

  // 再执行回调

  commitPassiveMountEffects(root, root.current);

  ...

}

回调报错


function Child() {

  useEffect(() => {

    console.log('useEffect');

    console.log(xxx);

  }, []);

  return <div>child</div>;

}

export default function App() {

  return (

    <ErrorBoundary>

      <Child />

    </ErrorBoundary>

  );

}

销毁函数报错


function Child({ count }) {

  useEffect(() => {

    return () => {

      console.log('useEffect destroy');

      console.log(xxx);

    }

  }, [count]);

  return <div>child</div>;

}

function App() {

  const [hide, setHide] = useState(false)

  const [count, addCount] = useCount()

  return (

    <ErrorBoundary>

      <div><button onClick={addCount}>点击+1</button></div>

      <div><button onClick={() => setHide(true)}>点击卸载Child</button></div>

      {!hide && <Child count={count}/>}

    </ErrorBoundary>

  );

}

销毁函数的执行是 count 增加的时候或者 Child 组件卸载的时候:

count 增加,有渲染出备用 ui

Child 组件卸载,发现没渲染出备用 ui

为何后者无法被错误边界捕获呢?我们来看下销毁函数执行的代码:


function safelyCallDestroy(

  current: Fiber,

  nearestMountedAncestor: Fiber | null,

  destroy: () => void,

) {

  try {

    destroy();

  } catch (error) {

    // 本例捕获到useEffect销毁函数报错,那么往上找错误边界,找到了就渲染备用ui

    captureCommitPhaseError(current, nearestMountedAncestor, error);

  }

}

我们发现确实也有 catch 到,但有一点要注意,对于 Child 组件被卸载,因为 useEffect 是异步调度的,等到执行销毁函数时发现 Child 组件对应 fiber 的 return(指向父fiber) 已经被设置为 null 了:


function commitDeletion(

  finishedRoot: FiberRoot,

  current: Fiber,

  nearestMountedAncestor: Fiber,

): void {

  ...

  detachFiberMutation(current);

}

// commitMutation会调用,因为useEffect是异步的,所以这里先执行,

// 等到useEffect执行销毁函数的时候发现其fiber的return为空了

function detachFiberMutation(fiber: Fiber) {

  ...

  fiber.return = null;

}

所以当 catch 到错误的时候,还是会调用 captureCommitPhaseError 往上找 parent fiber,可是我们看到 detachFiberMutation 已经将 Child fiber 的 return 设置为 null 了,所以肯定就找不到错误边界了,而更新阶段触发 useEffect 的销毁函数,这时候 Child fiber 的 return 存在,那么就能找到错误边界。

useLayoutEffect

useLayoutEffect 是在 Layout 阶段调用回调,是同步执行的:


try {

  commitLayoutEffectOnFiber(root, current, fiber, committedLanes);

} catch (error) {

  // 本例捕获到函数组件useLayoutEffect回调报错,那么往上找错误边界,找到了就渲染备用ui

  captureCommitPhaseError(fiber, fiber.return, error);

}

  


function commitLayoutEffectOnFiber(

  finishedRoot: FiberRoot,

  current: Fiber | null,

  finishedWork: Fiber,

  committedLanes: Lanes,

): void {

  ...

  // 函数组件

  case FunctionComponent:

        ...

        commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork);

        break;

  ...

}

// 调用useLayoutEffect回调

function commitHookEffectListMount(flags: HookFlags, finishedWork: Fiber) {

  ...

  // 回调函数

  const create = effect.create;

  // 返回的销毁函数

  effect.destroy = create();

  ...

}

useLayoutEffect 的销毁函数也是同步执行的,且其销毁函数在detachFiberMutation(置空 fiber 的 return)之前:


function commitDeletion(

  finishedRoot: FiberRoot,

  current: Fiber,

  nearestMountedAncestor: Fiber,

): void {

    ...

    // useLayoutEffect的销毁函数会在这里同步执行,这个时候fiber.current还不为空

    commitNestedUnmounts(finishedRoot, current, nearestMountedAncestor);

  // 置空fiber的return

  detachFiberMutation(current);

}

由于回调和销毁函数都是同步的,所以都能捕获到错误:


function Child({count}) {

  useLayoutEffect(() => {

    return () => {

      console.log('useLayoutEffect destroy');

      console.log(xxx);

    }

  }, [count]);

  return <div>child</div>;

}

export default function App() {

  const [hide, setHide] = useState(false)

  const [count, addCount] = useCount()

  return (

    <ErrorBoundary>

      <div><button onClick={addCount}>点击+1</button></div>

      <div><button onClick={() => setHide(true)}>点击卸载Child</button></div>

      {!hide && <Child count={count}/>}

    </ErrorBoundary>

  );

}

###  错误边界不起作用的场景

上面我们分析的错误边界使用的条件,那么下面就来分析不适用的场景。

1.组件外的报错

如下例子:


// child.js

console.log(xxx)

  


function Child() {

  return <div>child</div>

}

这种情况肯定无法被 catch 到,所以错误边界也就无法起作用了

2.异步代码的报错

比如在生命周期、useEffect、useLayoutEffect 中使用了异步代码,等到执行回调报错,已经不在 catch 的作用域内,也就捕获不到了

如:


function Child() {

  useLayoutEffect(() => {

    setTimeout( () => {

      console.log('useLayoutEffect');

      console.log(xxx);

    })

  }, []);

  return <div>child</div>;

}

export default function App() {

  return (

    <ErrorBoundary>

      <Child />

    </ErrorBoundary>

  );

}

没渲染错误边界

3.事件函数中的报错

如:


function Child() {

  // Uncaught ReferenceError: xxx is not defined

  return <div onClick={()=> xxx}>child</div>;

}

export default function App() {

  return (

    <ErrorBoundary>

      <Child />

    </ErrorBoundary>

  );

}

4.错误边界自身抛出的错误


class ErrorBoundary extends Component {

  state = { error: null }

  


  componentDidCatch(error: any, errorInfo: any) {

    this.setState({ error })

    console.log('捕获到错误', error, errorInfo)

  }

  // static getDerivedStateFromError(error: Error) {

  //   return { error }

  // }

  render() {

    // Uncaught ReferenceError: xxx is not defined

    console.log(xxx);

    if (this.state.error) {

      return <div>我是备用ui</div>

    }

  


    return this.props.children

  }

}

5.错误边界的父组件报错

根据我们上面分析,组件抛出错误会往上找错误边界,但是如果是错误边界的父组件,那么往上无论再怎么找都肯定找不到。

函数组件被卸载,触发 useEffect 的销毁

这个我们上面分析了,这种情况下错误边界也不起作用

总结

ErrorBoundary 错误边界实际上就是子组件在渲染期间调用生命周期useEffectuseLayoutEffect 等这些场景下,利用 try catch 来捕获报错的组件:

即如果遇到错误,则会就被 catch 到,然后从该报错的组件往上找错误边界,只要父组件是类组件,且有实例属性 componentDidCatch 或静态属性 getSnapshotBeforeUpdate,那么就判定为错误边界,同时在上述两个方法中可以修改 state,从而渲染出备用的 ui,而不至于直接让页面白屏。

同时我们分析了错误边界的适用条件和不适用场景,其分别如下:

**适用条件: **

**1.组件渲染期间

2.生命周期

3.useEffect 和 useLayoutEffect 的 create 和 destroy(排除组件卸载触发的 useEffect 的 destroy) **

不适用条件

**1. 组件外的报错

2.异步代码的报错

3.事件函数中的报错

4.错误边界自身抛出的错误

5.错误边界的父组件

6.函数组件被卸载,触发 useEffect 的销毁

不适用的场景除了最后一个,其他都是因为没有被catch到错误。 **

最后

本文通过各种例子和相应的源码分析了什么是错误边界,错误边界的适用条件和不适用的场景,希望通过本文能让大家更加了解错误边界的原理,同时也要知道错误边界不是万能的,其也有适用的范围,错误的使用会导致错误边界不起作用。