從原始碼看React異常處理

hujiao發表於2019-02-20

原文地址:github.com/HuJiaoHJ/bl…

本文原始碼是2018年8月30日拉取的React倉庫master分支上的程式碼

本文涉及的原始碼是React16異常處理部分,對於React16整體的原始碼的分析,可以看看我的文章:React16原始碼之React Fiber架構

React16引入了 Error Boundaries 即異常邊界概念,以及一個新的生命週期函式:componentDidCatch,來支援React執行時的異常捕獲和處理

對 React16 Error Boundaries 不瞭解的小夥伴可以看看官方文件:Error Boundaries

下面從兩個方面進行分享:

  • Error Boundaries 介紹和使用
  • 原始碼分析

Error Boundaries(異常邊界)

A JavaScript error in a part of the UI shouldn’t break the whole app. To solve this problem for React users, React 16 introduces a new concept of an “error boundary”.

Error boundaries are React components that catch JavaScript errors anywhere in their child component tree, log those errors, and display a fallback UI instead of the component tree that crashed. Error boundaries catch errors during rendering, in lifecycle methods, and in constructors of the whole tree below them.

從上面可以知道,React16引入了Error Boundaries(異常邊界)的概念是為了避免React的元件內的UI異常導致整個應用的異常

Error Boundaries(異常邊界)是React元件,用於捕獲它子元件樹種所有元件產生的js異常,並渲染指定的兜底UI來替代出問題的元件

它能捕獲子元件生命週期函式中的異常,包括建構函式(constructor)和render函式

而不能捕獲以下異常:

  • Event handlers(事件處理函式)
  • Asynchronous code(非同步程式碼,如setTimeout、promise等)
  • Server side rendering(服務端渲染)
  • Errors thrown in the error boundary itself (rather than its children)(異常邊界元件本身丟擲的異常)

接下來我們來寫一個異常邊界元件,如下:

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  componentDidCatch(error, info) {
    // Display fallback UI
    this.setState({ hasError: true });
    // You can also log the error to an error reporting service
    logErrorToMyService(error, info);
  }

  render() {
    if (this.state.hasError) {
      // You can render any custom fallback UI
      return <h1>Something went wrong.</h1>;
    }
    return this.props.children;
  }
}
複製程式碼

使用如下:

<ErrorBoundary>
  <MyWidget />
</ErrorBoundary>
複製程式碼

MyWidget元件在建構函式、render函式以及所有生命週期函式中丟擲異常時,異常將會被 ErrorBoundary異常邊界元件捕獲,執行 componentDidCatch函式,渲染對應 fallback UI 替代MyWidget元件

接下來,我們從原始碼的角度來看看異常邊界元件是怎麼捕獲異常,以及為什麼只能捕獲到子元件在建構函式、render函式以及所有生命週期函式中丟擲異常

原始碼分析

先簡單瞭解一下React整體的原始碼結構,感興趣的小夥伴可以看看之前寫的文章:React16原始碼之React Fiber架構 ,這篇文章包括了對React整體流程的原始碼分析,其中有提到React核心模組(Reconciliation,又叫協調模組)分為兩階段:(本文不會再詳細介紹了,感興趣的小夥伴自行了解哈~)

reconciliation階段

函式呼叫流程如下:

從原始碼看React異常處理

這個階段核心的部分是上圖中標出的第三部分,React元件部分的生命週期函式的呼叫以及通過Diff演算法計算出所有更新工作都在第三部分進行的,所以異常處理也是在這部分進行的

commit階段

函式呼叫流程如下:

從原始碼看React異常處理

這個階段主要做的工作拿到reconciliation階段產出的所有更新工作,提交這些工作並呼叫渲染模組(react-dom)渲染UI。完成UI渲染之後,會呼叫剩餘的生命週期函式,所以異常處理也會在這部分進行

而各生命週期函式在各階段的呼叫情況如下:

從原始碼看React異常處理

下面我們正式開始異常處理部分的原始碼分析,React異常處理在原始碼中的入口主要有兩處:

1、reconciliation階段的 renderRoot 函式,對應異常處理方法是 throwException

2、commit階段的 commitRoot 函式,對應異常處理方法是 dispatch

throwException

首先看看 renderRoot 函式原始碼中與異常處理相關的部分:

function renderRoot(
  root: FiberRoot,
  isYieldy: boolean,
  isExpired: boolean,
): void {
  ...
  do {
    try {
      workLoop(isYieldy);
    } catch (thrownValue) {
      if (nextUnitOfWork === null) {
        // This is a fatal error.
        didFatal = true;
        onUncaughtError(thrownValue);
      } else {
        ...
        const sourceFiber: Fiber = nextUnitOfWork;
        let returnFiber = sourceFiber.return;
        if (returnFiber === null) {
          // This is the root. The root could capture its own errors. However,
          // we don`t know if it errors before or after we pushed the host
          // context. This information is needed to avoid a stack mismatch.
          // Because we`re not sure, treat this as a fatal error. We could track
          // which phase it fails in, but doesn`t seem worth it. At least
          // for now.
          didFatal = true;
          onUncaughtError(thrownValue);
        } else {
          throwException(
            root,
            returnFiber,
            sourceFiber,
            thrownValue,
            nextRenderExpirationTime,
          );
          nextUnitOfWork = completeUnitOfWork(sourceFiber);
          continue;
        }
      }
    }
    break;
  } while (true);
  ...
}
複製程式碼

可以看到,這部分就是在workLoop大迴圈外套了層try...catch...,在catch中判斷當前錯誤型別,呼叫不同的異常處理方法

有兩種異常處理方法:

1、RootError,最後是呼叫 onUncaughtError 函式處理

2、ClassError,最後是呼叫 componentDidCatch 生命週期函式處理

上面兩種方法處理流程基本類似,這裡就重點分析 ClassError 方法

接下來我們看看 throwException 原始碼:

function throwException(
  root: FiberRoot,
  returnFiber: Fiber,
  sourceFiber: Fiber,
  value: mixed,
  renderExpirationTime: ExpirationTime,
) {
  ...
  // We didn`t find a boundary that could handle this type of exception. Start
  // over and traverse parent path again, this time treating the exception
  // as an error.
  renderDidError();
  value = createCapturedValue(value, sourceFiber);
  let workInProgress = returnFiber;
  do {
    switch (workInProgress.tag) {
      case HostRoot: {
        const errorInfo = value;
        workInProgress.effectTag |= ShouldCapture;
        workInProgress.expirationTime = renderExpirationTime;
        const update = createRootErrorUpdate(
          workInProgress,
          errorInfo,
          renderExpirationTime,
        );
        enqueueCapturedUpdate(workInProgress, update);
        return;
      }
      case ClassComponent:
      case ClassComponentLazy:
        // Capture and retry
        const errorInfo = value;
        const ctor = workInProgress.type;
        const instance = workInProgress.stateNode;
        if (
          (workInProgress.effectTag & DidCapture) === NoEffect &&
          ((typeof ctor.getDerivedStateFromCatch === `function` &&
            enableGetDerivedStateFromCatch) ||
            (instance !== null &&
              typeof instance.componentDidCatch === `function` &&
              !isAlreadyFailedLegacyErrorBoundary(instance)))
        ) {
          workInProgress.effectTag |= ShouldCapture;
          workInProgress.expirationTime = renderExpirationTime;
          // Schedule the error boundary to re-render using updated state
          const update = createClassErrorUpdate(
            workInProgress,
            errorInfo,
            renderExpirationTime,
          );
          enqueueCapturedUpdate(workInProgress, update);
          return;
        }
        break;
      default:
        break;
    }
    workInProgress = workInProgress.return;
  } while (workInProgress !== null);
}
複製程式碼

throwException函式分為兩部分:

1、遍歷當前異常節點的所有父節點,找到對應的錯誤資訊(錯誤名稱、呼叫棧等),這部分程式碼在上面中沒有展示出來

2、第二部分就是上面展示出來的部分,可以看到,也是遍歷當前異常節點的所有父節點,判斷各節點的型別,主要還是上面提到的兩種型別,這裡重點講ClassComponent型別,判斷該節點是否是異常邊界元件(通過判斷是否存在componentDidCatch生命週期函式等),如果是找到異常邊界元件,則呼叫 createClassErrorUpdate函式新建update,並將此update放入此節點的異常更新佇列中,在後續更新中,會更新此佇列中的更新工作

我們來看看 createClassErrorUpdate的原始碼:

function createClassErrorUpdate(
  fiber: Fiber,
  errorInfo: CapturedValue<mixed>,
  expirationTime: ExpirationTime,
): Update<mixed> {
  const update = createUpdate(expirationTime);
  update.tag = CaptureUpdate;
  ...
  const inst = fiber.stateNode;
  if (inst !== null && typeof inst.componentDidCatch === `function`) {
    update.callback = function callback() {
      if (
        !enableGetDerivedStateFromCatch ||
        getDerivedStateFromCatch !== `function`
      ) {
        // To preserve the preexisting retry behavior of error boundaries,
        // we keep track of which ones already failed during this batch.
        // This gets reset before we yield back to the browser.
        // TODO: Warn in strict mode if getDerivedStateFromCatch is
        // not defined.
        markLegacyErrorBoundaryAsFailed(this);
      }
      const error = errorInfo.value;
      const stack = errorInfo.stack;
      logError(fiber, errorInfo);
      this.componentDidCatch(error, {
        componentStack: stack !== null ? stack : ``,
      });
    };
  }
  return update;
}
複製程式碼

可以看到,此函式返回一個update,此update的callback最終會呼叫元件的 componentDidCatch生命週期函式

大家可能會好奇,update的callback最終會在什麼時候被呼叫,update的callback最終會在commit階段的 commitAllLifeCycles函式中被呼叫,這塊在講完dispatch之後會詳細講一下

以上就是 reconciliation階段 的異常捕獲到異常處理的流程,可以知道此階段是在workLoop大迴圈外套了層try...catch...,所以workLoop裡所有的異常都能被異常邊界元件捕獲並處理

下面我們看看 commit階段 的 dispatch

dispatch

我們先看看 dispatch 的原始碼:

function dispatch(
  sourceFiber: Fiber,
  value: mixed,
  expirationTime: ExpirationTime,
) {
  let fiber = sourceFiber.return;
  while (fiber !== null) {
    switch (fiber.tag) {
      case ClassComponent:
      case ClassComponentLazy:
        const ctor = fiber.type;
        const instance = fiber.stateNode;
        if (
          typeof ctor.getDerivedStateFromCatch === `function` ||
          (typeof instance.componentDidCatch === `function` &&
            !isAlreadyFailedLegacyErrorBoundary(instance))
        ) {
          const errorInfo = createCapturedValue(value, sourceFiber);
          const update = createClassErrorUpdate(
            fiber,
            errorInfo,
            expirationTime,
          );
          enqueueUpdate(fiber, update);
          scheduleWork(fiber, expirationTime);
          return;
        }
        break;
      case HostRoot: {
        const errorInfo = createCapturedValue(value, sourceFiber);
        const update = createRootErrorUpdate(fiber, errorInfo, expirationTime);
        enqueueUpdate(fiber, update);
        scheduleWork(fiber, expirationTime);
        return;
      }
    }
    fiber = fiber.return;
  }

  if (sourceFiber.tag === HostRoot) {
    // Error was thrown at the root. There is no parent, so the root
    // itself should capture it.
    const rootFiber = sourceFiber;
    const errorInfo = createCapturedValue(value, rootFiber);
    const update = createRootErrorUpdate(rootFiber, errorInfo, expirationTime);
    enqueueUpdate(rootFiber, update);
    scheduleWork(rootFiber, expirationTime);
  }
}
複製程式碼

dispatch函式做的事情和上部分的 throwException 類似,遍歷當前異常節點的所有父節點,找到異常邊界元件(有componentDidCatch生命週期函式的元件),新建update,在update.callback中呼叫元件的componentDidCatch生命週期函式,後續的部分這裡就不詳細描述了,和 reconciliation階段 基本一致,這裡我們看看commit階段都哪些部分呼叫了dispatch函式

function captureCommitPhaseError(fiber: Fiber, error: mixed) {
  return dispatch(fiber, error, Sync);
}
複製程式碼

呼叫 captureCommitPhaseError 即呼叫 dispatch,而 captureCommitPhaseError 主要是在 commitRoot 函式中被呼叫,原始碼如下:

function commitRoot(root: FiberRoot, finishedWork: Fiber): void {
  ...
  // commit階段的準備工作
  prepareForCommit(root.containerInfo);

  // Invoke instances of getSnapshotBeforeUpdate before mutation.
  nextEffect = firstEffect;
  startCommitSnapshotEffectsTimer();
  while (nextEffect !== null) {
    let didError = false;
    let error;
    try {
        // 呼叫 getSnapshotBeforeUpdate 生命週期函式
        commitBeforeMutationLifecycles();
    } catch (e) {
        didError = true;
        error = e;
    }
    if (didError) {
      captureCommitPhaseError(nextEffect, error);
      if (nextEffect !== null) {
        nextEffect = nextEffect.nextEffect;
      }
    }
  }
  stopCommitSnapshotEffectsTimer();

  // Commit all the side-effects within a tree. We`ll do this in two passes.
  // The first pass performs all the host insertions, updates, deletions and
  // ref unmounts.
  nextEffect = firstEffect;
  startCommitHostEffectsTimer();
  while (nextEffect !== null) {
    let didError = false;
    let error;
    try {
        // 提交所有更新並呼叫渲染模組渲染UI
        commitAllHostEffects(root);
    } catch (e) {
        didError = true;
        error = e;
    }
    if (didError) {
      captureCommitPhaseError(nextEffect, error);
      // Clean-up
      if (nextEffect !== null) {
        nextEffect = nextEffect.nextEffect;
      }
    }
  }
  stopCommitHostEffectsTimer();

  // The work-in-progress tree is now the current tree. This must come after
  // the first pass of the commit phase, so that the previous tree is still
  // current during componentWillUnmount, but before the second pass, so that
  // the finished work is current during componentDidMount/Update.
  root.current = finishedWork;

  // In the second pass we`ll perform all life-cycles and ref callbacks.
  // Life-cycles happen as a separate pass so that all placements, updates,
  // and deletions in the entire tree have already been invoked.
  // This pass also triggers any renderer-specific initial effects.
  nextEffect = firstEffect;
  startCommitLifeCyclesTimer();
  while (nextEffect !== null) {
    let didError = false;
    let error;
    try {
        // 呼叫剩餘生命週期函式
        commitAllLifeCycles(root, committedExpirationTime);
    } catch (e) {
        didError = true;
        error = e;
    }
    if (didError) {
      captureCommitPhaseError(nextEffect, error);
      if (nextEffect !== null) {
        nextEffect = nextEffect.nextEffect;
      }
    }
  }
  ...
}
複製程式碼

可以看到,有三處(也是commit階段主要的三部分)通過try...catch...呼叫了 captureCommitPhaseError函式,即呼叫了 dispatch函式,而這三個部分具體做的事情註釋裡也寫了,詳細的感興趣的小夥伴可以看看我的文章:React16原始碼之React Fiber架構

剛剛我們提到,update的callback會在commit階段的commitAllLifeCycles函式中被呼叫,我們來看下具體的呼叫流程:

1、commitAllLifeCycles函式中會呼叫commitLifeCycles函式

2、在commitLifeCycles函式中,對於ClassComponent和HostRoot會呼叫commitUpdateQueue函式

3、我們來看看 commitUpdateQueue 函式原始碼:

export function commitUpdateQueue<State>(
  finishedWork: Fiber,
  finishedQueue: UpdateQueue<State>,
  instance: any,
  renderExpirationTime: ExpirationTime,
): void {
  ...
  // Commit the effects
  commitUpdateEffects(finishedQueue.firstEffect, instance);
  finishedQueue.firstEffect = finishedQueue.lastEffect = null;

  commitUpdateEffects(finishedQueue.firstCapturedEffect, instance);
  finishedQueue.firstCapturedEffect = finishedQueue.lastCapturedEffect = null;
}

function commitUpdateEffects<State>(
  effect: Update<State> | null,
  instance: any,
): void {
  while (effect !== null) {
    const callback = effect.callback;
    if (callback !== null) {
      effect.callback = null;
      callCallback(callback, instance);
    }
    effect = effect.nextEffect;
  }
}
複製程式碼

我們可以看到,commitUpdateQueue函式中會呼叫兩次commitUpdateEffects函式,引數分別是正常update佇列以及存放異常處理update佇列

而commitUpdateEffects函式就是遍歷所有update,呼叫其callback方法

上文提到,commitAllLifeCycles函式中是用於呼叫剩餘生命週期函式,所以異常邊界元件的 componentDidCatch生命週期函式也是在這個階段呼叫

總結

我們現在可以知道,React內部其實也是通過 try...catch... 形式是捕獲各階段的異常,但是隻在兩個階段的特定幾處進行了異常捕獲,這也是為什麼異常邊界只能捕獲到子元件在建構函式、render函式以及所有生命週期函式中丟擲的異常

細心的小夥伴應該注意到,throwExceptiondispatch 在遍歷節點時,是從異常節點的父節點開始遍歷,這也是為什麼異常邊界元件自身的異常不會捕獲並處理

我們也提到了React內部將異常分為了兩種異常處理方法:RootError、ClassError,我們只重點分析了 ClassError 型別的異常處理函式,其實 RootError 是一樣的,區別在於最後呼叫的處理方法不同,在遍歷所有父節點過程中,如果有異常邊界元件,則會呼叫 ClassError 型別的異常處理函式,如果沒有,一直遍歷到根節點,則會呼叫 RootError 型別的異常處理函式,最後呼叫的 onUncaughtError 方法,此方法做的事情很簡單,其實就是將 hasUnhandledError 變數賦值為 true,將 unhandledError 變數賦值為異常物件,此異常物件最終將在 finishRendering函式中被丟擲,而finishRendering函式是在performWork函式的最後被呼叫,這塊簡單感興趣的小夥伴可以自行看程式碼~

本文涉及很多React其他部分的原始碼,不熟悉的小夥伴可以看看我的文章:React16原始碼之React Fiber架構

寫在最後

以上就是我對React16異常處理部分的原始碼的分享,希望能對有需要的小夥伴有幫助~~~

喜歡我的文章小夥伴可以去 我的個人部落格 點star ⭐️

相關文章