iT邦幫忙

0

完全理解React的completeWork以及错误边界

點擊進入React源碼調試倉庫。

概述

每個fiber節點在更新時都會經歷兩個階段:beginWork和completeWork。在Diff之後(詳見深入理解React Diff原理),workInProgress節點就會進入complete階段。這個時候拿到的workInProgress節點都是經過diff算法調和過的,也就意味著對於某個節點來說它fiber的形態已經基本確定了,但除此之外還有兩點:

  • 目前只有fiber形態變了,對於原生DOM組件(HostComponent)和文本節點(HostText)的fiber來說,對應的DOM節點(fiber.stateNode)並未變化。
  • 經過Diff生成的新的workInProgress節點持有了flag(即effectTag)

基於這兩個特點,completeWork的工作主要有:

  • 構建或更新DOM節點,
    • 構建過程中,會自下而上將子節點的第壹層第壹層插入到當前節點。
    • 更新過程中,會計算DOM節點的屬性,壹旦屬性需要更新,會為DOM節點對應的workInProgress節點標記Update的effectTag。
  • 自下而上收集effectList,最終收集到root上

對於正常執行工作的workInProgress節點來說,會走以上的流程。但是免不了節點的更新會出錯,所以對出錯的節點會采取措施,這涉及到錯誤邊界以及Suspense的概念,
本文只做簡單流程分析。

這壹節涉及的知識點有

  • DOM節點的創建以及掛載
  • DOM屬性的處理
  • effectList的收集
  • 錯誤處理

流程

completeUnitOfWork是completeWork階段的入口。它內部有壹個循環,會自下而上地遍歷workInProgress節點,依次處理節點。

對於正常的workInProgress節點,會執行completeWork。這其中會對HostComponent組件完成更新props、綁定事件等DOM相關的工作。

function completeUnitOfWork(unitOfWork: Fiber): void {
  let completedWork = unitOfWork;
  do {
    const current = completedWork.alternate;
    const returnFiber = completedWork.return;

    if ((completedWork.effectTag & Incomplete) === NoEffect) {
      // 如果workInProgress節點沒有出錯,走正常的complete流程
      ...

      let next;

      // 省略了判斷邏輯
      // 對節點進行completeWork,生成DOM,更新props,綁定事件
      next = completeWork(current, completedWork, subtreeRenderLanes);

      if (next !== null) {
        // 任務被掛起的情況,
        workInProgress = next;
        return;
      }

      // 收集workInProgress節點的lanes,不漏掉被跳過的update的lanes,便於再次發起調度
      resetChildLanes(completedWork);

      // 將當前節點的effectList並入父級節點
       ...

      // 如果當前節點他自己也有effectTag,將它自己
      // 也並入到父級節點的effectList
    } else {
      // 執行到這個分支說明之前的更新有錯誤
      // 進入unwindWork
      const next = unwindWork(completedWork, subtreeRenderLanes);
      ...

    }

    // 查找兄弟節點,若有則進行beginWork -> completeWork
    const siblingFiber = completedWork.sibling;
    if (siblingFiber !== null) {

      workInProgress = siblingFiber;
      return;
    }
    // 若沒有兄弟節點,那麽向上回到父級節點
    // 父節點進入complete
    completedWork = returnFiber;
    // 將workInProgress節點指向父級節點
    workInProgress = completedWork;
  } while (completedWork !== null);

  // 到達了root,整棵樹完成了工作,標記完成狀態
  if (workInProgressRootExitStatus === RootIncomplete) {
    workInProgressRootExitStatus = RootCompleted;
  }
}

由於React的大部分的fiber節點最終都要體現為DOM,所以本文主要分析HostComponent相關的處理流程。

function completeWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {

  ...

  switch (workInProgress.tag) {
    ...
    case HostComponent: {
      ...
      if (current !== null && workInProgress.stateNode != null) {
        // 更新
      } else {
        // 創建
      }
      return null;
    }
    case HostText: {
      const newText = newProps;
      if (current && workInProgress.stateNode != null) {
        // 更新
      } else {
        // 創建
      }
      return null;
    }
    case SuspenseComponent:
    ...
  }
}

由completeWork的結構可以看出,就是依據fiber的tag做不同處理。對HostComponent 和 HostText的處理是類似的,都是針對它們的DOM節點,處理方法又會分為更新和創建。

若current存在並且workInProgress.stateNode(workInProgress節點對應的DOM實例)存在,說明此workInProgress節點的DOM節點已經存在,走更新邏輯,否則進行創建。

DOM節點的更新實則是屬性的更新,會在下面的DOM屬性的處理 -> 屬性的更新中講到,先來看壹下DOM節點的創建和插入。

DOM節點的創建和插入

我們知道,此時的completeWork處理的是經過diff算法之後產生的新fiber。對於HostComponent類型的新fiber來說,它可能有DOM節點,也可能沒有。沒有的話,
就需要執行先創建,再插入的操作,由此引入DOM的插入算法。

if (current !== null && workInProgress.stateNode != null) {
    // 表明fiber有dom節點,需要執行更新過程
} else {
    // fiber不存在DOM節點
    // 先創建DOM節點
    const instance = createInstance(
      type,
      newProps,
      rootContainerInstance,
      currentHostContext,
      workInProgress,
    );

    //DOM節點插入
    appendAllChildren(instance, workInProgress, false, false);

    // 將DOM節點掛載到fiber的stateNode上
    workInProgress.stateNode = instance;

    ...

}

需要註意的是,DOM的插入並不是將當前DOM插入它的父節點,而是將當前這個DOM節點的第壹層子節點插入到它自己的下面。

圖解算法

此時的completeWork階段,會自下而上遍歷workInProgress樹到root,每經過壹層都會按照上面的規則插入DOM。下邊用壹個例子來理解壹下這個過程。

這是壹棵fiber樹的結構,workInProgress樹最終要成為這個形態。

  1              App
                  |
                  |
  2              div
                /
               /
  3        <List/>--->span
            /
           /
  4       p ----> 'text node'
         /
        /
  5    h1

構建workInProgress樹的DFS遍歷對沿途節點壹路beginWork,此時已經遍歷到最深的h1節點,它的beginWork已經結束,開始進入completeWork階段,此時所在的層級深度為第5層。

第5層

  1              App
                  |
                  |
  2              div
                /
               /
  3        <List/>
            /
           /
  4       p
         /
        /
  5--->h1

此時workInProgress節點指向h1的fiber,它對應的dom節點為h1,dom標簽創建出來以後進入appendAllChildren,因為當前的workInProgress節點為h1,所以它的child為null,無子節點可插入,退出。
h1節點完成工作往上返回到第4層的p節點。

此時的dom樹為

      h1

第4層

  1              App
                  |
                  |
  2              div
                /
               /
  3        <List/>
            /
           /
  4 --->  p ----> 'text node'
         /
        /
  5    h1

此時workInProgress節點指向p的fiber,它對應的dom節點為p,進入appendAllChildren,發現 p 的child為 h1,並且是HostComponent組件,將 h1 插入 p,然後尋找子節點h1是否有同級的sibling節點。發現沒有,退出。

p節點的所有工作完成,它的兄弟節點:HostText類型的組件'text'會作為下壹個工作單元,執行beginWork再進入completeWork。現在需要對它執行appendAllChildren,發現沒有child,不執行插入操作。它的工作也完成,return到父節點<List/>,進入第3層

此時的dom樹為

        p      'text'
       /
      /
     h1

第3層

  1              App
                  |
                  |
  2              div
                /
               /
  3 --->   <List/>--->span
            /
           /
  4       p ----> 'text'
         /
        /
  5    h1

此時workInProgress節點指向<List/>的fiber,對它進行completeWork,由於此時它是自定義組件,不屬於HostComponent,所以不會對它進行子節點的插入操作。

尋找它的兄弟節點span,對span先進行beginWork再進行到completeWork,執行span子節點的插入操作,發現它沒有child,退出。return到父節點div,進入第二層。

此時的dom樹為

                span

        p      'text'
       /
      /
     h1

第2層

  1              App
                  |
                  |
  2 --------->   div
                /
               /
  3        <List/>--->span
            /
           /
  4       p ---->'text'
         /
        /
  5    h1

此時workInProgress節點指向div的fiber,對它進行completeWork,執行div的子節點插入。由於它的child是,不滿足node.tag === HostComponent || node.tag === HostText的條件,所以不會將它插入到div中。繼續向下找的child,發現是p,將P插入div,然後尋找p的sibling,發現了'text',將它也插入div。之後再也找不到同級節點,此時回到第三層的節點。

有sibling節點span,將span插入到div。由於span沒有子節點,退出。

此時的dom樹為

             div
          /   |   \
         /    |    \
       p   'text'  span
      /
     /
    h1

第1層
此時workInProgress節點指向App的fiber,由於它是自定義節點,所以不會對它進行子節點的插入操作。

到此為止,dom樹基本構建完成。在這個過程中我們可以總結出幾個規律:

  1. 向節點中插入dom節點時,只插入它子節點中第壹層的dom。可以把這個插入可以看成是壹個自下而上收集dom節點的過程。第壹層子節點之下的dom,已經在第壹層子節點執行插入時被插入第壹層子節點了,從下往上逐層completeWork
    的這個過程類似於dom節點的累加。

  2. 總是優先看本身可否插入,再往下找,之後才是找sibling節點。

這是由於fiber樹和dom樹的差異導致,每個fiber節點不壹定對應壹個dom節點,但壹個dom節點壹定對應壹個fiber節點。

   fiber樹      DOM樹

   <App/>       div
     |           |
    div        input
     |
  <Input/>
     |
   input

由於壹個原生DOM組件的子組件有可能是類組件或函數組件,所以會優先檢查自身,發現自己不是原生DOM組件,不能被插入到父級fiber節點對應的DOM中,所以要往下找,直到找到原生DOM組件,執行插入,最後再從這壹層找同級的fiber節點,同級節點也會執行先自檢,再檢查下級,再檢查下級的同級的操作。

可以看出,節點的插入也是深度優先。值得註意的是,這壹整個插入的流程並沒有真的將DOM插入到真實的頁面上,它只是在操作fiber上的stateNode。真實的插入DOM操作發生在commit階段。

節點插入源碼

下面是插入節點算法的源碼,可以對照上面的過程來看。

  appendAllChildren = function(
    parent: Instance,
    workInProgress: Fiber,
    needsVisibilityToggle: boolean,
    isHidden: boolean,
  ) {
    // 找到當前節點的子fiber節點
    let node = workInProgress.child;
    // 當存在子節點時,去往下遍歷
    while (node !== null) {
      if (node.tag === HostComponent || node.tag === HostText) {
        // 子節點是原生DOM 節點,直接可以插入
        appendInitialChild(parent, node.stateNode);
      } else if (enableFundamentalAPI && node.tag === FundamentalComponent) {
        appendInitialChild(parent, node.stateNode.instance);
      } else if (node.tag === HostPortal) {
        // 如果是HostPortal類型的節點,什麽都不做
      } else if (node.child !== null) {
        // 代碼執行到這,說明node不符合插入要求,
        // 繼續尋找子節點
        node.child.return = node;
        node = node.child;
        continue;
      }
      if (node === workInProgress) {
        return;
      }
      // 當不存在兄弟節點時往上找,此過程發生在當前completeWork節點的子節點再無子節點的場景,
      // 並不是直接從當前completeWork的節點去往上找
      while (node.sibling === null) {
        if (node.return === null || node.return === workInProgress) {
          return;
        }
        node = node.return;
      }
      // 當不存在子節點時,從sibling節點入手開始找
      node.sibling.return = node.return;
      node = node.sibling;
    }
  };

DOM屬性的處理

上面的插入過程完成了DOM樹的構建,這之後要做的就是為每個DOM節點計算它自己的屬性(props)。由於節點存在創建和更新兩種情況,所以對屬性的處理也會區別對待。

屬性的創建

屬性的創建相對更新來說比較簡單,這個過程發生在DOM節點構建的最後,調用finalizeInitialChildren函數完成新節點的屬性設置。

if (current !== null && workInProgress.stateNode != null) {
    // 更新
} else {
    ...
    // 創建、插入DOM節點的過程
    ...

    // DOM節點屬性的初始化
    if (
      finalizeInitialChildren(
        instance,
        type,
        newProps,
        rootContainerInstance,
        currentHostContext,
      )
     ) {
       // 最終會依據textarea的autoFocus屬性
       // 來決定是否更新fiber
       markUpdate(workInProgress);
     }
}

finalizeInitialChildren最終會調用setInitialProperties,來完成屬性的設置。過程好理解,主要就是調用setInitialDOMProperties將屬性直接設置進DOM節點(事件在這個階段綁定)

function setInitialDOMProperties(
  tag: string,
  domElement: Element,
  rootContainerElement: Element | Document,
  nextProps: Object,
  isCustomComponentTag: boolean,
): void {
  for (const propKey in nextProps) {
    const nextProp = nextProps[propKey];
    if (propKey === STYLE) {
      // 設置行內樣式
      setValueForStyles(domElement, nextProp);
    } else if (propKey === DANGEROUSLY_SET_INNER_HTML) {
      // 設置innerHTML
      const nextHtml = nextProp ? nextProp[HTML] : undefined;
      if (nextHtml != null) {
        setInnerHTML(domElement, nextHtml);
      }
    }
     ...
     else if (registrationNameDependencies.hasOwnProperty(propKey)) {
      // 綁定事件
      if (nextProp != null) {
        ensureListeningTo(rootContainerElement, propKey);
      }
    } else if (nextProp != null) {
      // 設置其余屬性
      setValueForProperty(domElement, propKey, nextProp, isCustomComponentTag);
    }
  }
}

屬性的更新

若對已有DOM節點進行更新,說明只對屬性進行更新即可,因為節點已經存在,不存在刪除和新增的情況。updateHostComponent函數負責HostComponent對應DOM節點屬性的更新,代碼不多很好理解。

  updateHostComponent = function(
    current: Fiber,
    workInProgress: Fiber,
    type: Type,
    newProps: Props,
    rootContainerInstance: Container,
  ) {
    const oldProps = current.memoizedProps;
    // 新舊props相同,不更新
    if (oldProps === newProps) {
      return;
    }

    const instance: Instance = workInProgress.stateNode;
    const currentHostContext = getHostContext();

    // prepareUpdate計算新屬性
    const updatePayload = prepareUpdate(
      instance,
      type,
      oldProps,
      newProps,
      rootContainerInstance,
      currentHostContext,
    );

    // 最終新屬性被掛載到updateQueue中,供commit階段使用
    workInProgress.updateQueue = (updatePayload: any);

    if (updatePayload) {
      // 標記workInProgress節點有更新
      markUpdate(workInProgress);
    }
  };

可以看出它只做了壹件事,就是計算新的屬性,並掛載到workInProgress節點的updateQueue中,它的形式是以2為單位,index為偶數的是key,為奇數的是value:

[ 'style', { color: 'blue' }, title, '測試標題' ]

這個結果由diffProperties計算產生,它對比lastProps和nextProps,計算出updatePayload。

舉個例子來說,有如下組件,div上綁定的點擊事件會改變它的props。

class PropsDiff extends React.Component {
    state = {
        title: '更新前的標題',
        color: 'red',
        fontSize: 18
    }
    onClickDiv = () => {
        this.setState({
            title: '更新後的標題',
            color: 'blue'
        })
    }
    render() {
        const { color, fontSize, title } = this.state
        return <div
            className="test"
            onClick={this.onClickDiv}
            title={title}
            style={{color, fontSize}}
            {...this.state.color === 'red' && { props: '自定義舊屬性' }}
        >
            測試div的Props變化
        </div>
    }
}

lastProps和nextProps分別為

lastProps
{
  "className": "test",
  "title": "更新前的標題",
  "style": { "color": "red", "fontSize": 18},
  "props": "自定義舊屬性",
  "children": "測試div的Props變化",
  "onClick": () => {...}
}

nextProps
{
  "className": "test",
  "title": "更新後的標題",
  "style": { "color":"blue", "fontSize":18 },
  "children": "測試div的Props變化",
  "onClick": () => {...}
}

它們有變化的是propsKey是style、title、props,經過diff,最終打印出來的updatePayload為

[
   "props", null,
   "title", "更新後的標題",
   "style", {"color":"blue"}
]

diffProperties內部的規則可以概括為:

若有某個屬性(propKey),它在

  • lastProps中存在,nextProps中不存在,將propKey的value標記為null表示刪除
  • lastProps中不存在,nextProps中存在,將nextProps中的propKey和對應的value添加到updatePayload
  • lastProps中存在,nextProps中也存在,將nextProps中的propKey和對應的value添加到updatePayload

對照這個規則看壹下源碼:

export function diffProperties(
  domElement: Element,
  tag: string,
  lastRawProps: Object,
  nextRawProps: Object,
  rootContainerElement: Element | Document,
): null | Array<mixed> {

  let updatePayload: null | Array<any> = null;

  let lastProps: Object;
  let nextProps: Object;

  ...

  let propKey;
  let styleName;
  let styleUpdates = null;

  for (propKey in lastProps) {
    // 循環lastProps,找出需要標記刪除的propKey
    if (
      nextProps.hasOwnProperty(propKey) ||
      !lastProps.hasOwnProperty(propKey) ||
      lastProps[propKey] == null
    ) {
      // 對propKey來說,如果nextProps也有,或者lastProps沒有,那麽
      // 就不需要標記為刪除,跳出本次循環繼續判斷下壹個propKey
      continue;
    }
    if (propKey === STYLE) {
      // 刪除style
      const lastStyle = lastProps[propKey];
      for (styleName in lastStyle) {
        if (lastStyle.hasOwnProperty(styleName)) {
          if (!styleUpdates) {
            styleUpdates = {};
          }
          styleUpdates[styleName] = '';
        }
      }
    } else if(/*...*/) {
      ...
      // 壹些特定種類的propKey的刪除
    } else {
      // 將其他種類的propKey標記為刪除
      (updatePayload = updatePayload || []).push(propKey, null);
    }
  }
  for (propKey in nextProps) {
    // 將新prop添加到updatePayload
    const nextProp = nextProps[propKey];
    const lastProp = lastProps != null ? lastProps[propKey] : undefined;
    if (
      !nextProps.hasOwnProperty(propKey) ||
      nextProp === lastProp ||
      (nextProp == null && lastProp == null)
    ) {
      // 如果nextProps不存在propKey,或者前後的value相同,或者前後的value都為null
      // 那麽不需要添加進去,跳出本次循環繼續處理下壹個prop
      continue;
    }
    if (propKey === STYLE) {
      /*
      * lastProp: { color: 'red' }
      * nextProp: { color: 'blue' }
      * */
      // 如果style在lastProps和nextProps中都有
      // 那麽需要刪除lastProps中style的樣式
      if (lastProp) {
        // 如果lastProps中也有style
        // 將style內的樣式屬性設置為空
        // styleUpdates = { color: '' }
        for (styleName in lastProp) {
          if (
            lastProp.hasOwnProperty(styleName) &&
            (!nextProp || !nextProp.hasOwnProperty(styleName))
          ) {
            if (!styleUpdates) {
              styleUpdates = {};
            }
            styleUpdates[styleName] = '';
          }
        }
        // 以nextProp的屬性名為key設置新的style的value
        // styleUpdates = { color: 'blue' }
        for (styleName in nextProp) {
          if (
            nextProp.hasOwnProperty(styleName) &&
            lastProp[styleName] !== nextProp[styleName]
          ) {
            if (!styleUpdates) {
              styleUpdates = {};
            }
            styleUpdates[styleName] = nextProp[styleName];
          }
        }
      } else {
        // 如果lastProps中沒有style,說明新增的
        // 屬性全部可放入updatePayload
        if (!styleUpdates) {
          if (!updatePayload) {
            updatePayload = [];
          }
          updatePayload.push(propKey, styleUpdates);
          // updatePayload: [ style, null ]
        }
        styleUpdates = nextProp;
        // styleUpdates = { color: 'blue' }
      }
    } else if (/*...*/) {
      ...
      // 壹些特定種類的propKey的處理
    } else if (registrationNameDependencies.hasOwnProperty(propKey)) {
      if (nextProp != null) {
        // 重新綁定事件
        ensureListeningTo(rootContainerElement, propKey);
      }
      if (!updatePayload && lastProp !== nextProp) {
        // 事件重新綁定後,需要賦值updatePayload,使這個節點得以被更新
        updatePayload = [];
      }
    } else if (
      typeof nextProp === 'object' &&
      nextProp !== null &&
      nextProp.$$typeof === REACT_OPAQUE_ID_TYPE
    ) {
      // 服務端渲染相關
      nextProp.toString();
    } else {
       // 將計算好的屬性push到updatePayload
      (updatePayload = updatePayload || []).push(propKey, nextProp);
    }
  }
  if (styleUpdates) {
    // 將style和值push進updatePayload
    (updatePayload = updatePayload || []).push(STYLE, styleUpdates);
  }
  console.log('updatePayload', JSON.stringify(updatePayload));
  // [ 'style', { color: 'blue' }, title, '測試標題' ]
  return updatePayload;
}

DOM節點屬性的diff為workInProgress節點掛載了帶有新屬性的updateQueue,壹旦節點的updateQueue不為空,它就會被標記上Update的effectTag,commit階段會處理updateQueue。

if (updatePayload) {
  markUpdate(workInProgress);
}

effect鏈的收集

經過beginWork和上面對於DOM的操作,有變化的workInProgress節點已經被打上了effectTag。

壹旦workInProgress節點持有了effectTag,說明它需要在commit階段被處理。每個workInProgress節點都有壹個firstEffect和lastEffect,是壹個單向鏈表,來表示它自身以及它的子節點上所有持有effectTag的workInProgress節點。completeWork階段在向上遍歷的過程中也會逐層收集effect鏈,最終收集到root上,供接下來的commit階段使用。

實現上相對簡單,對於某個workInProgress節點來說,先將它已有的effectList並入到父級節點,再判斷它自己有沒有effectTag,有的話也並入到父級節點。

 /*
* effectList是壹條單向鏈表,每完成壹個工作單元上的任務,
* 都要將它產生的effect鏈表並入
* 上級工作單元。
* */
// 將當前節點的effectList並入到父節點的effectList
if (returnFiber.firstEffect === null) {
  returnFiber.firstEffect = completedWork.firstEffect;
}
if (completedWork.lastEffect !== null) {
  if (returnFiber.lastEffect !== null) {
    returnFiber.lastEffect.nextEffect = completedWork.firstEffect;
  }
  returnFiber.lastEffect = completedWork.lastEffect;
}

// 將自身添加到effect鏈,添加時跳過NoWork 和
// PerformedWork的effectTag,因為真正
// 的commit用不到
const effectTag = completedWork.effectTag;

if (effectTag > PerformedWork) {
  if (returnFiber.lastEffect !== null) {
    returnFiber.lastEffect.nextEffect = completedWork;
  } else {
    returnFiber.firstEffect = completedWork;
  }
  returnFiber.lastEffect = completedWork;
}

每個節點都會執行這樣的操作,最終當回到root的時候,root上會有壹條完整的effectList,包含了所有需要處理的fiber節點。

錯誤處理

completeUnitWork中的錯誤處理是錯誤邊界機制的組成部分。

錯誤邊界是壹種React組件,壹旦類組件中使用了getDerivedStateFromErrorcomponentDidCatch,就可以捕獲發生在其子樹中的錯誤,那麽它就是錯誤邊界。

回到源碼中,節點如果在更新的過程中報錯,它就會被打上Incomplete的effectTag,說明節點的更新工作未完成,因此不能執行正常的completeWork,要走另壹個判斷分支進行處理。

if ((completedWork.effectTag & Incomplete) === NoEffect) {

} else {
  // 有Incomplete的節點會進入到這個判斷分支進行錯誤處理
}

Incomplete從何而來

什麽情況下節點會被標記上Incomplete呢?這還要從最外層的工作循環說起。

concurrent模式的渲染函數:renderRootConcurrent之中在構建workInProgress樹時,使用了try...catch來包裹執行函數,這對處理報錯節點提供了機會。

do {
    try {
      workLoopConcurrent();
      break;
    } catch (thrownValue) {
      handleError(root, thrownValue);
    }
  } while (true);

壹旦某個節點執行出錯,會進入handleError函數處理。該函數中可以獲取到當前出錯的workInProgress節點,除此之外我們暫且不關註其他功能,只需清楚它調用了throwException

throwException會為這個出錯的workInProgress節點打上Incomplete 的 effectTag,表明未完成,在向上找到可以處理錯誤的節點(即錯誤邊界),添加上ShouldCapture 的 effectTag。另外,創建代表錯誤的update,getDerivedStateFromError放入payload,componentDidCatch放入callback。最後這個update入隊節點的updateQueue。

throwException執行完畢,回到出錯的workInProgress節點,執行completeUnitOfWork,目的是將錯誤終止到當前的節點,因為它本身都出錯了,再向下渲染沒有意義。

function handleError(root, thrownValue):void {
  ...

  // 給當前出錯的workInProgress節點添加上 Incomplete 的effectTag
  throwException(
    root,
    erroredWork.return,
    erroredWork,
    thrownValue,
    workInProgressRootRenderLanes,
  );

  // 開始對錯誤節點執行completeWork階段
  completeUnitOfWork(erroredWork);

  ...

}

重點:從發生錯誤的節點往上找到錯誤邊界,做記號,記號就是ShouldCapture 的 effectTag。

錯誤邊界再次更新

當這個錯誤節點進入completeUnitOfWork時,因為持有了Incomplete,所以不會進入正常的complete流程,而是會進入錯誤處理的邏輯。

錯誤處理邏輯做的事情:

  • 對出錯節點執行unwindWork
  • 將出錯節點的父節點(returnFiber)標記上Incomplete,目的是在父節點執行到completeUnitOfWork的時候,也能被執行unwindWork,進而驗證它是否是錯誤邊界。
  • 清空出錯節點父節點上的effect鏈。

這裏的重點是unwindWork會驗證節點是否是錯誤邊界,來看壹下unwindWork的關鍵代碼:

function unwindWork(workInProgress: Fiber, renderLanes: Lanes) {
  switch (workInProgress.tag) {
    case ClassComponent: {

      ...

      const effectTag = workInProgress.effectTag;
      if (effectTag & ShouldCapture) {
        // 刪它上面的ShouldCapture,再打上DidCapture
        workInProgress.effectTag = (effectTag & ~ShouldCapture) | DidCapture;

        return workInProgress;
      }
      return null;
    }
    ...
    default:
      return null;
  }
}

unwindWork驗證節點是錯誤邊界的依據就是節點上是否有剛剛throwException的時候打上的ShouldCapture的effectTag。如果驗證成功,最終會被return出去。return出去之後呢?會被賦值給workInProgress節點,我們往下看壹下錯誤處理的整體邏輯:

if ((completedWork.effectTag & Incomplete) === NoEffect) {

    // 正常流程
    ...

} else {
  // 驗證節點是否是錯誤邊界
  const next = unwindWork(completedWork, subtreeRenderLanes);

  if (next !== null) {
    // 如果找到了錯誤邊界,刪除與錯誤處理有關的effectTag,
    // 例如ShouldCapture、Incomplete,
    // 並將workInProgress指針指向next
    next.effectTag &= HostEffectMask;
    workInProgress = next;
    return;
  }

  // ...省略了React性能分析相關的代碼

  if (returnFiber !== null) {
    // 將父Fiber的effect list清除,effectTag標記為Incomplete,
    // 便於它的父節點再completeWork的時候被unwindWork
    returnFiber.firstEffect = returnFiber.lastEffect = null;
    returnFiber.effectTag |= Incomplete;
  }
}

...
// 繼續向上completeWork的過程
completedWork = returnFiber;

現在我們要有個認知,壹旦unwindWork識別當前的workInProgress節點為錯誤邊界,那麽現在的workInProgress節點就是這個錯誤邊界。然後會刪除掉與錯誤處理有關的effectTag,DidCapture會被保留下來。

  if (next !== null) {
    next.effectTag &= HostEffectMask;
    workInProgress = next;
    return;
  }

重點:將workInProgress節點指向錯誤邊界,這樣可以對錯誤邊界重新走更新流程。

這個時候workInProgress節點有值,並且跳出了completeUnitOfWork,那麽繼續最外層的工作循環:

function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

此時,workInProgress節點,也就是錯誤邊界,它會再被performUnitOfWork處理,然後進入beginWork、completeWork!

也就是說它會被重新更新壹次。為什麽說再被更新呢?因為構建workInProgress樹的時候,beginWork是從上往下的,當時workInProgress指針指向它的時候,它只執行了beginWork。此時子節點出錯導致向上completeUnitOfWork的時候,發現了他是錯誤邊界,workInProgress又指向了它,所以它會再次進行beginWork。不同的是,這次節點上持有了
DidCapture的effectTag。所以流程上是不壹樣的。

還記得throwException階段入隊錯誤邊界更新隊列的表示錯誤的update嗎?它在此次beginWork調用processUpdateQueue的時候,會被處理。這樣保證了getDerivedStateFromErrorcomponentDidCatch的調用,然後產生新的state,這個state表示這次錯誤的狀態。

錯誤邊界是類組件,在beginWork階段會執行finishClassComponent,如果判斷組件有DidCapture,會卸載掉它所有的子節點,然後重新渲染新的子節點,這些子節點有可能是經過錯誤處理渲染的備用UI。

示例代碼來自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;
  }
}

對於上述情況來說,壹旦ErrorBoundary的子樹中有某個節點發生了錯誤,組件中的getDerivedStateFromErrorcomponentDidCatch就會被觸發,
此時的備用UI就是:

<h1>Something went wrong.</h1>

流程梳理

上面的錯誤處理我們用圖來梳理壹下,假設<Example/>具有錯誤處理的能力。

  1              App
                  |
                  |
  2           <Example/>
                /
               /
  3 --->   <List/>--->span
            /
           /
  4       p ----> 'text'
         /
        /
  5    h1

1.如果<List/>更新出錯,那麽首先throwException會給它打上Incomplete的effectTag,然後以它的父節點為起點向上找到可以處理錯誤的節點。

2.找到了<Example/>,它可以處理錯誤,給他打上ShouldCapture的effectTag(做記號),創建錯誤的update,將getDerivedStateFromError放入payload,componentDidCatch放入callback。
,入隊<Example/>的updateQueue。

3.從<List/>開始直接completeUnitOfWork。由於它有Incomplete,所以會走unwindWork,然後給它的父節點<Example/>打上Incomplete,unwindWork發現它不是剛剛做記號的錯誤邊界,
繼續向上completeUnitOfWork

4.<Example/>有Incomplete,進入unwindWork,而它恰恰是剛剛做過記號的錯誤邊界節點,去掉ShouldCapture打上DidCapture,將workInProgress的指針指向<Example/>

5.<Example/>重新進入beginWork處理updateQueue,調和子節點(卸載掉原有的子節點,渲染備用UI)。

我們可以看出來,React的錯誤邊界的概念其實是對可以處理錯誤的組件重新進行更新。錯誤邊界只能捕獲它子樹的錯誤,而不能捕獲到它自己的錯誤,自己的錯誤要靠它上面的錯誤邊界來捕獲。
我想這是由於出錯的組件已經無法再渲染出它的子樹,也就意味著它不能渲染出備用UI,所以即使它捕獲到了自己的錯誤也於事無補。

這壹點在throwException函數中有體現,是從它的父節點開始向上找錯誤邊界:

// 從當前節點的父節點開始向上找
let workInProgress = returnFiber;

do {
  ...
} while (workInProgress !== null);

回到completeWork,它在整體的錯誤處理中做的事情就是對錯誤邊界內的節點進行處理:

  • 檢查當前節點是否是錯誤邊界,是的話將workInProgress指針指向它,便於它再次走壹遍更新。
  • 置空節點上的effectList。

以上我們只是分析了壹般場景下的錯誤處理,實際上在任務掛起(Suspense)時,也會走錯誤處理的邏輯,因為此時throw的錯誤值是個thenable對象,具體會在分析suspense時詳細解釋。

總結

workInProgress節點的completeWork階段主要做的事情再來回顧壹下:

  • 真實DOM節點的創建以及掛載
  • DOM屬性的處理
  • effectList的收集
  • 錯誤處理

雖然用了不少的篇幅去講錯誤處理,但是仍然需要重點關註正常節點的處理過程。completeWork階段處在beginWork之後,commit之前,起到的是壹個承上啟下的作用。它接收到的是經過diff後的fiber節點,然後他自己要將DOM節點和effectList都準備好。因為commit階段是不能被打斷的,所以充分準備有利於commit階段做更少的工作。

壹旦workInProgress樹的所有節點都完成complete,則說明workInProgress樹已經構建完成,所有的更新工作已經做完,接下來這棵樹會進入commit階段,從下壹篇文章開始,我們會分析commit階段的各個過程。


尚未有邦友留言

立即登入留言