14
头图

系列文章目录(同步更新)

本系列文章均为讨论 React v17.0.0-alpha 的源码

错误边界(Error Boundaries)

在解释 React 内部实现前,我想先从一个 React API —— 错误边界(Error Boundaries) 这一 React 异常处理机制 的“冰山一角”开始介绍。

错误边界是什么

在 React 16 之前,React 并没有对开发者提供 API 来处理组件渲染过程中抛出的异常:

  • 这里的“组件渲染过程”,实际指的是 jsx 代码段;
  • 因为 命令式 的代码,可以使用 try/catch 来处理异常;
  • 但 React 的组件是“声明式”的,开发者无法在组件内直接使用 try/catch 来处理异常。

而 React 16 带来了 错误边界 这一全新的概念,向开发者提供一种能力来更精细地处理组件维度抛出的异常。

错误边界就像一堵 防火墙 (命名上也有点像),我们可以在整个组件树中“放置”若干这样的“防火墙”,那么一旦某个组件出现异常,该异常会被离它最近的错误边界给拦截住,避免影响组件树的其它分支;而我们也可以通过错误边界来渲染更“用户友好”的 UI 界面。

什么样的组件才能被称为错误边界

错误边界 也是一个组件(目前只支持 类组件 ),因此我们可以插入任意数量的错误边界到组件树中的任意位置。

错误边界包含两个 API :类组件静态方法 getDerivedStateFromError 和类组件成员方法 componentDidCatch(也可以理解成是生命周期方法),只要一个类组件(ClassComponent)包含这两者或其中之一,那么这个类组件就成为了错误边界。

贴一段 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) {
    // 你同样可以将错误日志上报给服务器
    logErrorToMyService(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // 你可以自定义降级后的 UI 并渲染
      return <h1>Something went wrong.</h1>;
    }

    return this.props.children; 
  }
}

错误边界能达到什么效果

早期版本的错误边界只有 componentDidCatch 一个 API ,后增加了 getDerivedStateFromError ,这两个 API 各司其职。

getDerivedStateFromError

getDerivedStateFromError 的主要功能是在捕获到异常后,返回当前组件的最新 state 。通常的做法就如上文的实例,设置一个 hasError 开关,通过开关来控制是展示“错误提示”还是正常的子组件树;这点还是比较重要的,因为此时子组件树中的某个子组件异常,必须将其从页面上排除出去,否则还是会影响整棵组件树的渲染。

static getDerivedStateFromError(error) {
  // 更新 state 使下一次渲染能够显示降级后的 UI
  return { hasError: true };
}

由于 getDerivedStateFromError 会在 render 阶段被调用,因此不应在此处做任何副作用操作;若有需要,应在 componentDidCatch 生命周期方法中执行相应操作。

componentDidCatch

componentDidCatch 会在 commit 阶段被调用,因此完全可以用来执行副作用操作,比如上报错误日志。

在早期还没有 getDerivedStateFromError 这个 API 的时候,需要在 componentDidCatch 这个生命周期里通过 setState 方法来更新 state ,但现在已经完全不推荐这么做了,因为通过 getDerivedStateFromError ,在 render 阶段就已经处理好了,何必等到 commit 阶段呢?这块内容在下文会详细介绍。

React 的 render 异常处理机制

之所以优先介绍“错误边界”,一方面是因为这是直接面向开发者的 API ,更好理解;另一方面则是 React 为了实现这样的能力,让 render 异常处理机制变得更复杂了,不然直接用 try/catch 捕获异常后统一处理掉就非常简单粗暴了。

异常是如何产生的

上文中提到,错误边界处理的是组件渲染过程中抛出的异常,其实这本质上也是 React 的 render 异常处理机制所决定的;而其它诸如事件回调方法、 setTimeout/setInterval 等异步方法,由于并不会影响 React 组件树的渲染,因此也就不是 render 异常处理机制的目标了。

什么样的异常会被 render 异常处理机制捕获

简单来说,类组件的 render 方法、函数组件这样的会在 render 阶段被同步执行的代码,一旦抛出异常就会被 render 的异常处理机制捕获(无论是否有错误边界)。举一个实际开发中很常遇到的场景:

function App() {
    return (
        <div>
            <ErrorComponent />
        </div>
    );
}

function ErrorComponent(props) {
    // 父组件并没有传option参数,此时就会抛出异常:
    // Uncaught TypeError: Cannot read properties of undefined (reading 'text')
    return <p>{props.option.text}</p>;
}

React.render(<App />, document.getElementById('app'));

在 React 的 render 过程中,上述两个函数组件先后会被执行,而当执行到props.foo.text时就会抛出异常,下面是 <ErrorComponent /> 的 jsx 代码经过转译后的,形成的可执行的 js 代码:

function ErrorComponent(props) {
  return /*#__PURE__*/Object(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_3__["jsxDEV"])("p", {
    children: props.foo.text // 抛出异常
  }, void 0, false, { // debug相关,可忽略
    fileName: _jsxFileName,
    lineNumber: 35,
    columnNumber: 10
  }, this);
}

组件本身抛异常的具体位置

以下内容需要你对 React 的 render 过程有一定的了解,请先阅读《React 源码解析系列 - React 的 render 阶段(二):beginWork》

beginWork 方法中,若判断当前 Fiber 节点无法 bailout (剪枝),那么就会创建/更新 Fiber 子节点:

switch (workInProgress.tag) {
  case IndeterminateComponent: 
    // ...省略
  case LazyComponent: 
    // ...省略
  case FunctionComponent: {
      const Component = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps;
      const resolvedProps =
        workInProgress.elementType === Component
          ? unresolvedProps
          : resolveDefaultProps(Component, unresolvedProps);
      return updateFunctionComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        renderLanes,
      );
    }
  case ClassComponent: {
    const Component = workInProgress.type;
    const unresolvedProps = workInProgress.pendingProps;
    const resolvedProps =
      workInProgress.elementType === Component
        ? unresolvedProps
        : resolveDefaultProps(Component, unresolvedProps);
    return updateClassComponent(
      current,
      workInProgress,
      Component,
      resolvedProps,
      renderLanes,
    );
  }
  case HostRoot:
    // ...省略
  case HostComponent:
    // ...省略
  case HostText:
    // ...省略
  // ...省略其他类型
}
ClassComponent 抛异常的位置

从上面 beginWork 这代码段可以看到执行了 updateClassComponent 方法,并且传入了名为 Component 的参数,此参数实际上就是类组件的 class ,此时由于尚未执行 render 方法,因此仍未抛出异常。

循着 updateClassComponent,我们可以看到执行了 finishClassComponent 来创建 Fiber 子节点:

function updateClassComponent(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: any,
  nextProps: any,
  renderLanes: Lanes,
) {
    // 省略
    const nextUnitOfWork = finishClassComponent(
      current,
      workInProgress,
      Component,
      shouldUpdate,
      hasContext,
      renderLanes,
    );
    // 省略
}

finishClassComponent 中,我们可以看到 nextChildren = instance.render() ,这里的 instance 就是实例化后的类对象,而调用 render 成员方法后,便得到了 nextChildren 这一 ReactElement 对象。

在后续过程中,React 会根据这一 ReactElement 对象来创建/更新 Fiber 子节点,但这不是本文所关心的;我们关心的是,这里执行了 render 成员方法,也就有可能抛出 React 异常处理机制所针对的异常。

FunctionComponent 抛异常的位置

接下来我们来定位与 FunctionComponent 抛异常的位置:有了 ClassComponent 的经验,我们一路循着 updateFunctionComponentrenderWithHooks ,在该方法中,我们可以看到 let children = Component(props, secondArg); ,这里的 Component 就是函数组件的 function 本身,而 children 则是执行函数组件后得到的 ReactElement 对象。

如何捕获 render 异常

当我们被问到“如何捕获异常”,本能就会回答“用 try/catch 呀”,那 React 是如何捕获这组件渲染过程中抛出的异常的呢?

  • 在生产环境下, React 使用 try/catch 来捕获异常
  • 在开发环境下, React 没有使用 try/catch ,而是实现了一套更为精细的捕获机制

为什么不能直接使用 try/catch 呢

React 原先就是直接使用 try/catch 来捕获 render 异常的,结果收到了大量的 issue ,详情是这样的:

  • Chrome devtools 有个名为 Pause on exceptions 的功能,该功能可以快速定位到抛出异常的代码位置,效果就相当于在该行代码上打了断点一样;但只有未被捕获的异常能够使用这种方法来定位
  • 开发者们投诉无法通过 Chrome devtools 定位到 React 组件渲染过程中抛出异常的代码
  • 有人发现只要打开 Pause On Caught Exceptions 便能定位到抛出异常的代码位置;这个功能开启后,即便异常被捕获也可以定位到目标位置,由此判断 React 把异常给“吞”了

为了解决这个问题,React 需要提供一套满足以下条件的异常捕获方案:

  • 依然需要捕获异常,捕获后交给错误边界来处理
  • 不使用 try/catch ,避免影响 Chrome devtools 的 Pause on exceptions 功能

如何不使用 try/catch 来捕获 render 异常

当 JavaScript 运行时错误(包括语法错误)发生时,window 会触发一个 ErrorEvent 接口的 error 事件,并执行 window.onerror() 。

上述这段描述出自 MDN GlobalEventHandlers.onerror 的文档,这便是除 try/catch 外的捕获异常的方法:我们可以给 window 对象的 error 事件挂上回调处理方法,那么只要页面上任意 javascript 代码抛出异常,我们都可以捕获到。

但这样做的话,岂不是也捕获到许多与 React 组件渲染过程无关的异常?其实,我们只需要在执行 React 组件渲染前监听 error 事件,而在组件结束渲染后取消监听该事件即可:

function mockTryCatch(renderFunc, handleError) {
    window.addEventListener('error', handleError); // handleError可以理解成是启用“错误边界”的入口方法
    renderFunc();
    window.removeEventListener('error', handleError); // 清除副作用
}

那是不是这样就大功告成了呢?且慢!这套方案的原意是要在开发环境取代原先使用的 try/catch 代码,但现在却有一个 try/catch 的重要特性没有还原:那就是 try/catch 在捕获异常后,会继续执行 try/catch 以外的代码:

try {
    throw '异常';
} catch () {
    console.log('捕获到异常!')
}
console.log('继续正常运行');

// 上述代码运行的结果是:
// 捕获到异常!
// 继续正常运行

而使用上述的 mockTryCatch 来试图替代 try/catch 的话:

mockTryCatch(() => {
    throw '异常';
}, () => {
    console.log('捕获到异常!')
});
console.log('继续正常运行');

// 上述代码运行的结果是:
// 捕获到异常!

显而易见, mockTryCatch 并不能完全替代 try/catch ,因为 mockTryCatch 在抛出异常后,后续同步代码的执行就会被强制终止。

如何像 try/catch 一样不影响后续代码执行

前端领域总是有各种各样的骚套路,还真让 React 开发者找到这样的方法: EventTarget.dispatchEvent ;那么,为什么说 dispatchEvent 就能模拟并替代 try/catch 呢?

dispatchEvent 能够同步执行代码
与浏览器原生事件不同,原生事件是由 DOM 派发的,并通过 event loop 异步调用事件处理程序,而 dispatchEvent() 则是同步调用事件处理程序。在调用 dispatchEvent() 后,所有监听该事件的事件处理程序将在代码继续前执行并返回。

上文出自 dispatchEvent 的 MDN 文档,由此可见: dispatchEvent 能够同步执行代码 ,这意味着在事件处理方法执行完成前,可以阻塞 dispatchEvent 后续的代码执行,同时这也是 try/catch 的特征之一。

dispatchEvent 抛的异常不冒泡
这些 event handlers 运行在一个嵌套的调用栈中:他们会阻塞调用直到他们处理完毕,但是异常不会冒泡。

准确来说,是:通过 dispatchEvent 触发的事件回调方法,异常不会冒泡;这意味着,即便抛出异常,也只是会终止事件回调方法本身的执行,而 dispatchEvent() 上下文的代码并不会收到影响。下面写个 DEMO 验证下这个特性:

function cb() {
  console.log('开始执行回调');
  throw 'dispatchEvent的事件处理函数抛异常了';
  console.log('走不到这里的');
}

/* 准备一个虚拟事件 */
const eventType = 'this-is-a-custom-event';
const fakeEvent = document.createEvent('Event');
fakeEvent.initEvent(eventType, false, false);

/* 准备一个虚拟DOM节点 */
const fakeNode = document.createElement('fake');
fakeNode.addEventListener(eventType, cb, false); // 挂载

console.log('dispatchEvent执行前');
fakeNode.dispatchEvent(fakeEvent);
console.log('dispatchEvent执行后');

// 上述代码运行的结果是:
// dispatchEvent执行前
// 开始执行回调
// Uncaught dispatchEvent的事件处理函数抛异常了
// dispatchEvent执行后

从上述 DEMO 可以看出,尽管 dispatchEvent 的事件处理函数抛了异常,但依然还是能够继续执行 dispatchEvent 后续的代码(即 DEMO 中的 console.log())。

实现一个简易版的 render 异常捕获器

接下来,让我们把 GlobalEventHandlers.onerrorEventTarget.dispatchEvent 结合起来,就能够实现一个简易版的 render 异常捕获器:

function exceptionCatcher(func) {
    /* 准备一个虚拟事件 */
    const eventType = 'this-is-a-custom-event';
    const fakeEvent = document.createEvent('Event');
    fakeEvent.initEvent(eventType, false, false);

    /* 准备一个虚拟DOM节点 */
    const fakeNode = document.createElement('fake');
    fakeNode.addEventListener(eventType, excuteFunc, false); // 挂载

    window.addEventListener('error', handleError);
    fakeNode.dispatchEvent(fakeEvent); // 触发执行目标方法
    window.addEventListener('error', handleError); // 清除副作用
    
    function excuteFunc() {
        func();
        fakeNode.removeEventListener(evtType, excuteFunc, false); 
    }
    
    function handleError() {
        // 将异常交给错误边界来处理
    }
}

React 源码中具体是如何捕获 render 异常的

上文介绍完捕获 render 异常的原理,也实现了个简易版 DEMO ,下面就可以来具体分析 React 源码了。

捕获目标:beginWork

上文提到, React 组件渲染维度的异常是在 beginWork 阶段抛出,因此我们捕获异常的目标显然就是 beginWork 了。

对 beginWork 进行包装

React 针对开发环境对 beginWork 方法进行了一个封装,添上了 捕获异常 的功能:

  1. 在执行 bginWork 前,先“备份”一下当前的 Fiber 节点(unitOfWork)的属性,复制到一个专门用于“备份”的 Fiber 节点上。
  2. 执行 beginWork 并使用 try/catch 捕获异常;看到这你也许会很疑惑,不是说不用 try/catch 来捕获异常吗,这怎么又用上了?还是继续往下看吧。
  3. 若 beginWork 抛出了异常,自然就会被捕获到,然后执行 catch 的代码段:

    1. 从备份中恢复当前 Fiber 节点(unitOfWork)到执行 beginWork 前的状态。
    2. 在当前 Fiber 节点上调用 invokeGuardedCallback 方法来重新执行一遍 beginWork ,这个 invokeGuardedCallback 方法会应用我们上文中提到的 GlobalEventHandlers.onerrorEventTarget.dispatchEvent 联合方法来捕获异常。
    3. 重新抛出捕获到的异常,后续可以针对异常进行处理;这里虽然抛出异常,并且这个异常会被外层的 try/catch 给捕获,但这不会影响 Pause on exceptions 功能,因为 invokeGuardedCallback 方法内产生的异常,并没有被外层的 try/catch 捕获。

beginWork的封装

let beginWork;
if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) {
    // 开发环境会走到这个分支
    beginWork = (current, unitOfWork, lanes) => {
        /*
            把当前Fiber节点(unitOfWork)的所有属性,拷贝到一个额外的Fiber节点(dummyFiber)中
            这个dummyFiber节点仅仅作为备份使用,并且永远不会被插入到Fiber树中
         */
        const originalWorkInProgressCopy = assignFiberPropertiesInDEV(
          dummyFiber,
          unitOfWork,
        );
        try {
          return originalBeginWork(current, unitOfWork, lanes); // 执行真正的beginWork方法
        } catch (originalError) {
            // ...省略
            
            // 从备份中恢复当前Fiber节点(unitOfWork)到执行beginWork前的状态
            assignFiberPropertiesInDEV(unitOfWork, originalWorkInProgressCopy);
            
            // ...省略
            
            // 重新在当前Fiber节点上执行一遍beginWork,这里是本文介绍捕获异常的重点
            invokeGuardedCallback(
              null,
              originalBeginWork,
              null,
              current,
              unitOfWork,
              lanes,
            );

            // 重新抛出捕获到的异常,后续可以针对异常进行处理,下文会介绍
        }
    };
} else {
    // 生产环境会走到这个分支
    beginWork = originalBeginWork;
}
invokeGuardedCallback

接下来看 invokeGuardedCallback 方法,这个方法其实并非核心,它跟它所在的 ReactErrorUtils.js 文件内的其它方法,形成了一个“存/取”异常的工具,我们关注的核心在 invokeGuardedCallbackImpl 方法。

let hasError: boolean = false;
let caughtError: mixed = null;

const reporter = {
  onError(error: mixed) {
    hasError = true;
    caughtError = error;
  },
};

export function invokeGuardedCallback<A, B, C, D, E, F, Context>(
  name: string | null,
  func: (a: A, b: B, c: C, d: D, e: E, f: F) => mixed,
  context: Context,
  a: A,
  b: B,
  c: C,
  d: D,
  e: E,
  f: F,
): void {
  hasError = false;
  caughtError = null;
  invokeGuardedCallbackImpl.apply(reporter, arguments);
}
invokeGuardedCallbackImpl

这个 invokeGuardedCallbackImpl 也分生产环境和开发环境的实现,我们只看开发环境的实现即可:

invokeGuardedCallbackImpl = function invokeGuardedCallbackDev<
  A,
  B,
  C,
  D,
  E,
  F,
  Context,
>(
  name: string | null,
  func: (a: A, b: B, c: C, d: D, e: E, f: F) => mixed,
  context: Context,
  a: A,
  b: B,
  c: C,
  d: D,
  e: E,
  f: F,
) {
  // 省略...
  
  const evt = document.createEvent('Event'); // 创建自定义事件

  // 省略...

  const windowEvent = window.event;

  // 省略...

  function restoreAfterDispatch() {
    fakeNode.removeEventListener(evtType, callCallback, false); // 取消自定义事件的监听,清除副作用
    // 省略...
  }

  const funcArgs = Array.prototype.slice.call(arguments, 3); // 取出需要传给beginWork的参数
  function callCallback() {
    // 省略...
    restoreAfterDispatch();
    func.apply(context, funcArgs); // 执行beginWork
    // 省略...
  }

  function handleWindowError(event) {
    error = event.error; // 捕获到异常
    // 省略...
  }

  // 自定义事件名称
  const evtType = `react-${name ? name : 'invokeguardedcallback'}`;

  window.addEventListener('error', handleWindowError);
  fakeNode.addEventListener(evtType, callCallback, false);

  evt.initEvent(evtType, false, false); // 初始化一个自定义事件
  fakeNode.dispatchEvent(evt); // 触发自定义事件,也可以认为是触发同步执行beginWork

  // 省略...
  this.onError(error); // 将捕获到的异常交给外层处理

  window.removeEventListener('error', handleWindowError); // 取消error事件的监听,清除副作用
};

以上是我精简后的 invokeGuardedCallbackImpl 方法,是不是跟我们上述实现的简易版 React 异常捕获器相差无几呢?当然,该方法内其实还包括了很多异常情况的处理,这些异常情况都是由 issues 提出,然后以“打补丁”的方式来处理的,例如在测试环境中缺失 document ,又或是碰到跨域异常(cross-origin error)等,这里就不一一细说了。

处理异常

上文介绍了异常是怎么产生的,也介绍了异常是怎么被捕获的,下面就来简单介绍一下异常被捕获到后是怎么处理的:

  1. 从抛异常的 Fiber 节点开始,往根节点方向遍历,寻找能处理本次异常的错误边界;如果找不到,就只能交给根节点来处理异常。
  2. 如果由错误边界来处理异常,则创建一个 payloadgetDerivedStateFromError 方法执行后返回的 state 值、 callbackcomponentDidCatch 的更新任务;如果是由根节点来处理异常,则创建一个卸载整个组件树的更新任务。
  3. 进入处理异常的节点的 render 过程中(也即 performUnitOfWork ),在该过程中会执行刚刚创建的更新任务。
  4. 最终,如果由错误边界来处理异常,那么根据错误边界 state 的变化,会卸载掉带有异常 Fiber 节点的子组件树,改为渲染含有友好异常提示的 UI 界面;而如果由根节点来处理异常,则会卸载掉整个组件树,导致白屏。

React 处理 render 异常的简单流程图

React 中处理异常的源码实现

上文说到在(开发环境)封装的 beginWork 里,会把 invokeGuardedCallback 捕获到的异常重新抛出,那这个异常会在哪里被截住呢?答案是 renderRootSync

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

下面来介绍 handleError

  • handleError 又是一个 React 惯用的 do...while(true) 的死循环结构,那么满足什么条件才能退出循环呢?
  • 在循环体内,有一个 try/catch 代码段,一旦 try 中的代码段抛异常被 catch 拦截住,那么就会回退到当前节点的父节点(React 的老套路了)继续尝试;如果某次执行中未抛异常,就能结束该循环,也即结束整个 handleError 方法。
  • try 代码段中,主要执行了 3 段逻辑:

    1. 判断当前节点或当前节点的父节点是否为 null ,如果是的话,则表明当前可能处在 Fiber 根节点,不可能有错误边界能够处理异常,直接作为致命异常来处理,结束当前方法。
    2. 执行 throwException 方法,遍历寻找一个能处理当前异常的节点(错误边界),下文将详细介绍。
    3. 执行 completeUnitOfWork ,这是 render 过程中最重要的方法之一,但这里主要是执行其中关于异常处理的代码分支,下文将详细介绍。

handleError 流程图

function handleError(root, thrownValue): void {
  do {
    let erroredWork = workInProgress;
    try {
      // 重置render过程中修改过的一些状态,省略...

      if (erroredWork === null || erroredWork.return === null) {
        // 若走到这个分支,则表明当前可能处在Fiber根节点,不可能有错误边界能够处理异常,直接作为致命异常来处理
        workInProgressRootExitStatus = RootFatalErrored;
        workInProgressRootFatalError = thrownValue;
        workInProgress = null;
        return;
      }

      // 省略...
      
      // throwException是关键所在,下文介绍
      throwException(
        root,
        erroredWork.return,
        erroredWork,
        thrownValue, // 异常本身
        workInProgressRootRenderLanes,
      );
      completeUnitOfWork(erroredWork); // 处理异常的Fiber节点(erroredWork)
    } catch (yetAnotherThrownValue) {
      // 如果上述代码段依然无法处理当前异常Fiber节点(erroredWork) —— 还是抛了异常,那么就尝试用异常节点(erroredWork)的Fiber父节点来处理
      // 这是一个循环过程,一直向上遍历父级节点,直到找到可以处理异常的Fiber节点,又或是到达Fiber根节点(确定无错误边界能够处理当前异常)
      thrownValue = yetAnotherThrownValue;
      if (workInProgress === erroredWork && erroredWork !== null) {
        erroredWork = erroredWork.return;
        workInProgress = erroredWork;
      } else {
        erroredWork = workInProgress;
      }
      continue;
    }
    return;
  } while (true);
}
throwException

下面来介绍 throwExceptionthrowException 主要做了以下事情:

  • 给当前抛异常的 Fiber 节点打上 Incomplete 这个 EffectTag ,后续会根据这个 Incomplete 标识走到异常处理的代码分支里。
  • 从当前抛异常的 Fiber 节点的父节点开始,往根节点方向遍历,找一个可以处理异常的节点;目前只有错误边界和 Fiber 根节点可以处理异常;根据遍历的方向,如果这个遍历路径中有错误边界的话,肯定会先找到错误边界,也就是优先让错误边界来处理异常。

    • 判断错误边界的标准”在这里就可以体现:必须是一个 ClassComponent ,且包含 getDerivedStateFromErrorcomponentDidCatch 两者或其中之一。
  • 找到可以处理异常的节点后,也会根据不同的类型来执行不同的代码分支,不过大概思路是一样的:

    1. 给该节点打上 ShouldCapture 的 EffectTag ,后续会根据这个EffectTag走到异常处理的代码分支。
    2. 针对当前异常新建一个更新任务,并给该更新任务找一个优先级最高的 lane ,保证在本次 render 时必定会执行;其中,错误边界会调用 createRootErrorUpdate 方法来创建更新任务,而根节点则是调用 createRootErrorUpdate 方法来创建更新任务,这两个方法下文都会详细介绍的。
function throwException(
  root: FiberRoot,
  returnFiber: Fiber,
  sourceFiber: Fiber,
  value: mixed, // 异常本身
  rootRenderLanes: Lanes,
) {
  // 给当前异常的Fiber节点打上Incomplete这个EffectTag,后续就根据这个Incomplete标识走到异常处理的代码分支里
  sourceFiber.effectTag |= Incomplete;
  sourceFiber.firstEffect = sourceFiber.lastEffect = null;

  // 一大段针对Suspense场景的处理,省略...

  renderDidError(); // 将workInProgressRootExitStatus置为RootErrored

  value = createCapturedValue(value, sourceFiber); // 获取从Fiber根节点到异常节点的完整节点路径,挂载到异常上,方便后续打印
  /*
    尝试往异常节点的父节点方向遍历,找一个可以处理异常的错误边界,如果找不到的话那就只能交给根节点来处理了
    注意,这里并不是从异常节点开始找的,因此即便异常节点自己是错误边界,也不能处理当前异常
   */
  let workInProgress = returnFiber;
  do {
    switch (workInProgress.tag) {
      case HostRoot: {
        // 进到这个代码分支意味着没能找到一个能够处理本次异常的错误边界,只能让Fiber根节点来处理异常
        // 给该节点打上ShouldCapture的EffectTag,后续会根据这个EffectTag走到异常处理的代码分支
        const errorInfo = value;
        workInProgress.effectTag |= ShouldCapture;
        // 针对当前异常新建一个更新任务,并给该更新任务找一个优先级最高的lane,保证在本次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)))
        ) {
          // 确定该节点是错误边界
          // 给该节点打上ShouldCapture的EffectTag,后续会根据这个EffectTag走到异常处理的代码分支
          workInProgress.effectTag |= ShouldCapture; 
          // 针对当前异常新建一个更新任务,并给该更新任务找一个优先级最高的lane,保证在本次render时必定会执行
          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);
}
createRootErrorUpdatecreateClassErrorUpdate

当遇到无错误边界能处理的致命异常时,会调用 createRootErrorUpdate 方法来创建一个状态更新任务,该任务会将根节点置为 null ,即卸载整棵 React 组件树。 React 官方认为,与其渲染一个异常的界面误导用户,还不如直接显示白屏;我无法否定官方的这种思想,但更肯定了错误边界的重要性。

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

当发现有错误边界可以处理当前异常时,会调用 createClassErrorUpdate 方法来创建一个状态更新任务,该更新任务的 payloadgetDerivedStateFromError 执行后返回的结果,而在更新任务的 callback 中,则执行了 componentDidCatch 方法(通常用来执行一些带有副作用的操作)。

function createClassErrorUpdate(
  fiber: Fiber,
  errorInfo: CapturedValue<mixed>,
  lane: Lane,
): Update<mixed> {
  const update = createUpdate(NoTimestamp, lane, null);
  update.tag = CaptureUpdate;
  // 注意这里的getDerivedStateFromError是取类组件本身的静态方法
  const getDerivedStateFromError = fiber.type.getDerivedStateFromError;
  if (typeof getDerivedStateFromError === 'function') {
    const error = errorInfo.value;
    // 在新创建的状态更新任务中,将state设置为getDerivedStateFromError方法执行后返回的结果
    update.payload = () => {
      logCapturedError(fiber, errorInfo);
      return getDerivedStateFromError(error);
    };
  }

  const inst = fiber.stateNode;
  if (inst !== null && typeof inst.componentDidCatch === 'function') {
    // 设置更新任务的callback
    update.callback = function callback() {
      // 省略...
      if (typeof getDerivedStateFromError !== 'function') {
        // 兼容早期的错误边界版本,当时并没有getDerivedStateFromError这个API
        // 省略...
      }
      const error = errorInfo.value;
      const stack = errorInfo.stack;
      // 执行类组件的componentDidCatch成员方法,通常用来执行一些带有副作用的操作
      this.componentDidCatch(error, {
        componentStack: stack !== null ? stack : '',
      });
      // 省略...
    };
  }
  // 省略...
  return update;
}
completeUnitOfWork

上面讲完了 throwException ,下面继续看 handleError 方法中的最后一个步骤 —— completeUnitOfWork ,该方法会对异常的 Fiber 节点进行处理,在异常场景中该方法的唯一参数是 抛出异常的 Fiber 节点

function handleError(root, thrownValue): void {
  do {
    let erroredWork = workInProgress;
    try {
      // 省略...
      
      // throwException是关键所在,下文介绍
      throwException(
        root,
        erroredWork.return,
        erroredWork,
        thrownValue, // 异常本身
        workInProgressRootRenderLanes,
      );
      completeUnitOfWork(erroredWork); // 抛出异常的Fiber节点(erroredWork)
    } catch (yetAnotherThrownValue) {
        // 省略...
    }
    return;
  } while (true);
}

在之前的文章中,我们已经介绍过 completeUnitOfWork 方法了,但介绍的是正常的流程,直接把异常处理的流程给忽略了,下面我们来补上这一块:

  • completeUnitOfWork 跟上文介绍的 throwException 有点像,是从当前 Fiber 节点(在异常场景指的是抛异常的节点)往根节点方向遍历,找一个可以处理异常的节点;由于 completeUnitOfWork 同时包含了正常流程和异常处理流程,因此是通过 Incomplete 这个 EffectTag 来进入到异常处理的代码分支里的。
  • 一旦发现可以处理异常的 Fiber 节点,则将其设置为下一轮 work(performUnitOfWork)循环主体(workInProgres),然后立即终止本 completeUnitOfWork 方法;后续就会回到 performUnitOfWork 并进入到该(可以处理异常的) Fiber 节点的 beginWork 阶段。
  • 在遍历过程中,如果发现当前节点无法处理异常,那么就会给当前节点的父节点也打上 Incomplete ,保证父节点也会进入到异常处理的代码分支。
  • completeUnitOfWork 中针对 sibling 节点的逻辑并没有区分是否为正常流程,这点我有点意外:因为如果当前节点有异常,那么它的 sibling 节点即便是正常的,在后续的异常处理过程中也会被重新 render ,此时又何必去 render 它的 sibling 节点呢;但反过来想,这样做也不会产生问题,因为 sibling 节点在 completeUnitOfWork 回退到父节点时,由于父节点已经被设置为 Incomplete 了,所以也依然会走异常处理的流程。

completeUnitOfWork 的异常处理流程

这里还有个问题:为什么要重新 render 可以处理异常的节点 呢?我们不看后续的操作其实就能猜到 React 的做法:假设这个 可以处理异常的节点 是一个错误边界,在上文介绍的 throwException 中已经根据 getDerivedStateFromError 执行后返回的 state 值来创建了一个更新任务,那么后续只需要更新错误边界的 state ,根据 state 卸载掉抛异常的组件并渲染错误提示的组件,那这不就是一个很正常的 render 流程了吗。

function completeUnitOfWork(unitOfWork: Fiber): void {
  let completedWork = unitOfWork; // 这里的unitOfWork指的是抛出异常的Fiber节点
  do {
    const current = completedWork.alternate;
    const returnFiber = completedWork.return;

    // 判断当前Fiber节点是否被打上Incomplete这个EffectTag
    if ((completedWork.effectTag & Incomplete) === NoEffect) {
    // 正常的Fiber节点的处理流程,省略...
    } else {
      // 当前Fiber节点是否被打上Incomplete这个EffectTag,即当前Fiber节点因为异常,未能完成render过程,尝试走进处理异常的流程

      // 判断当前Fiber节点(completeWork)能否处理异常,如果可以的话就赋给next变量
      const next = unwindWork(completedWork, subtreeRenderLanes);

      // Because this fiber did not complete, don't reset its expiration time.

      if (next !== null) {
        // 发现当前Fiber节点能够处理异常,将其设置为下一轮work(performUnitOfWork)的循环主体(workInProgres),
        // 然后立即终止当前的completeWork阶段,后续将进入到当前Fiber节点的beginWork阶段(render的“递”阶段)
        next.effectTag &= HostEffectMask;
        workInProgress = next;
        return;
      }

      // 省略...

      // 走到这个分支意味着当前Fiber节点(completeWork)并不能处理异常,
      // 因此把Fiber父节点也打上Incomplete的EffectTag,后续将继续尝试走进处理异常的流程
      if (returnFiber !== null) {
        // Mark the parent fiber as incomplete and clear its effect list.
        returnFiber.firstEffect = returnFiber.lastEffect = null;
        returnFiber.effectTag |= Incomplete;
      }
    }

    // 处理当前Fiber节点的sibling节点,可以正常进入sibling节点的beginWork阶段
    // 后续会继续通过sibling节点的completeUnitOfWork回退到父节点来判断是否能够处理异常
    
    // 在当前循环中回退到父节点,继续尝试走进处理异常的流程
    completedWork = returnFiber;
    workInProgress = completedWork;
  } while (completedWork !== null);
  // 省略...
}
unwindWork

这里介绍一下 unwindWork 方法是怎么判断当前 Fiber 节点(completeWork)能否处理异常的:

  • 根据 completeWork.tag 即 Fiber 节点类型来判断,仅有 ClassComponent / HostRoot / SuspenseComponent / DehydratedSuspenseComponent 这 4 类 Fiber 节点类型能够处理异常
  • 根据 completeWork.effectTag 中是否包含 ShouldCapture 来判断,这个 EffectTag 是在上文介绍的 throwException 方法打上的。

unwindWork 方法中,一旦判断当前 Fiber 节点能够处理异常,那么则清除其 ShouldCapture ,并添上 DidCapture 的 EffectTag ,该 EffectTag 也会成为后续异常处理的判断标准。

function unwindWork(workInProgress: Fiber, renderLanes: Lanes) {
  switch (workInProgress.tag) {
    case ClassComponent: {
      // 省略...
      const effectTag = workInProgress.effectTag;
      // 判断是否包含ShouldCapture这个EffectTag
      if (effectTag & ShouldCapture) {
        // 确定当前Fiber节点能够处理异常,即确定为错误边界
        // 清除当前Fiber节点的ShouldCapture,并添上DidCapture的EffectTag 
        workInProgress.effectTag = (effectTag & ~ShouldCapture) | DidCapture;
        // 省略...
        return workInProgress;
      }
      return null;
    }
    case HostRoot: {
      // 进到当前代码分支,意味着在当前Fiber树中没有能够处理本次异常的错误边界
      // 因此交由Fiber根节点来统一处理异常
      // 省略...
      const effectTag = workInProgress.effectTag;
      // 省略...
      // 清除Fiber根节点的ShouldCapture,并添上DidCapture的EffectTag 
      workInProgress.effectTag = (effectTag & ~ShouldCapture) | DidCapture;
      return workInProgress;
    }
    // 省略...
    default:
      return null;
  }
}
重新 render 错误边界 Fiber 节点

在 completeUnitOfWork 方法中,我们通过 do...while 循环配合 unwindWork 方法,寻找在 throwException 方法中已经标记过可以处理当前异常的 错误边界 节点;下面假设的确有这样的一个 错误边界 节点,那么 completeUnitOfWork 方法会被结束,然后就进入到该节点的第二次 render :

workLoopSync --> performUnitOfWork --> beginWork --> updateClassComponent -> updateClassInstance / finishClassComponent

上面这都是正常 render 一个 ClassComponent 的过程,首先我们需要关注到 updateClassInstance ,在这个方法中,会针对当前节点的更新任务,来更新节点的 state ;还记得在 createClassErrorUpdate 中根据类组件静态方法 getDerivedStateFromError 返回的 state 值来创建的一个更新任务吗,该更新任务还被赋予了最高优先级:pickArbitraryLane(rootRenderLanes) ,因此在 updateClassInstance 就会根据这个更新任务来更新 state (也就是 getDerivedStateFromError 返回的 state 值)。

然后,我们进入到 finishClassComponent 方法的逻辑里,本方法针对异常处理其实就做了两个事情:

  1. 兼容老版错误边界的API

    • 判断是否为老版错误边界的依据是:当前节点的 ClassComponent 是否存在 getDerivedStateFromError 这个类静态方法;在老版错误边界中,没有 getDerivedStateFromError 这个 API ,统一是在 componentDidCatch 中发起 setState() 来修改 state 的,
    • 兼容的方法是:在本次 render 过程中,把 nextChildren 设置为 null,即卸载掉所有的子节点,这样的话就能避免本次 render 抛异常;而在 commit 阶段,会执行更新任务的 callback ,即 componentDidCatch ,到时候可以发起新一轮 render 。
  2. 强制重新创建子节点,这块其实与正常逻辑调用 reconcileChildren 差别不大,但做了一些小手段来禁止复用 current 树上的子节点,下文会详细介绍。
function finishClassComponent(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: any,
  shouldUpdate: boolean,
  hasContext: boolean,
  renderLanes: Lanes,
) {
  // 省略...
  // 判断是否有DidCapture这个EffectTag,若带有该EffectTag,则表示当前Fiber节点为处理异常的错误边界
  const didCaptureError = (workInProgress.effectTag & DidCapture) !== NoEffect;

  // 正常的render流程代码分支,省略...

  const instance = workInProgress.stateNode;
  ReactCurrentOwner.current = workInProgress;
  let nextChildren;
  if (
    didCaptureError &&
    typeof Component.getDerivedStateFromError !== 'function'
  ) {
    // 若当前为处理异常的错误边界,但又没有定义getDerivedStateFromError这方法,则进入到本代码分支
    // 这个代码分支主要是为了兼容老版错误边界的API,在老版错误边界中,是在componentDidCatch发起setState()来修改state的
    // 兼容的方法是,在本次render过程中,把nextChildren设置为null,即卸载掉所有的子节点,这样的话就能避免本次render抛异常
    nextChildren = null;
    // 省略...
  } else {
    // 正常的render流程代码分支,省略...
  }

  // 省略...
  
  if (current !== null && didCaptureError) {
    // 强制重新创建子节点,禁止复用current树上的子节点;
    forceUnmountCurrentAndReconcile(
      current,
      workInProgress,
      nextChildren,
      renderLanes,
    );
  } else {
    // 正常的render流程代码分支
    reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  }

  // 省略...

  return workInProgress.child;
}
如何强制重新渲染子节点

在介绍 finishClassComponent 时我们提到可以用 forceUnmountCurrentAndReconcile 方法,与正常的 render 逻辑类似,该方法中也会调用 reconcileChildFibers ,但却非常巧妙地调用了两次:

  1. 第一次调用 reconcileChildFibers 时,会把原本应该传“子节点 ReactElement 对象”的参数改为传 null,相当于卸载掉所有子节点 ;这样的话就会给 current 树上的所有子节点都标记上“删除”的 EffectTag 。
  2. 第二次调用 reconcileChildFibers 时,会把原本应该传“ current 树上对应子节点”的参数改为传 null ;这样的话就能保证本次 render 后,当前节点(错误边界)的所有子节点都是新创建的,不会复用 current 树节点

至于为什么要这么做呢, React 官方的解释是“从概念上来说,处理异常时与正常渲染时是不同的两套 UI ,不应该复用任何子节点(即使该节点的特征 —— key/props 等是一致的)”;简单来理解的话,就是“一刀切”避免复用到异常的 Fiber 节点吧。

function forceUnmountCurrentAndReconcile(
  current: Fiber,
  workInProgress: Fiber,
  nextChildren: any,
  renderLanes: Lanes,
) {
  // 只有在render处理异常的错误边界时,才会进入到当前方法;当然正常逻辑下也是会执行reconcileChildFibers
  // 在处理异常时,应该拒绝复用current树上对应的current子节点,避免复用到异常的子节点;为此,会调用两次reconcileChildFibers
  
  // 第一次调用reconcileChildFibers,会把原本应该传子节点ReactElement对象的参数改为传null
  // 这样的话就会给current树上的所有子节点都标记上“删除”的EffectTag
  workInProgress.child = reconcileChildFibers(
    workInProgress,
    current.child,
    null,
    renderLanes,
  );

  // 第二次调用reconcileChildFibers,会把原本应该传current树上对应子节点的参数改为传null
  // 这样就能保证本次render后的所有子节点都是新创建的,不会复用
  workInProgress.child = reconcileChildFibers(
    workInProgress,
    null,
    nextChildren,
    renderLanes,
  );
}

写在最后

以上便是对 React render 异常处理机制的介绍,通过本文,补全了前面几篇介绍 render 的文章的疏漏(前文仅介绍了 render 的正常流程),让我们在开发过程中做到对异常处理“心里有数”,快给你的应用加几个错误边界吧(笑)。


array_huang
10.5k 声望6.6k 粉丝