「React 技巧」: ErrorBounary

343 阅读6分钟

摘要

JavaScript 的错误会破坏 React 的内部状态,进而导致整个应用崩溃。为了解决这个问题,React 引入了错误边界。错误边界可以捕获子组件的 JavaScript 错误,打印这些错误并展示降级 UI。通过阅读本文,你可以了解到 ErrorBounary 使用方式和原理。文章的最后,提供了在线代码。

在线运行代码:codesandbox.io/s/react-sus…

介绍

过去,在使用 React 开发时遇到组件内部的任何 JavaScript 错误,都会破坏 React 的内部状态导致页面崩溃。例如,我们声明了一个组件 TriggleError,从 props 中解构 data 对象并展示 data.name。实际上 props 里并没有 data:

// TriggleError.jsx
import React from "react";
​
const TriggleError = (props) => {
  const { data } = props;
  return <div>{data.name}</div>;
};
​
export default TriggleError;

image.png

JavaScript 的报错导致页面奔溃,对于开发者和使用者来说都难以接受。为了解决这一问题,React 16 引入了一个新的概念 ErrorBounary(错误边界)来处理错误。ErrorBounary 特性

  • ErrorBounary 是一种 React 组件,可以在应用程序的任何位置捕获 JavaScript 错误,打印这些错误,并显示降级 UI。
  • ErrorBounary 不会破坏应用程序的组件树,只会在组件中发生错误是展示降级UI。
  • ErrorBounary 可以捕获子组件树渲染期间、生命周期方法以及构造函数中的错误。

但它并非万能,无法捕获以下场景中产生的错误:

  • 事件处理
  • 异步代码(例如 setTimeoutrequestAnimationFrame 回调函数)
  • 服务端渲染
  • 它自身抛出来的错误(并非它的子组件)

在上篇 「React 技巧」: Suspense 中提到,ErrorBounary 和 Suspense 相似。ErrorBounary 是用于捕获组件的错误,Suspense 时捕获组件的 Promise 异步状态。

image.png

方法

ErrorBounary Use Cases

在 React class 组件中,定义了 static getDerivedStateFromError()componentDidCatch() 这两个生命周期方法中的任意一个时,那么它就变成一个ErrorBounary。当抛出错误后,使用 getDerivedStateFromError() 渲染备用 UI ,通过 componentDidCatch() 打印错误信息。例如,想要展示 Something went wrong! 的降级 UI,在 TriggleError 组件外层:

// CatchError.jsx
import React from "react";
import TriggleError from "./TriggleError";
import ErrorBoundary from "./ErrorBounary";
​
const CatchError = () => {
  return (
    <ErrorBoundary>
      <TriggleError />
    </ErrorBoundary>
  );
};
​
export default CatchError;

可以使用已有的 JS库 react-error-bounary 替代自己实现。

// ErrorBounary.jsx
import React from "react";
​
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }
​
  static getDerivedStateFromError(error) {
    // 更新 state 使下一次渲染能够显示降级后的 UI
    return { hasError: true };
  }
​
  componentDidCatch(error, errorInfo) {
    // 你同样可以将错误日志上报给服务器
    console.log(error, errorInfo);
  }
​
  render() {
    if (this.state.hasError) {
      // 你可以自定义降级后的 UI 并渲染
      return <h1>Something went wrong!</h1>;
    }
​
    return this.props.children;
  }
}
​
export default ErrorBoundary;

image.png

ErrorBounary 无法捕捉事件处理的错误,但我们可以通过 try catch 来处理事件中的错误,保证程序的健壮性:

import React from "react";
​
const TriggleError = (props) => {
  const { data } = props;
  console.log("data", props.data);
​
  const handleClick = () => {
    try {
      // 执行操作,如有错误则会抛出
    } catch(e) {
      // pass
    }
  };
​
  return <div onClick={handleClick}>{data.name}</div>;
};
​
export default TriggleError;

ErrorBounary Mechanism

ErrorBounary 的源码已经在上面给出,通过 getDerivedStateFromError 在 react render 阶段调用更新 state 来显示降级 UI。通过 componentDidCatch 在 react commit 阶段调用,执行上报错误日志等副作用。

React 错误处理工作流程

  1. Find ErrorBounary: 从抛出异常的 Fiber 节点开始向上遍历,寻找 ErrorBounary;如果没找到,交给根节点来处理;
  2. ErrorBounary ?捕获错误:交给根节点:如果有ErrorBounary,创建一个 payload 为 getDerivedStateFromError 方法更新 state 值、 callback 为 componentDidCatch 的更新任务;如果是由根节点来处理异常,则创建一个卸载整个组件树的更新任务。
  3. Render: 进入处理异常的节点的 render 过程中,执行 performUnitOfWork,在该过程中会执行刚刚创建的更新任务。
  4. Render: 错误边界来处理异常,渲染降级 UI;由根节点来处理异常,则会卸载掉整个组件树,导致白屏。

根据以上四步结合源码分析:

  1. Find ErrorBounary

ReactFiberWorkLoop.js

do {
  try {
    workLoopSync(); // workLoopSync中会调用beginWork
    break;
  } catch (thrownValue) {
    handleError(root, thrownValue); // 处理异常
  }
} while (true);

handleError 通过循环来寻找 ErrorBounary 或找到根节点:

  • 当前节点或当前节点的父节点为 null,说明没有 ErrorBounary,结束循环;
  • throwException 向父组件遍历,寻找 ErrorBounary;
  • 执行 completeUnitOfWork,处理错误流程。

handleError

function handleError(root, thrownValue): void {
  do {
    let erroredWork = workInProgress;
    try {
      ...
      if (erroredWork === null || erroredWork.return === null) {
        // 没有 ErrorBounary
        workInProgressRootExitStatus = RootFatalErrored;
        workInProgressRootFatalError = thrownValue;
        workInProgress = null;
        return;
      }
      ...
      // 向父组件遍历
      throwException(
        root,
        erroredWork.return,
        erroredWork,
        thrownValue,
        workInProgressRootRenderLanes,
      );
      // 处理信息
      completeUnitOfWork(erroredWork);
    } catch (yetAnotherThrownValue) {
      ..
    }
    return;
  } while (true);
}

throwException

  • 给节点的 EffectTag 标记为 Incomplete,进入异常处理逻辑;
  • 向父组件遍历,当组件是 ClassComponent,且包含 getDerivedStateFromError 和 componentDidCatch 两者或其中之一,认为该节点是 ErrorBounary;
  • 并给该节点打上 ShouldCapture 的 EffectTag,进入异常处理逻辑;分配一个最高优的 lane,保证本次 render 执行。
function throwException(
  root: FiberRoot,
  returnFiber: Fiber,
  sourceFiber: Fiber,
  value: mixed, // 异常本身
  rootRenderLanes: Lanes,
) {
  // 标记
  sourceFiber.effectTag |= Incomplete;
  sourceFiber.firstEffect = sourceFiber.lastEffect = null;
  renderDidError();
​
  value = createCapturedValue(value, sourceFiber);
  let workInProgress = returnFiber;
  do {
    switch (workInProgress.tag) {
      // 根节点处理错误
      case HostRoot: {
        const errorInfo = value;
        workInProgress.effectTag |= ShouldCapture;
        // render
        const lane = pickArbitraryLane(rootRenderLanes);
        workInProgress.lanes = mergeLanes(workInProgress.lanes, lane);
        const update = createRootErrorUpdate(workInProgress, errorInfo, lane); 
        enqueueCapturedUpdate(workInProgress, update);
        return;
      }
      case ClassComponent:
        const errorInfo = value;
        const ctor = workInProgress.type;
        const instance = workInProgress.stateNode;
        
        if (
          (workInProgress.effectTag & DidCapture) === NoEffect &&
          (typeof ctor.getDerivedStateFromError === 'function' ||
            (instance !== null &&
              typeof instance.componentDidCatch === 'function' &&
              !isAlreadyFailedLegacyErrorBoundary(instance)))
        ) {
          // ErrorBounary 处理错误,标记
          workInProgress.effectTag |= ShouldCapture; 
          const lane = pickArbitraryLane(rootRenderLanes);
          workInProgress.lanes = mergeLanes(workInProgress.lanes, lane);
          const update = createClassErrorUpdate(
            workInProgress,
            errorInfo,
            lane,
          );
          enqueueCapturedUpdate(workInProgress, update);
          return;
        }
        break;
      default:
        break;
    }
    workInProgress = workInProgress.return;
  } while (workInProgress !== null);
}
  1. ErrorBounary ?捕获错误:交给根节点

当找到ErrorBounary时,调用 createClassErrorUpdate 将 payload 设置为 getDerivedStateFromError,callback 设置为 componentDidCatch。

function createClassErrorUpdate(
  fiber: Fiber,
  errorInfo: CapturedValue<mixed>,
  lane: Lane,
): Update<mixed> {
  const update = createUpdate(NoTimestamp, lane, null);
  update.tag = CaptureUpdate;
  const getDerivedStateFromError = fiber.type.getDerivedStateFromError;
  if (typeof getDerivedStateFromError === 'function') {
    const error = errorInfo.value;
    update.payload = () => {
      logCapturedError(fiber, errorInfo);
      return getDerivedStateFromError(error);
    };
  }
​
  const inst = fiber.stateNode;
  if (inst !== null && typeof inst.componentDidCatch === 'function') {
    update.callback = function callback() {
      if (typeof getDerivedStateFromError !== 'function') {
      }
      const error = errorInfo.value;
      const stack = errorInfo.stack;
      this.componentDidCatch(error, {
        componentStack: stack !== null ? stack : '',
      });
    };
  }
  return update;
}
​

未找到 ErrorBounary,根节点卸载组件树:

createRootErrorUpdate

function createRootErrorUpdate(
  fiber: Fiber,
  errorInfo: CapturedValue<mixed>,
  lane: Lane,
): Update<mixed> {
  const update = createUpdate(NoTimestamp, lane, null);
  update.tag = CaptureUpdate;
  // 将根节点置为null,即卸载整棵React组件树
  update.payload = {element: null};
  const error = errorInfo.value;
  update.callback = () => {
    // 打印错误信息
    onUncaughtError(error);
    logCapturedError(fiber, errorInfo);
  };
  return update;
}

3 & 4 render

上一步我们已经找到了 ErrorBounary / 根节点,接下来处理错误并重新渲染。

completeUnitOfWork

function completeUnitOfWork(unitOfWork: Fiber): void {
  let completedWork = unitOfWork;
  do {
    const current = completedWork.alternate;
    const returnFiber = completedWork.return;
    
    if ((completedWork.effectTag & Incomplete) === NoEffect) {
      ...
    } else {
      // 判断当前节点是否能处理错误
      const next = unwindWork(completedWork, subtreeRenderLanes);
      if (next !== null) {
        // render & 终止
        next.effectTag &= HostEffectMask;
        workInProgress = next;
        return;
      }
      if (returnFiber !== null) {
        returnFiber.firstEffect = returnFiber.lastEffect = null;
        returnFiber.effectTag |= Incomplete;
      }
    }
    completedWork = returnFiber;
    workInProgress = completedWork;
  } while (completedWork !== null);
}

unwindWork

根据 Fiber 节点的类型和 effectTag 是否包含上一步标记的 ShouldCapture 来判断该节点能否处理异常

function unwindWork(workInProgress: Fiber, renderLanes: Lanes) {
  switch (workInProgress.tag) {
    case ClassComponent: {
      const effectTag = workInProgress.effectTag;
      if (effectTag & ShouldCapture) {
        // errorbounary
        workInProgress.effectTag = (effectTag & ~ShouldCapture) | DidCapture;
        return workInProgress;
      }
      return null;
    }
    case HostRoot: {
      const effectTag = workInProgress.effectTag;
      workInProgress.effectTag = (effectTag & ~ShouldCapture) | DidCapture;
      return workInProgress;
    }
    default:
      return null;
  }
}

forceUnmountCurrentAndReconcile

通过 forceUnmountCurrentAndReconcile 先卸载子节点的 ReactElement 对象,再将 ErrorBounary 展示的子节点挂载,渲染降级 UI。

function forceUnmountCurrentAndReconcile(
  current: Fiber,
  workInProgress: Fiber,
  nextChildren: any,
  renderLanes: Lanes,
) {
  workInProgress.child = reconcileChildFibers(
    workInProgress,
    current.child,
    null,
    renderLanes,
  );
  workInProgress.child = reconcileChildFibers(
    workInProgress,
    null,
    nextChildren,
    renderLanes,
  );
}

结论

ErrorBounary 是 React 提供的组件,可以用来捕获子组件 UI 的 JavaScript 报错,展示降级 UI。ErrorBounary 无法捕获事件处理、异步代码、服务端渲染和组件本身的错误。在 React class 组件中,包含 getDerivedStateFromError 和 componentDidCatch 两者或其中之一,认为该组件是是 ErrorBounary。文章的最后一小节梳理了 React 实现 ErrorBounary 的机制,本质上是通过 try catch 来捕获组件的错误,向父组件遍历找到 ErrorBounary 或根组件。ErrorBounary 会展示降级 UI,根组件则直接卸载组件树,展示空白页面。

在线运行代码:codesandbox.io/s/react-sus…

参考

[1]  zh-hans.reactjs.org/docs/error-…

[​2]  imshubhamkhandal.medium.com/error-bound…

[3]  zh-hans.reactjs.org/docs/error-…

[4]  juejin.cn/post/719187…

[5]  zh-hans.reactjs.org/docs/react-…

[6]  zh-hans.reactjs.org/docs/react-…

[​7]  developer.mozilla.org/en-US/docs/…

[8]  segmentfault.com/a/119000004…