见鬼,为何我的 ErrorBoundary(错误边界)不起作用

4,969 阅读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
  2. 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 了。

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

生命周期报错

componentDidMount、componentDidUpdate

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>
  );
}

错误边界不起作用的场景

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

组件外的报错

如下例子:

// child.js
console.log(xxx)

function Child() {
  return <div>child</div>
}

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

异步代码的报错

比如在生命周期、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>
  );
}

没渲染错误边界

事件函数中的报错

如:

function Child() {
  // Uncaught ReferenceError: xxx is not defined
  return <div onClick={()=> xxx}>child</div>;
}
export default function App() {
  return (
    <ErrorBoundary>
      <Child />
    </ErrorBoundary>
  );
}

错误边界自身抛出的错误

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
  }
}

错误边界的父组件报错

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

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

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

总结

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

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

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

适用条件:

  • 组件渲染期间
  • 生命周期
  • useEffect 和 useLayoutEffect 的 create 和 destroy(排除组件卸载触发的 useEffect 的 destroy)

不适用条件

  • 组件外的报错
  • 异步代码的报错
  • 事件函数中的报错
  • 错误边界自身抛出的错误
  • 错误边界的父组件
  • 函数组件被卸载,触发 useEffect 的销毁

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

最后

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

感谢留下足迹,如果您觉得文章不错😄😄,还请动动手指😋😋,点赞+收藏+转发🌹🌹

往期文章

翻译翻译,什么叫ReactDOM.createRoot

翻译翻译,什么叫JSX

什么,React Router已经到V6了 ??

React Router源码分析之history

系好安全带,带你遨游 React Router v6 源码