React 原始碼解析系列 - React 的 render 階段(三):completeUnitOfWork

array_huang發表於2021-09-30

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

本系列文章均為討論 React v17.0.0-alpha 的原始碼

performUnitOfWork

回憶《React 原始碼解析系列 - React 的 render 階段(一):基本流程介紹》中介紹的 performUnitOfWork 方法:

function performUnitOfWork(unitOfWork: Fiber): void {
  const current = unitOfWork.alternate; // current樹上對應的Fiber節點,有可能為null
  // ...省略

  let next; // 用來存放beginWork()返回的結果
  next = beginWork(current, unitOfWork, subtreeRenderLanes);

  // ...省略
  unitOfWork.memoizedProps = unitOfWork.pendingProps;
  if (next === null) { // beginWork返回null,表示無(或無需關注)當前節點的子Fiber節點
    completeUnitOfWork(unitOfWork);
  } else {
    workInProgress = next; // 下次的workLoopSync/workLoopConcurrent的while迴圈的迴圈主體為子Fiber節點
  }

  // ...省略
}

作為 render 的“歸”階段,需在 render 的“遞”階段結束後才會執行;換句話說,當 beginWork 返回 null 值,即當前節點無(或無需關注)當前節點的子Fiber節點時,才會進入到 render 的“歸”階段 —— completeUnitOfWork

completeUnitOfWork

下面來看本文的主角 —— completeUnitOfWork 方法:

function completeUnitOfWork(unitOfWork: Fiber): void {
  /*
    完成對當前Fiber節點的一些處理
    處理完成後,若當前節點尚有sibling節點,則結束當前方法,進入到下一次的performUnitOfWork的迴圈中
    若已沒有sibling節點,則回退處理父節點(completedWork.return),
    直到父節點為null,表示整棵 workInProgress fiber 樹已處理完畢。
   */
  let completedWork = unitOfWork;
  do {
    const current = completedWork.alternate;
    const returnFiber = completedWork.return;

    if ((completedWork.effectTag & Incomplete) === NoEffect) {
      let next;
      // ...省略
      next = completeWork(current, completedWork, subtreeRenderLanes);
      // ...省略
      
      /*
        假如completeWork返回不為空,則進入到下一次的performUnitOfWork迴圈中
        但這種情況太罕見,目前我只看到Suspense相關會有返回,因此此程式碼段姑且認為不會執行
       */
      if (next !== null) {
        workInProgress = next;
        return;
      }

      // ...省略

      if (
        returnFiber !== null &&
        (returnFiber.effectTag & Incomplete) === NoEffect
      ) {
        /* 收集所有帶有EffectTag的子Fiber節點,以連結串列(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;
        }

        /* 如果當前Fiber節點(completedWork)也有EffectTag,那麼將其放在(EffectList中)子Fiber節點後面 */
        const effectTag = completedWork.effectTag;
        /* 跳過NoWork/PerformedWork這兩種EffectTag的節點,NoWork就不用解釋了,PerformedWork是給DevTools用的 */
        if (effectTag > PerformedWork) {
          if (returnFiber.lastEffect !== null) {
            returnFiber.lastEffect.nextEffect = completedWork;
          } else {
            returnFiber.firstEffect = completedWork;
          }
          returnFiber.lastEffect = completedWork;
        }
      }
    } else {
      // 異常處理,省略...
    }

    // 取當前Fiber節點(completedWork)的兄弟(sibling)節點;
    // 如果有值,則結束completeUnitOfWork,並將該兄弟節點作為下次performUnitOfWork的主體(unitOfWork)
    const siblingFiber = completedWork.sibling;
    if (siblingFiber !== null) {
      workInProgress = siblingFiber;
      return;
    }
    // 若沒有兄弟節點,則將在下次do...while迴圈中處理父節點(completedWork.return)
    completedWork = returnFiber;
    // 此處需要注意!
    // 雖然把workInProgress置為completedWork,但由於沒有return,即沒有結束completeUnitOfWork,因此沒有意義
    // 直到completedWork(此時實際上是本迴圈中原completedWork.return)為null,結束do...while迴圈後
    // 此時completeUnitOfWork的執行結果(workInProgress)為null
    // 也意味著performSyncWorkOnRoot/performConcurrentWorkOnRoot中的while迴圈也達到了結束條件
    workInProgress = completedWork;
  } while (completedWork !== null);

  // 省略...
}

請看流程圖:

react原始碼解析 - completeUnitOfWork流程圖

由流程圖可知, completeUnitOfWork 主要做了兩件事:執行 completeWork收攏 EffectList ,下面詳細介紹一下這兩塊內容。

completeWork

如果說“遞”階段的 beginWork 方法主要是建立子節點,那麼“歸”階段的 completeWork 方法則主要是建立當前節點的 DOM 節點,並對子節點的 DOM 節點和 EffectList 進行收攏。
類似 beginWork , completeWork 也會根據當前節點不同的 tag 型別執行不同的邏輯:

function completeWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  const newProps = workInProgress.pendingProps;

  switch (workInProgress.tag) {
    case IndeterminateComponent:
    case LazyComponent:
    case SimpleMemoComponent:
    case FunctionComponent:
    case ForwardRef:
    case Fragment:
    case Mode:
    case Profiler:
    case ContextConsumer:
    case MemoComponent:
      return null;
    case ClassComponent: {
      // ...省略
      return null;
    }
    case HostRoot: {
      // ...省略
      return null;
    }
    case HostComponent: {
      // ...省略
      return null;
    }
  // ...省略
}

需要注意的是,很多型別的節點是沒有 completeWork 這一塊的邏輯的(即啥操作都沒做就直接 return null),比如非常常見的 FragmentFunctionComponent 。我們重點關注頁面渲染所必須的 HostComponent ,即由 HTML 標籤(如 <div></div>)轉換成的 Fiber 節點。

處理 HostComponent

function completeWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  const newProps = workInProgress.pendingProps;

  switch (workInProgress.tag) {
    // ...省略
    case HostComponent: {
      // ...省略
      const type = workInProgress.type;
      if (current !== null && workInProgress.stateNode != null) {
        updateHostComponent(
          current,
          workInProgress,
          type,
          newProps,
          rootContainerInstance,
        );

        // ...省略
      } else {
        // ...省略
        const instance = createInstance(
          type,
          newProps,
          rootContainerInstance,
          currentHostContext,
          workInProgress,
        );

        appendAllChildren(instance, workInProgress, false, false);
        workInProgress.stateNode = instance;

        if (
          finalizeInitialChildren(
            instance,
            type,
            newProps,
            rootContainerInstance,
            currentHostContext,
          )
        ) {
          markUpdate(workInProgress);
        }
      }
      return null;
    }
    // ...省略
  }
}

從上面這個程式碼段我們可以得知, completeWork 方法對 HostComponent 的處理主要有兩個程式碼分支:

  • (current !== null && workInProgress.stateNode != null) === true 時,對當前節點做“更新”操作;
  • (current !== null && workInProgress.stateNode != null) === true 時,對當前節點做“新建”操作;

這裡之所以沒有用之前文章裡常用的 mount(首屏渲染) 和 update 來表達,是因為存在一種情況,是 current !== nullworkInProgress.stateNode === null 的:在 update 時,假如當前的 Fiber 節點是個新的節點,已經在 beginWork 階段被打上了 Placement effectTag ,那麼就會存在 stateNode 為 null 的情況;而在這種情況下,同樣需要做“新建”操作。

completeWork(處理 HostComponent 程式碼段)流程圖

HostComponent 的“更新”操作

在此程式碼分支中,由於已經判斷 workInProgress.stateNode !== null,即已存在對應的 DOM 節點,所以不需要再生成 DOM 節點。

我們可以看到這塊主要是執行了一個 updateHostComponent 方法:

updateHostComponent = function(
  current: Fiber,
  workInProgress: Fiber,
  type: Type,
  newProps: Props,
  rootContainerInstance: Container,
) {
  /* 假如props沒有變化(當前節點是通過bailoutOnAlreadyFinishedWork方法來複用的),可以跳過對當前節點的處理 */
  const oldProps = current.memoizedProps;
  if (oldProps === newProps) {
    return;
  }

  const instance: Instance = workInProgress.stateNode;
  // 省略...
  /* 計算需要變化的DOM節點屬性,以陣列方式儲存(陣列偶數索引的元素為屬性名,陣列基數索引的元素為屬性值) */
  const updatePayload = prepareUpdate(
    instance,
    type,
    oldProps,
    newProps,
    rootContainerInstance,
    currentHostContext,
  );
  // 將計算出來的updatePayload掛載在workInProgress.updateQueue上,供後續commit階段使用
  workInProgress.updateQueue = (updatePayload: any); 
  // 如果updatePayload不為空,則給當前節點打上Update的EffectTag
  if (updatePayload) {
    markUpdate(workInProgress);
  }
};

從上面的程式碼段可以看出 updateHostComponent 的主要作用就是計算出需要變化的 DOM 節點屬性,並給當前節點打上Update的EffectTag。

prepareUpdate

接下來我們看 prepareUpdate 方法是如何計算出需要變化的 DOM 節點屬性的:

export function prepareUpdate(
  domElement: Instance,
  type: string,
  oldProps: Props,
  newProps: Props,
  rootContainerInstance: Container,
  hostContext: HostContext,
): null | Array<mixed> {
  // 省略DEV程式碼...
  return diffProperties(
    domElement,
    type,
    oldProps,
    newProps,
    rootContainerInstance,
  );
}

可以看出 prepareUpdate 其實是直接呼叫了 diffProperties 方法。

diffProperties

diffProperties 方法的程式碼比較多,我這邊就不放原始碼了,大概講一下過程:

  1. 對特定 tag (由於本場景是處理 HostComponent ,因此 tag 即 html 標籤名)的 lastProps & nextProps 做特殊處理,包括 input/select/textarea ,舉例:input 的 value 值可能會是個 number ,而原生 input 的 value 只接受 string,因此這裡需要轉換資料型別。
  2. 遍歷 lastProps:

    1. 如果該 prop 在 nextProps 中也存在,那麼就跳過,相當於該 prop 沒有變化,無需處理。
    2. 見到有 style 的 prop 就整理到 styleUpdates 變數(object)中,這部分 style 屬性被置為空值
    3. 把除以上情況外的 propKey 推進一個陣列(updatePayload)中,另外再推一個 null 值進陣列中,表示把該 prop 清空掉。
  3. 遍歷 nextProps:

    1. 如果該 nextProp 與 lastProp 一致,即更新前後沒有發生變化,則跳過。
    2. 見到有 style 的 prop 就整理到 styleUpdates 變數中,注意這部分 style 屬性是有值的
    3. 處理 DANGEROUSLY_SET_INNER_HTML
    4. 處理 children
    5. 除以上場景外,直接把 prop 的 key 和值都推進陣列(updatePayload)中。
  4. 如果 styleUpdates 不為空,那麼就把'style'和 styleUpdates 變數都推進陣列(updatePayload)中。
  5. 返回 updatePayload。

updatePayload 是個陣列,其中陣列偶數索引的元素為 prop key ,陣列基數索引的元素為 prop value

markUpdate

接著來看 markUpdate 方法,該方法其實很簡單,就是在 workInProgress.effectTag 上打了個 Update EffectTag

function markUpdate(workInProgress: Fiber) {
  // Tag the fiber with an update effect. This turns a Placement into
  // a PlacementAndUpdate.
  workInProgress.effectTag |= Update;
}

HostComponent 的“新建”操作

“新建”操作的主要邏輯包括三個:

  • 為 Fiber 節點生成對應的 DOM 節點: createInstance 方法
  • 將子孫 DOM 節點插入剛生成的 DOM 節點中: appendAllChildren 方法
  • 初始化當前 DOM 節點的所有屬性以及事件回撥處理: finalizeInitialChildren 方法
createInstance

下面來看“為 Fiber 節點生成對應的 DOM 節點”的方法 —— createInstance

export function createInstance(
  type: string,
  props: Props,
  rootContainerInstance: Container,
  hostContext: HostContext,
  internalInstanceHandle: Object,
): Instance {
  let parentNamespace: string;
  // 省略DEV程式碼段...
  // 確定該DOM節點的名稱空間(xmlns屬性),一般是"http://www.w3.org/1999/xhtml"
  parentNamespace = ((hostContext: any): HostContextProd); 
  // 建立 DOM 元素
  const domElement: Instance = createElement(
    type,
    props,
    rootContainerInstance,
    parentNamespace,
  );
  // 在 DOM 物件上建立指向 fiber 節點物件的屬性(指標),方便後續取用
  precacheFiberNode(internalInstanceHandle, domElement);
  // 在 DOM 物件上建立指向 props 的屬性(指標),方便後續取用
  updateFiberProps(domElement, props);
  return domElement;
}

可以看出 createInstance 主要是呼叫了 createElement 方法來建立 DOM 元素;至於 createElement 本文不展開,有興趣可以看看原始碼

appendAllChildren

下面來看“將子孫 DOM 節點插入剛生成的 DOM 節點中”的方法 —— appendAllChildren :

// completeWork是這樣呼叫的:appendAllChildren(instance, workInProgress, false, false);

appendAllChildren = function(
  parent: Instance, // 相對於要append的子節點來說,completeWork當前處理的節點就是父節點
  workInProgress: Fiber,
  needsVisibilityToggle: boolean,
  isHidden: boolean,
) {
  let node = workInProgress.child; // 第一個子Fiber節點
  /* 這個while迴圈本質上是一個深度優先遍歷 */
  while (node !== null) {
    if (node.tag === HostComponent || node.tag === HostText) {
      // 如果是html標籤或純文字對應的子節點,則將當前子節點的DOM新增到父節點的DOM子節點列表末尾
      appendInitialChild(parent, node.stateNode);
    } else if (enableFundamentalAPI && node.tag === FundamentalComponent) { // 先忽略
      appendInitialChild(parent, node.stateNode.instance);
    } else if (node.tag === HostPortal) {
      // ...無操作
    } else if (node.child !== null) {
      // 針對一些特殊型別的子節點,如<Fragment />,嘗試從子節點的子節點獲取DOM
      node.child.return = node; // 設定好return指標,方便後續辨別是否達到迴圈結束條件
      node = node.child; // 迴圈主體由子節點變為子節點的子節點
      continue; // 立即開展新一輪的迴圈
    }
    if (node === workInProgress) {
      return; // 遍歷“迴歸時”發現已經達到遍歷的結束條件,結束遍歷
    }
    // 若當前迴圈主體node已無兄弟節點(sibling),則進行“迴歸”;且如果“迴歸”一次後發現還是沒有sibling,將繼續“迴歸”
    while (node.sibling === null) {
      if (node.return === null || node.return === workInProgress) {
        return; // “迴歸”過程中達到遍歷的結束條件,結束遍歷
      }
      node = node.return; // “迴歸”的結果:將node.return作為下次迴圈的主體
    }
    // 走到這裡就表明當前迴圈主體有sibling
    node.sibling.return = node.return; // 設定好return指標,方便後續辨別是否達到迴圈結束條件
    node = node.sibling; // 將node.sibling作為下次迴圈的主體
  }
};

// appendInitialChild本質上就是執行了appendChild這個原生的DOM節點方法
// https://developer.mozilla.org/zh-CN/docs/Web/API/Node/appendChild
export function appendInitialChild(parentInstance: Instance, child: Instance | TextInstance): void {
  parentInstance.appendChild(child);
}

appendAllChildren 本質上是一個有條件限制(限制遞進層次)的深度優先遍歷:

  1. 取出當前節點(parent)的第一個子節點作為迴圈主體(node)。
  2. 如果該迴圈主體是 html 標籤或純文字對應的 Fiber 節點,則將其 DOM appendChildparent
  3. 如果當前迴圈主體(node)有兄弟節點(node.sibling),則將該兄弟節點設為下次迴圈的主體。

光看上面這個流程,這不是一個典型的廣度優先遍歷嗎?別急,因為還有一種比較特殊的情況:噹噹前迴圈主體不是 html 標籤或純文字對應的 Fiber 節點,且當前迴圈主體有子節點(node.child)時,將當前迴圈主體的子節點作為下次迴圈的主體,並立即開始下次迴圈(continue)。

以下面這個元件作為例子:

function App() {
    return (
        <div>
            <b>1</b>
            <Fragment>
                <span>2</span>
                <p>3</p>
            </Fragment>
        </div>
    )
}

根據《React 原始碼解析系列 - React 的 render 階段(一):基本流程介紹》裡對 beginWork 和 completeWork 的執行順序可以得出:

1. rootFiber beginWork 
2. App Fiber beginWork 
3. div Fiber beginWork 
4. b Fiber beginWork 
5. b Fiber completeWork // 當前節點 —— <b />, appendChild 文字節點
6. Fragment Fiber beginWork
7. span Fiber beginWork
8. span Fiber completeWork // 當前節點 —— <span />, appendChild 文字節點
9. p Fiber beginWork
10. p Fiber completeWork  // 當前節點 —— <p />, appendChild 文字節點
11. Fragment Fiber completeWork // 跳過
12. div Fiber completeWork // 下面我們來重點介紹這一塊
13. App Fiber completeWork
14. rootFiber completeWork

我們來重點介紹 div 節點中的 appendAllChildren

  1. while 迴圈執行前初始化:取出 div 節點的第一個子節點 —— b 節點,作為第一次 while 迴圈的主體。
  2. 第一次 while 迴圈(迴圈主體為 b 節點):

    1. b 節點是一個 HostComponent ,直接 appendChild 。
    2. b 節點有一個兄弟節點,即 Fragment 節點,將其設定為下一次 while 迴圈的主體(node)。
  3. 第二次 while 迴圈(迴圈主體為 Fragment 節點):

    1. 由於 Fragment 節點既不是 HostComponent 也不是 HostText ,因此將取 Fragment 節點的第一個子節點 —— span 節點作為下次 while 迴圈的主體(node)。
    2. 立即進入(continue)下一次 while 迴圈。
  4. 第三次 while 迴圈(迴圈主體為 span 節點):

    1. span 節點是一個 HostComponent ,直接 appendChild 。
    2. span 節點有一個兄弟節點,即 p 節點,將其設定為下一次 while 迴圈的主體(node)。
  5. 第四次 while 迴圈(迴圈主體為 p 節點):

    1. p 節點是一個 HostComponent ,直接 appendChild 。
    2. p 節點沒有兄弟節點,進行迴歸(node = node.return),此時在該“迴歸”程式碼段 —— 一個小 while 迴圈中,迴圈主體變為 p 節點的父節點,即 Fragment 節點。
    3. 繼續下一次小 while 迴圈:由於 Fragment 也沒有兄弟節點,不滿足小 while 迴圈的結束條件,因此繼續進行“迴歸”,此時迴圈主體(node)為 div 節點。
    4. 繼續下一次小 while 迴圈:由於 div 節點滿足node.return === workInProgress,因此直接結束整個遍歷過程 —— appendAllChildren。
finalizeInitialChildren

下面來看“初始化當前 DOM 節點的所有屬性以及事件回撥處理” —— finalizeInitialChildren

export function finalizeInitialChildren(
  domElement: Instance,
  type: string,
  props: Props,
  rootContainerInstance: Container,
  hostContext: HostContext,
): boolean {
  setInitialProperties(domElement, type, props, rootContainerInstance);
  return shouldAutoFocusHostComponent(type, props);
}

從上面的程式碼段,我們可以很清晰地看到 finalizeInitialChildren 主要分為兩個步驟:

  1. 執行 setInitialProperties 方法;注意,該方法與 prepareUpdate 不一樣,該方法是會真正將 DOM 屬性掛載到 DOM 節點上的,也會真正地呼叫 addEventListener 把事件處理回撥繫結在當前 DOM 節點上的。
  2. 執行 shouldAutoFocusHostComponent 方法:返回 props.autoFocus 的值(僅 button / input / select / textarea 支援)。

收攏 EffectList

作為 DOM 操作的依據,commit 階段需要找到所有帶有 effectTag 的 Fiber 節點並依次執行effectTag 對應操作,難道還需要在 commit 階段再遍歷一次 Fiber 樹嗎?這顯然是很低效的。

為了解決這個問題,在 completeUnitOfWork 中,每個執行完 completeWork 且存在 effectTag 的 Fiber 節點會被儲存在一條被稱為 effectList 的單向連結串列中; effectList 中第一個 Fiber 節點儲存在 fiber.firstEffect ,最後一個元素儲存在 fiber.lastEffect 。

類似 appendAllChildren ,在“歸”階段,所有有 effectTag 的 Fiber 節點都會被追加在父節點的 effectList 中,最終形成一條以 rootFiber.firstEffect 為起點的單向連結串列。

如果當前 Fiber 節點(completedWork)也有 EffectTag ,那麼將其放在( EffectList 中)子 Fiber 節點的後面。

/* 如果父節點的effectList頭指標為空,那麼就直接把本節點的effectList頭指標賦給父節點的頭指標,相當於把本節點的整個effectList直接掛在父節點中 */
if (returnFiber.firstEffect === null) {
    returnFiber.firstEffect = completedWork.firstEffect;
}
/* 如果父節點的effectList不為空,那麼就把本節點的effectList掛載在父節點effectList的後面 */
if (completedWork.lastEffect !== null) {
    if (returnFiber.lastEffect !== null) {
    returnFiber.lastEffect.nextEffect = completedWork.firstEffect;
    }
    returnFiber.lastEffect = completedWork.lastEffect;
}

/* 如果當前Fiber節點(completedWork)也有EffectTag,那麼將其放在(EffectList中)子Fiber節點後面 */
const effectTag = completedWork.effectTag;
/* 跳過NoWork/PerformedWork這兩種EffectTag的節點,NoWork就不用解釋了,PerformedWork是給DevTools用的 */
if (effectTag > PerformedWork) {
  if (returnFiber.lastEffect !== null) {
     returnFiber.lastEffect.nextEffect = completedWork;
  } else {
     returnFiber.firstEffect = completedWork;
  }
     returnFiber.lastEffect = completedWork;
  }
}

completeUnitOfWork 結束

completeUnitOfWork 有兩種結束的場景:

  • 當前節點(completed)有兄弟節點(completed.sibling),此時會將 workInProgress(即 performUnitOfWork 的迴圈主體)設為該兄弟節點,然後結束掉 completeUnitOfWork 方法,此後將進行下一次 performUnitOfWork ,換句話說:執行該“兄弟節點”的“遞”階段 —— beginWork 。
  • 在 completeUnitOfWork “迴歸”的過程中, completed 的值為 null ,即當前已完成整棵 Fiber 樹的迴歸;此時, workInProgress 的值為 null ,這意味著 workLoopSync / workLoopConcurrent 方法中的 while 迴圈也到達了結束條件;至此, React 的 render 階段結束。

當 render 階段結束時,在 performSyncWorkOnRoot 方法中,會呼叫 commitRoot(root) (這裡的 root 傳參指的是 fiberRootNode )來開啟 React commit 階段的工作。

相關文章