React 原始碼解析系列 - React 的 render 異常處理機制

array_huang發表於2022-02-17

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

本系列文章均為討論 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 的正常流程),讓我們在開發過程中做到對異常處理“心裡有數”,快給你的應用加幾個錯誤邊界吧(笑)。

相關文章