14
头图

Series article directory (synchronized update)

This series of articles discusses the source code of React v17.0.0-alpha

Error Boundaries

Before explaining the internal implementation of React, I want to start with a React API - Error Boundaries This React exception handling mechanism "The tip of the iceberg" starts.

what is the error boundary

Before React 16, React did not provide developers with an API to handle exceptions thrown during component rendering:

  • The "component rendering process" here actually refers to the jsx code segment;
  • Because of the code of imperative , you can use try/catch to handle exceptions;
  • But React's components are " declarative ", and developers cannot directly use try/catch to handle exceptions in components.

React 16 brings a brand new concept of error boundary , which provides developers with the ability to handle exceptions thrown by the component dimension in a finer manner.

The error boundary is like a firewall (the name is also a bit similar), we can "place" several such "firewalls" in the entire component tree, then once a component has an exception, the exception will be The closest to it will be The error boundary is intercepted to avoid affecting other branches of the component tree; and we can also use the error boundary to render a more "user-friendly" UI interface.

What kind of components can be called error boundaries

error boundary is also a component (currently only the class component is supported), so we can insert any number of error boundaries anywhere in the component tree.

The error boundary contains two APIs: class component static method getDerivedStateFromError and class component member method componentDidCatch (which can also be understood as life cycle methods), as long as a class component (ClassComponent) contains either or both, then this class component is became the wrong boundary.

An example of pasting an official React documentation:

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

What the error boundary can achieve

The error boundary of the earlier version was only componentDidCatch , an API, and getDerivedStateFromError was added later. These two APIs perform their respective functions.

getDerivedStateFromError

The main function of getDerivedStateFromError is to return the latest state of the current component after catching an exception. The usual practice is like the example above, setting a hasError switch to control whether to display the "error prompt" or the normal sub-component tree; this is still more important, because at this time a sub-component in the sub-component tree is abnormal , it must be excluded from the page, otherwise it will still affect the rendering of the entire component tree.

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

Since getDerivedStateFromError will be called in the render stage, no side effects should be performed here; if necessary, the corresponding operations should be performed in the componentDidCatch lifecycle method.

componentDidCatch

componentDidCatch will be called in the commit stage, so it can be used to perform side effects, such as reporting error logs.

In the early days, when there was no API getDerivedStateFromError , it was necessary to update the state through the componentDidCatch method in the life cycle of setState , but this is not recommended at all, because through getDerivedStateFromError , it has been processed in the render stage, so why wait until the commit stage? This content will be described in detail below.

React's render exception handling mechanism

The reason why the "error boundary" is introduced first is that on the one hand, this is an API directly oriented to developers, which is easier to understand; on the other hand, in order to achieve such capabilities, React makes the render exception handling mechanism more complicated, otherwise It is very simple and rude to directly use try/catch catch the exception and deal with it uniformly.

How the exception is generated

As mentioned above, the error boundary deals with exceptions thrown during component rendering. In fact, this is essentially determined by React's render exception handling mechanism; while other asynchronous methods such as event callback methods and setTimeout/setInterval do not Affects the rendering of the React component tree, and is therefore not the target of the render exception handling mechanism.

What kind of exception will be caught by the render exception handling mechanism

Simply put, the render method of class components, function components and other code that will be executed synchronously in the render phase, once an exception is thrown, it will be caught by the render exception handling mechanism (regardless of whether there is an error boundary). Take a scenario that is often encountered in actual development:

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

In the rendering process of React, the above two function components will be executed successively, and an exception will be thrown when the execution reaches props.foo.text . The following is the executable js code formed after the <ErrorComponent /> code of 0620dba3e494ba is translated:

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

The specific location where the component itself throws the exception

The following content requires you to have a certain understanding of React's rendering process. Please read "React Source Code Analysis Series - React's Render Stage (2): beginWork"

In the beginWork method, if it is judged that the current Fiber node cannot be bailout (pruned), then the Fiber child node will be created/updated:

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:
    // ...省略
  // ...省略其他类型
}
Where ClassComponent throws exception

From the code segment of beginWork above, you can see that the updateClassComponent method is executed, and a parameter named Component is passed in. This parameter is actually the class of the class component. At this time, because the render method has not been executed, an exception has not been thrown.

Following , we can see that finishClassComponent is executed to create the Fiber child node:

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

In finishClassComponent , we can see nextChildren = instance.render() , where instance is the instantiated class object, and after calling the 0620dba3e4969c member render member method, we get nextChildren .

In the subsequent process, React will create/update Fiber child nodes based on this ReactElement object, but this is not what this article is concerned with; what we care about is that the render member method is executed here, which may throw the React exception handling mechanism The exception targeted.

Where the FunctionComponent throws the exception

Next we come to locate the position of the FunctionComponent Throws: With experience ClassComponent, we follow all the way updateFunctionComponent to renderWithHooks , in this method, we can see let children = Component(props, secondArg); , where Component is the function The function of the component itself, and children is the ReactElement object obtained after executing the function component.

How to catch render exceptions

When we are asked "how to catch exceptions", our instinct will answer "use try/catch ", so how does React catch exceptions thrown during the rendering of this component?

  • In production, React uses try/catch to catch exceptions
  • In the development environment, React does not use try/catch , but implements a more refined capture mechanism

Why can't you use try/catch directly?

React originally used try/catch directly to capture render exceptions, and as a result received a large number of issue , the details are as follows:

  • Chrome devtools has a function called Pause on exceptions , which can quickly locate the code where the exception is thrown, the effect is equivalent to setting a breakpoint on this line of code; but only uncaught exceptions can be used this method to locate
  • Developers complain that Chrome devtools cannot locate code that throws exceptions during rendering of React components
  • Some people found that as long as you open Pause On Caught Exceptions can locate the code location that throws the exception; after this function is enabled, even if the exception is caught, you can locate the target location, so it is judged that React "swallowed" the exception.

To solve this problem, React needs to provide a set of exception catching solutions that satisfy the following conditions:

  • It is still necessary to catch the exception and hand it over to the error boundary for processing after catching
  • Do not use try/catch to avoid affecting Chrome devtools' Pause on exceptions feature

How to catch render exceptions without using try/catch

When a JavaScript runtime error (including a syntax error) occurs, window fires an error event of the ErrorEvent interface and executes window.onerror().

The above description comes from the document of MDN GlobalEventHandlers.onerror , this is the method for catching exceptions except try/catch : we can attach a callback processing method to the error event of the window object, then as long as any javascript code on the page throws We can catch any exceptions.

But in doing so, wouldn't it also catch many exceptions unrelated to the rendering process of React components? In fact, we only need to listen for the error event before executing the rendering of the React component, and cancel the listening for the event after the component finishes rendering:

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

Does that make it work? Wait a minute! The original intention of this scheme is to replace the original try/catch code in the development environment, but now there is an important feature of try/catch that has not been restored: that is, after try/catch catches an exception, it will continue to execute code other than try/catch :

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

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

And use the above mockTryCatch to try to replace the words of try/catch :

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

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

Obviously, mockTryCatch cannot completely replace try/catch , because after mockTryCatch throws an exception, the execution of subsequent synchronization code will be forcibly terminated.

How to not affect subsequent code execution like try/catch

There are always all kinds of tricks in the front-end field, and it really makes React developers find such a way: EventTarget.dispatchEvent ; So, why do you say dispatchEvent can simulate and try/catch it?

dispatchEvent can execute code synchronously
Unlike browser native events, which are dispatched by the DOM and call event handlers asynchronously through the event loop, dispatchEvent() calls event handlers synchronously. After calling dispatchEvent() , all event handlers listening for this event will execute and return before the code continues.

The above is from the MDN document of dispatchEvent , which shows that: dispatchEvent can execute code synchronously, which means that the subsequent code execution of dispatchEvent can be blocked before the execution of the event processing method is completed, which is also one of the characteristics of try/catch .

The exception thrown by dispatchEvent does not bubble
These event handlers operate in a nested call stack: they block calls until they are done, but exceptions do not bubble.

To be precise, it is: through the event callback method triggered by dispatchEvent, the exception will not bubble; this means that even if an exception is thrown, it will only terminate the execution of the event callback method itself, and the code in the dispatchEvent() context will not receive Influence. Write a DEMO below to verify this feature:

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执行后

As can be seen from the above DEMO, although the event handler of dispatchEvent throws an exception, it can still continue to execute the subsequent code of dispatchEvent (ie console.log() in DEMO).

Implement a simplified render exception catcher

Next, let's combine GlobalEventHandlers.onerror and EventTarget.dispatchEvent to implement a simplified render exception catcher:

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() {
        // 将异常交给错误边界来处理
    }
}

How to catch render exceptions in React source code

After introducing the principle of catching render exceptions above, a simplified version of DEMO has also been implemented. Now we can analyze the React source code in detail.

Capture target: beginWork

As mentioned above, the exception of the rendering dimension of the React component is thrown in the stage of beginWork , so our target of capturing the exception is obviously beginWork .

Wrap beginWork

React encapsulates the beginWork method for the development environment, and adds the function of to capture the exception :

  1. Before executing bginWork, "back up" the properties of the current Fiber node ( unitOfWork ) and copy it to a Fiber node specially used for "backup".
  2. Execute beginWork and use try/catch catch exceptions; you may be confused when you see this, doesn't it mean that try/catch is not used to catch exceptions, why is this used again? Just keep looking down.
  3. If beginWork throws an exception, it will naturally be caught, and then execute the code segment of catch :

    1. Restore the current Fiber node ( unitOfWork ) from the backup to the state before beginWork was executed.
    2. Call the invokeGuardedCallback method on the current Fiber node to re-execute the beginWork. This invokeGuardedCallback method will apply the GlobalEventHandlers.onerror and EventTarget.dispatchEvent combined methods mentioned above to capture exceptions.
    3. Re-throw the caught exception, and the exception can be processed later; although an exception is thrown here, and this exception will be caught by the outer try/catch , this will not affect Pause on exceptions function, because it is generated in the invokeGuardedCallback method The exception is not caught by the outer 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

Next, let's look at the invokeGuardedCallback method. This method is not the core. It forms a "save/fetch" exception tool with other methods in the ReactErrorUtils.js file where it is located. The core of our attention is the invokeGuardedCallbackImpl method.

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

This invokeGuardedCallbackImpl is also divided into the realization of the production environment and the development environment. We only need to look at the realization of the development environment:

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事件的监听,清除副作用
};

The above is my simplified invokeGuardedCallbackImpl method. Is it similar to the simplified version of the React exception catcher we implemented above? Of course, this method actually includes the handling of many abnormal situations. These abnormal situations are raised by issues and then handled in a "patch" way, such as missing document in the test environment, or encountering cross Domain exception (cross-origin error), etc., will not be discussed in detail here.

Handling exceptions

The above describes how the exception is generated and how the exception is caught. Here is a brief introduction to how the exception is handled after it is caught:

  1. Starting from the Fiber node that throws the exception, traverse to the root node to find the error boundary that can handle the exception; if it cannot be found, it can only be handed over to the root node to handle the exception.
  2. If the exception is handled by the error boundary, create an update task whose payload is the state value returned after the getDerivedStateFromError method is executed, and callback is componentDidCatch ; if the exception is handled by the root node, an update task that unloads the entire component tree is created.
  3. In the render process of the node that handles the exception (ie performUnitOfWork ), the update task just created will be executed in this process.
  4. Finally, if the exception is handled by the error boundary, then according to the change of the error boundary state, the sub-component tree with the abnormal Fiber node will be unloaded, and the UI interface with friendly exception prompts will be rendered instead; and if the root node handles the exception , the entire component tree will be uninstalled, resulting in a white screen.

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

Source implementation of exception handling in React

As mentioned above, in the beginWork packaged in the (development environment), the exception captured by invokeGuardedCallback will be re-thrown, so where will this exception be intercepted? The answer is renderRootSync :

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

Let's introduce :

  • is an infinite loop structure of do...while(true) used by React, so what conditions can be met to exit the loop?
  • In the loop body, there is a try/catch code segment. Once the code segment in try throws an exception and is intercepted by catch , it will fall back to the parent node of the current node (React's old routine) and continue to try; Throwing an exception will end the loop, that is, end the entire method.
  • In the try code segment, 3 pieces of logic are mainly executed:

    1. Determine whether the current node or the parent node of the current node is null . If so, it means that the current node may be in the Fiber root node, and there is no error boundary to handle the exception. It is directly treated as a fatal exception and the current method is ended.
    2. Execute the throwException method, and traverse to find a node (error boundary) that can handle the current exception, which will be described in detail below.
    3. Execute completeUnitOfWork , which is one of the most important methods in the rendering process, but here is mainly to execute the code branch about exception handling, which will be described in detail below.

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

the

  • Add the EffectTag of Incomplete to the Fiber node that throws the exception, and then it will go to the code branch of exception handling according to this Incomplete flag.
  • Starting from the parent node of the Fiber node that throws the exception, traverse to the root node to find a node that can handle the exception; currently only the error boundary and the Fiber root node can handle the exception; according to the traversal direction, if there is an error in the traversal path If there is a boundary, the error boundary will definitely be found first, that is, the error boundary will be given priority to handle exceptions.

    • " Standard for judging error boundaries" can be reflected here: it must be a ClassComponent, and contains either or one of and componentDidCatch .
  • After finding a node that can handle exceptions, different code branches will be executed according to different types, but the general idea is the same:

    1. Add the EffectTag of 1620dba3e4a1d8 to the node, and then go to the code branch of exception handling according to this EffectTag.
    2. Create a new update task for the current exception, and find a lane with the highest priority for the update task to ensure that it will be executed in this render; among them, the error boundary will call createRootErrorUpdate method to create the update task, and the root node will It is to call createRootErrorUpdate method to create an update task. These two methods will be described in detail below.
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);
}
createRootErrorUpdate and createClassErrorUpdate

When encountering a fatal exception that no error boundary can handle, the createRootErrorUpdate method will be called to create a state update task, which will set the root node to null , that is, unload the entire React component tree. React officials believe that instead of rendering an abnormal interface to mislead users, it is better to directly display a white screen; I can't deny the official idea, but I am more sure of the importance of error boundaries.

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

When an error boundary is found to handle the current exception, the method getDerivedStateFromError will be called to create a status update callback payload componentDidCatch method (usually used to perform some operations with side effects).

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

After talking about throwException above, let's continue to look at the last step in the handleError method - completeUnitOfWork , this method will process the abnormal Fiber node. In the abnormal scenario, the only parameter of this method is throws the exception Fiber node .

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

In the previous article, we have introduced the completeUnitOfWork method, but the introduction is the normal process, and the exception handling process is directly ignored. Let's make up this piece:

  • completeUnitOfWork is a bit similar to the throwException introduced above. It traverses from the current Fiber node (the node that throws the exception in the abnormal scenario) to the root node to find a node that can handle the exception; since completeUnitOfWork also contains The normal process and exception handling process are therefore entered into the code branch of exception handling through the EffectTag of Incomplete .
  • Once a Fiber node that can handle exceptions is found, set it as the next round of work( body (workInProgres) , and then immediately terminate the completeUnitOfWork method; it will return to performUnitOfWork and enter this ( beginWork stage of Fiber node that can handle exceptions.
  • During the traversal process, if it is found that the current node cannot handle the exception, the parent node of the current node will also be marked Incomplete to ensure that the parent node will also enter the code branch of exception handling.
  • The logic for the sibling node in completeUnitOfWork does not distinguish whether it is a normal process or not, which is a bit surprising to me: because if the current node has an exception, even if its sibling node is normal, it will be re-rendered in the subsequent exception handling process. , why do you need to render its sibling node at this time; but conversely, doing so will not cause a problem, because when the sibling node returns to the parent node in completeUnitOfWork, since the parent node has been set to Incomplete, it is also The exception handling process will still be followed.

completeUnitOfWork 的异常处理流程

Here's another question: why re-render the node that can handle exceptions with ? We can actually guess what React does without looking at the subsequent operations: assuming that the node can handle exceptions is an error boundary, the state value returned by throwException described above has been executed according to An update task is created, then only the state of the error boundary needs to be updated later, and the component that throws the exception is unloaded and the component with the error prompt is rendered according to the state. Isn't this a very normal rendering process?

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

Here is how the unwindWork method determines whether the current Fiber node ( completeWork ) can handle exceptions:

  • According to completeWork.tag , the Fiber node type, only the four types of Fiber node types, ClassComponent / HostRoot / SuspenseComponent / DehydratedSuspenseComponent can handle exceptions
  • completeWork.effectTag by whether 0620dba3e4a612 contains ShouldCapture , this EffectTag is marked in the throwException method described above.

In the unwindWork method, once it is judged that the current Fiber node can handle the exception, then clear its ShouldCapture and add the of 1620dba3e4a63f 1620dba3e4a640, which will also become the judgment standard for subsequent exception handling.

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;
  }
}
Re-render wrong boundary Fiber nodes

In the completeUnitOfWork method, we use the do...while loop with the unwindWork method to find the error boundary node that has been marked in the throwException method to handle the current exception; the following assumption is that there is such a error boundary node, then the completeUnitOfWork method will be called End, and then enter the second render of the node:

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

This is on top of the normal course render a ClassComponent, first of all we need to pay attention to updateClassInstance , in this method, the task will be updated for the current node to update the state node; remember createClassErrorUpdate in Is an update task created based on the state value returned by the class component static method , the update task is also given the highest priority: pickArbitraryLane(rootRenderLanes) , so at will update the state according to this update task (that is, state value returned by getDerivedStateFromError ).

Then, we enter the logic of the finishClassComponent method. This method actually does two things for exception handling:

  1. Compatible with old error boundary API

    • The basis for judging whether it is the old version of the error boundary is: whether the ClassComponent of the current node has a static method of the class getDerivedStateFromError ; in the old version of the error boundary, there is no API of getDerivedStateFromError , and the unity is to initiate setState() componentDidCatch modify the state,
    • The compatible method is: in this render process, set nextChildren to null , that is, uninstall all child nodes, so as to avoid this render throwing an exception; and in the commit phase, the callback of the update task will be executed, that is componentDidCatch , then a new round of render can be initiated.
  2. Forcing the re-creation of child nodes is actually not much different from the normal logic call reconcileChildren , but some small measures are made to prohibit the reuse of child nodes on the current tree, which will be described in detail below.
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;
}
How to force re-render of child nodes

When introducing finishClassComponent, we mentioned that we can use forceUnmountCurrentAndReconcile method, which is similar to the normal render logic. ReconcileChildFibers is also called in this method, but it is called twice:

  1. When calling reconcileChildFibers for the first time, the parameter that should have been passed as " child node ReactElement object " is changed to null , which is equivalent to uninstalling all child nodes; Mark the EffectTag with " delete ".
  2. When calling reconcileChildFibers for the second time, the parameter that should have been passed in " current tree corresponding to the child node " is changed to null ; in this way, all children of the current node (error boundary) can be guaranteed after this render. Nodes are newly created, and current tree nodes will not be reused

As for why this is done, React's official explanation is "Conceptually, handling exceptions and normal rendering are two different sets of UI, and should not reuse any child nodes (even if the node's characteristics - key/props) etc. are consistent)"; in simple terms, it is "one size fits all" to avoid multiplexing to abnormal Fiber nodes.

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

write at the end

The above is an introduction to the React render exception handling mechanism. Through this article, the omissions of the previous articles introducing render are completed (the previous article only introduces the normal process of render), let us handle exceptions in the development process" You know", add a few error boundaries to your application (laughs).


array_huang
10.5k 声望6.6k 粉丝