從源碼看React異常處理

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

本文源碼是2018年8月30日拉取的React倉庫master分支上的代碼react

本文涉及的源碼是React16異常處理部分,對於React16總體的源碼的分析,能夠看看個人文章:React16源碼之React Fiber架構git

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

對 React16 Error Boundaries 不瞭解的小夥伴能夠看看官方文檔:Error Boundaries算法

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

  • 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」.bash

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異常致使整個應用的異常app

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

它能捕獲子組件生命週期函數中的異常,包括構造函數(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組件部分的生命週期函數的調用以及經過Diff算法計算出全部更新工做都在第三部分進行的,因此異常處理也是在這部分進行的

commit階段

函數調用流程以下:

這個階段主要作的工做拿到reconciliation階段產出的全部更新工做,提交這些工做並調用渲染模塊(react-dom)渲染UI。完成UI渲染以後,會調用剩餘的生命週期函數,因此異常處理也會在這部分進行

而各生命週期函數在各階段的調用狀況以下:

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

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

二、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中判斷當前錯誤類型,調用不一樣的異常處理方法

有兩種異常處理方法:

一、RootError,最後是調用 onUncaughtError 函數處理

二、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函數分爲兩部分:

一、遍歷當前異常節點的全部父節點,找到對應的錯誤信息(錯誤名稱、調用棧等),這部分代碼在上面中沒有展現出來

二、第二部分就是上面展現出來的部分,能夠看到,也是遍歷當前異常節點的全部父節點,判斷各節點的類型,主要仍是上面提到的兩種類型,這裏重點講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函數中被調用,咱們來看下具體的調用流程:

一、commitAllLifeCycles函數中會調用commitLifeCycles函數

二、在commitLifeCycles函數中,對於ClassComponent和HostRoot會調用commitUpdateQueue函數

三、咱們來看看 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 ⭐️

相關文章
相關標籤/搜索