深入理解React Diff演算法

nero發表於2021-01-19

點選進入React原始碼除錯倉庫。

上一篇扒一扒React計算狀態的原理 之後,我們來分析一下Diff的過程。

fiber上的updateQueue經過React的一番計算之後,這個fiber已經有了新的狀態,也就是state,對於類元件來說,state是在render函式裡被使用的,既然已經得到了新的state,那麼當務之急是執行一次render,得到持有新state的ReactElement。

假設render一次之後得到了大量的ReactElement,而這些ReactElement之中若只有少量需要更新的節點,那麼顯然不能全部去更新它們,此時就需要有一個diff過程來決定哪些節點是真正需要更新的。

原始碼結構

我們以類元件為例,state的計算髮生在類元件對應的fiber節點beginWork中的updateClassInstance函式中,在狀態計算完畢之後,緊跟著就是去調finishClassComponent執行diff、打上effectTag(即新版本的flag)。

打上effectTag可以標識這個fiber發生了怎樣的變化,例如:新增(Placement)、更新(Update)、刪除(Deletion),這些被打上flag的fiber會在complete階段被收集起來,形成一個effectList連結串列,只包含這些需要操作的fiber,最後在commit階段被更新掉。
function updateClassComponent(
   current: Fiber | null, workInProgress: Fiber, Component: any, nextProps: any, renderLanes: Lanes,) {
   ...
   // 計算狀態
   shouldUpdate = updateClassInstance(
     current,
     workInProgress,
     Component,
     nextProps,
     renderLanes,
   );
   
   ...
   
   // 執行render,進入diff,為fiber打上effectTag
   const nextUnitOfWork = finishClassComponent(
     current, 
     workInProgress,
     Component,
     shouldUpdate,
     hasContext,
     renderLanes,
     );
     return nextUnitOfWork;
 }

finishClassComponent函式中,呼叫reconcileChildFibers去做diff,而reconcileChildFibers實際上就是ChildReconciler,這是diff的核心函式,
該函式針對元件render生成的新節點的型別,呼叫不同的函式進行處理。

function ChildReconciler(shouldTrackSideEffects) {
 
   ...
   function reconcileSingleElement(
      returnFiber: Fiber,
      currentFirstChild: Fiber | null,
      element: ReactElement,
      lanes: Lanes,
   ): Fiber {
     // 單節點diff
   }
   
  function reconcileChildrenArray(
     returnFiber: Fiber,
     currentFirstChild: Fiber | null,
     newChildren: Array<*>,
     lanes: Lanes,
  ): Fiber | null {
    // 多節點diff
  }
   
   ...
 
   function reconcileChildFibers(
     returnFiber: Fiber,
     currentFirstChild: Fiber | null,
     newChild: any, lanes: Lanes,
   ): Fiber | null {
     const isObject = typeof newChild === 'object' && newChild !== null;
     if (isObject) {
       // 處理單節點
       switch (newChild.$$typeof) {
         case REACT_ELEMENT_TYPE:
           return placeSingleChild(
             reconcileSingleElement(
             returnFiber,
             currentFirstChild,
             newChild,
             lanes,
           ),
        );
        
        case REACT_PORTAL_TYPE:
        ...
        
        case REACT_LAZY_TYPE:
        ...
      
      }
    }
    if (typeof newChild === 'string' || typeof newChild === 'number') {
      // 處理文字節點
    }
    if (isArray(newChild)) {
      // 處理多節點
      return reconcileChildrenArray(
        returnFiber,
        currentFirstChild,
        newChild,
        lanes,
     );
   }
   
   ...
   
 }
 
 return reconcileChildFibers;
 
}

Diff的主體

關於Diff的參與者,在reconcileChildren函式的入參中可以看出

workInProgress.child = reconcileChildFibers(
 workInProgress,
 current.child,
 nextChildren,
 renderLanes,
 );
  • workInProgress:作為父節點傳入,新生成的第一個fiber的return會被指向它。
  • current.child:舊fiber節點,diff生成新fiber節點時會用新生成的ReactElement和它作比較。
  • nextChildren:新生成的ReactElement,會以它為標準生成新的fiber節點。
  • renderLanes:本次的渲染優先順序,最終會被掛載到新fiber的lanes屬性上。

可以看出,diff的兩個主體是:oldFiber(current.child)和newChildren(nextChildren,新的ReactElement),它們是兩個不一樣的資料結構。

比如現在有元件<Example/>,它計算完新的狀態之後,要基於這兩個東西去做diff,分別是現有fiber樹中(current樹)<Example/>對應fiber的所有子fiber節點<Example/>的render函式的執行結果,即那些ReactElements

<Example/>對應fiber的所有子fiber節點:oldFiber

 current樹中
 <Example/> fiber | | A --sibling---> B --sibling---> C

<Example/>的render函式的執行結果,newChildren

 current fiber 對應的元件render的結果
 [
    {$$typeof: Symbol(react.element), type: "div", key: "A" },
    {$$typeof: Symbol(react.element), type: "div", key: "B" }, 
    {$$typeof: Symbol(react.element), type: "div", key: "B" },
 ]

Diff的基本原則

對於新舊兩種結構來說,場景有節點自身更新、節點增刪、節點移動三種情況。面對複雜的情況,即使最前沿的演算法,複雜度也極高。面對這種情況,React以如下策略應對:

  • 即使兩個元素的子樹完全一樣,但前後的父級元素不同,依照規則div元素及其子樹會完全銷燬,並重建一個p元素及其子樹,不會嘗試複用子樹。
舊
<div>
 <span>a</span>
 <span>b</span>
</div>

新
<p>
 <span>a</span>
 <span>b</span>
</p>
  • 使用tag(標籤名)和 key識別節點,區分出前後的節點是否變化,以達到儘量複用無變化的節點。
舊
<p key="a">aa</p>
<h1 key="b">bb</h1>

新
<h1 key="b">bb</h1>
<p key="a">aa</p>

因為tag 和 key的存在,所以React可以知道這兩個節點只是位置發生了變化。

場景

上面說到diff演算法應對三種場景:節點更新、節點增刪、節點移動,但一個fiber的子元素有可能是單節點,也有可能是多節點。所以依據這兩類節點可以再細分為:

  • 單節點更新、單節點增刪。
  • 多節點更新、多節點增刪、多節點移動。

什麼是節點的更新呢?對於DOM節點來說,在前後的節點型別(tag)和key都相同的情況下,節點的屬性發生了變化,是節點更新。若前後的節點tag或者key不相同,Diff演算法會認為新節點和舊節點毫無關係。

以下例子中,key為b的新節點的className發生了變化,是節點更新。

舊
<div className={'a'} key={'a'}>aa</div>
<div className={'b'} key={'b'}>bb</div>

新
<div className={'a'} key={'a'}>aa</div>
<div className={'bcd'} key={'b'}>bb</div>

以下例子中,新節點的className雖然有變化,但key也變化了,不屬於節點更新

舊
<div className={'a'} key={'a'}>aa</div>
<div className={'b'} key={'b'}>bb</div>

新
<div className={'a'} key={'a'}>aa</div>
<div className={'bcd'} key={'bbb'}>bb</div>

以下例子中,新節點的className雖然有變化,但tag也變化了,不屬於節點更新

舊
<div className={'a'} key={'a'}>aa</div>
<div className={'b'} key={'b'}>bb</div>

新
<div className={'a'} key={'a'}>aa</div>
<p className={'bcd'} key={'b'}>bb</p>

下面來分開敘述一下單節點和多節點它們各自的更新策略。

單節點

若元件產出的元素是如下的型別:

<div key="a">aa</div>

那麼它最終產出的ReactElement為下面這樣(省略了一些與diff相關度不大的屬性)

{
   $$typeof: Symbol(react.element), type: "div", key: "a" 
   ...
}

單節點指newChildren為單一節點,但是oldFiber的數量不一定,所以實際有如下三種場景:

為了降低理解成本,我們用簡化的節點模型來說明問題,字母代表key。

  • 單箇舊節點
舊: A
新: A
  • 多箇舊節點
舊: A - B - C
新: B
  • 沒有舊節點
舊: --
新: A

對於單節點的diff,其實就只有更新操作,不會涉及位移和位置的變化,單節點的更新會呼叫reconcileSingleElement函式處理。該函式中對以上三種場景都做了覆蓋。但實際上面的情況對於React來說只是兩種,oldFiber鏈是否為空。因此,在實現上也只處理了這兩種情況。

oldFiber鏈不為空

遍歷它們,找到key相同的節點,然後刪除剩下的oldFiber節點,再用匹配的oldFiber,newChildren中新節點的props來生成新的fiber節點。

   function reconcileSingleElement(
     returnFiber: Fiber,
     currentFirstChild: Fiber | null,
     element: ReactElement,
     lanes: Lanes
   ): Fiber {
     const key = element.key;
     let child = currentFirstChild;
     while (child !== null) {
        if (child.key === key) {
          switch (child.tag) {
            case Fragment:
            ...
            
            case Block:
            ...
            
            default: {
              if (child.elementType === element.type) {
                 // 先刪除剩下的oldFiber節點
                deleteRemainingChildren(returnFiber, child.sibling);
                // 基於oldFiber節點和新節點的props新建新的fiber節點
                const existing = useFiber(child, element.props);
                existing.ref = coerceRef(returnFiber, child, element);
                existing.return = returnFiber; return existing;
              }
              break;
            }
         }
         
        deleteRemainingChildren(returnFiber, child);
        break;
      } else {
        // 沒匹配到說明新的fiber節點無法從oldFiber節點新建
        // 刪除掉所有oldFiber節點
        deleteChild(returnFiber, child);
     }
     child = child.sibling;
   }
   
 ...
 
 }

oldFiber鏈為空

對於沒有oldFiber節點的情況,只能新建newFiber節點。邏輯不復雜。

   function reconcileSingleElement(
     returnFiber: Fiber,
     currentFirstChild: Fiber | null,
     element: ReactElement,
     lanes: Lanes
   ): Fiber {
     const key = element.key;
     let child = currentFirstChild;
     while (child !== null) {
     
        // oldFiber鏈非空的處理
        ...
     } if (element.type === REACT_FRAGMENT_TYPE) {
        // 處理Fragment型別的節點
        ... 
     } else {
        // 用產生的ReactElement新建一個fiber節點
        const created = createFiberFromElement(element, returnFiber.mode, lanes);
        created.ref = coerceRef(returnFiber, currentFirstChild, element);
        created.return = returnFiber;
        return created;
     }
   }

單節點的更新就是這樣的處理,真正比較複雜的情況是多節點的diff。因為它涉及到節點的增刪和位移。

多節點

若元件最終產出的DOM元素是如下這樣:

<div key="a">aa</div>
<div key="b">bb</div>
<div key="c">cc</div>
<div key="d">dd</div>

那麼最終的newChildren為下面這樣(省略了一些與diff相關度不大的屬性)

[
 {$$typeof: Symbol(react.element), type: "div", key: "a" },
 {$$typeof: Symbol(react.element), type: "div", key: "b" },
 {$$typeof: Symbol(react.element), type: "div", key: "c" },
 {$$typeof: Symbol(react.element), type: "div", key: "d" }
]

多節點的變化有以下四種可能性。

  • 節點更新
舊: A - B - C
新: `A - B - C`
  • 新增節點
舊: A - B - C
新: A - B - C - `D - E`
  • 刪除節點
舊: A - B - C - `D - E`
新: A - B - C
  • 節點移動
舊: A - B - C - D - E
新: A - B - `D - C - E`

多節點的情況一定是屬於這四種情況的任意組合,這種情況會呼叫reconcileChildrenArray進行diff。按照以上四種情況,它會以newChildren為主體進行最多三輪遍歷,但這三輪遍歷並不是相互獨立的,事實上只有第一輪是從頭開始的,之後的每一輪都是上輪結束的斷點繼續。實際上在平時的實踐中,節點自身的更新是最多的,所以Diff演算法會優先處理更新的節點。因此四輪遍歷又可以按照場景分為兩部分:

第一輪是針對節點自身屬性更新,剩下的兩輪依次處理節點的新增、移動,而重點又在移動節點的處理上,所以本文會著重講解節點更新和節點移動的處理,對刪除和新增簡單帶過。

節點更新

第一輪從頭開始遍歷newChildren,會逐個與oldFiber鏈中的節點進行比較,判斷節點的key或者tag是否有變化。

  • 沒變則從oldFiber節點clone一個props被更新的fiber節點,新的props來自newChildren中的新節點,這樣就實現了節點更新。
  • 有變化說明不滿足複用條件,立即中斷遍歷進入下邊的遍歷。Diff演算法的複雜度也因為這個操作大幅降低。
let newIdx = 0;
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
   ...
   // 更新節點,對於DOM節點來說,updateSlot內部會判斷
   // key 和 tag。任意一個不同,則返回null
   const newFiber = updateSlot( returnFiber,
     oldFiber,
     newChildren[newIdx],
     lanes,
   );
   // newFiber為null則說明當前的節點不是更新的場景,中止這一輪迴圈
   if (newFiber === null) {
     if (oldFiber === null) {
        oldFiber = nextOldFiber;
     }
     break;
   }
    ...
 }

我們來看一個例子,假設新舊的節點如下:

舊: A - B - C - D - E
新: A - B - D - C

在本輪遍歷中,會遍歷A - B - D - C。A和B都是key沒變的節點,可以直接複用,但當遍歷到D時,發現key變化了,跳出當前遍歷。例子中A 和 B是自身發生更新的節點,後面的D 和 C我們看到它的位置相對於oldFiber鏈發生了變化,會往下走到處理移動節點的迴圈中。

關於移動節點的參照物

為了方便說明,把保留在原位的節點稱為固定節點。經過這次迴圈的處理,可以看出固定節點是A 和 B。在newChildren中,最靠右的固定節點的位置至關重要,對於後續的移動節點的處理來說,它的意義是提供參考位置。所以,每當處理到最後一個固定節點時,要記住此時它的位置,這個位置就是lastPlacedIndex。關鍵程式碼如下:

let newIdx = 0;
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
 ...
 // 跳出邏輯
 
 ...
 // 如果不跳出,記錄最新的固定節點的位置
 lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
 
 ...}

placeChild方法實際上是移動節點的方法,但當節點無需移動的時候,會返回當前節點的位置,對於固定節點來說,因為無需移動,所以返回的就是固定節點的index。

節點刪除

我們沒有提到對刪除節點的處理,實際上刪除節點比較簡單。

舊: A - B - C - D - E
新: A - B - C

因為遍歷的是newChildren,當它遍歷結束,但oldFiber鏈還沒有遍歷完,那麼說明剩下的節點都要被刪除。直接在oldFiber節點上標記Deletion的effectTag來實現刪除。

if (newIdx === newChildren.length) {
   // 新子節點遍歷完,說明剩下的oldFiber都是沒用的了,可以刪除
   deleteRemainingChildren(returnFiber, oldFiber);
   return resultingFirstChild;
}

deleteRemainingChildren呼叫了deleteChild,值得注意的是,刪除不僅僅是標記了effectTag為Deletion,還會將這個被刪除的fiber節點新增到父級的effectList中。

function deleteChild(returnFiber: Fiber, childToDelete: Fiber): void {
   ...
   const last = returnFiber.lastEffect;
   // 將要刪除的child新增到父級fiber的effectList中,並新增上effectTag為刪除
   if (last !== null) {
     last.nextEffect = childToDelete;
     returnFiber.lastEffect = childToDelete;
   } else {
     returnFiber.firstEffect = returnFiber.lastEffect = childToDelete;
   }
   childToDelete.nextEffect = null;
   childToDelete.effectTag = Deletion;
}

節點新增

新增節點的場景也很好理解,當oldFiber鏈遍歷完,但newChildren還沒遍歷完,那麼餘下的節點都屬於新插入的節點,會新建fiber節點並以sibling為指標連成fiber鏈。

舊: A - B - C
新: A - B - C - D - E

插入的邏輯(省略了相關度不高的程式碼)

if (oldFiber === null) {
 // 舊的遍歷完了,意味著剩下的都是新增的了
 for (; newIdx < newChildren.length; newIdx++) { // 首先建立newFiber
    const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
    ...
    // 再將newFiber連線成以sibling為指標的單向連結串列
    if (previousNewFiber === null) {
        resultingFirstChild = newFiber;
    } else {
        previousNewFiber.sibling = newFiber;
    }
    previousNewFiber = newFiber;
  }
  return resultingFirstChild;
}

節點移動

節點的移動是如下場景:

舊 A - B - C - D - E - F
新 A - B - D - C - E

經過第一輪遍歷的處理,固定節點為A B,最新的固定節點的位置(lastPlacedIndex)為1(B的位置)。此時oldFiber鏈中還剩C - D - E - F,newChildren中還剩D - C - E。

接下來的邏輯對於位置不一樣的節點,它自己會先更新再移動。因為此時剩餘的節點位置變了,更新又要複用oldFiber節點,所以為了在更新時方便查詢,會將剩餘的oldFiber節點放入一個以key為鍵,值為oldFiber節點的map中。稱為existingChildren

由於newChildren 和 oldFiber節點都沒遍歷完,說明需要移動位置。此刻需要明確一點,就是這些節點都在最新的固定節點的右邊

移動的邏輯是:newChildren中剩餘的節點,都是不確定要不要移動的,遍歷它們,每一個都去看看這個節點在oldFiber鏈中的位置(舊位置),遍歷到的節點有它在newChildren中的位置(新位置):

如果舊位置在lastPlacedIndex的右邊,說明這個節點位置不變。

原因是舊位置在lastPlacedIndex的右邊,而新節點的位置也在它的右邊,所以它的位置沒變化。因為位置不變,所以它成了固定節點,把lastPlacedIndex更新成新位置。

如果舊位置在lastPlacedIndex的左邊,當前這個節點的位置要往右挪。

原因是舊位置在lastPlacedIndex的左邊,新位置卻在lastPlacedIndex的右邊,所以它要往右挪,但它不是固定節點。此時無需更新lastPlacedIndex。

我們來用上邊的例子過一下這部分邏輯。

舊 A - B - C - D - E - F
新 A - B - D - C - E

位置固定部分 A - B,最右側的固定節點為B,lastPlacedIndex為1。這時剩餘oldFiber鏈為C - D - E - F,existingChildren為

{
   C: '節點C',
   D: '節點D',
   E: '節點E',
   F: '節點F'
}

newChildren的剩餘部分D - C - E繼續遍歷。

首先遍歷到D,D在oldFiber鏈中(A - B - C - D - E)的位置為3

3 > 1,oldFiber中D的位置在B的右邊,newChildren中也是如此,所以D的位置不動,此時最新的固定節點變成了D,更新lastPlacedIndex為3。並從existingChildren中刪除D,

{
   C: '節點C',
   E: '節點E',
   F: '節點F'
}

再遍歷到C,C在oldFiber鏈中(A - B - C - D - E)的索引為2

2 < 3,C原來在最新固定節點(D)的左邊,newChildren中C在D的右邊,所以要給它移動到右邊。並從existingChildren中刪除C。

{
   E: '節點E',
   F: '節點F'
}

再遍歷到E,E在oldFiber鏈中(A - B - C - D - E)的位置為4

4 > 3,oldFiber鏈中E位置在D的位置的右邊,新位置中也是如此,所以E的位置不動,此時最新的固定節點變成了E,更新lastPlacedIndex為4。並從existingChildren中刪除E,

{
   F: '節點F'
}

這個時候newChildren都處理完了,針對移動節點的遍歷結束。此時還剩一個F節點,是在oldFiber鏈中的,因為newChildren都處理完了,所以將它刪除即可。

existingChildren.forEach(child => deleteChild(returnFiber, child));

可以看到,節點的移動是以最右側的固定節點位置作為參照的。這些固定節點是指位置未發生變化的節點。每次對比節點是否需要移動之後,及時更新固定節點非常重要。

原始碼

瞭解了上邊的多節點diff原理後,將上邊的關鍵點匹配到原始碼上更方便能進一步理解。下面放出帶有詳細註釋的原始碼。

 function reconcileChildrenArray(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    newChildren: Array<*>,
    lanes: Lanes,
): Fiber | null {
    /* * returnFiber:currentFirstChild的父級fiber節點
       * currentFirstChild:當前執行更新任務的WIP(fiber)節點
       * newChildren:元件的render方法渲染出的新的ReactElement節點
       * lanes:優先順序相關
    * */
    
    // resultingFirstChild是diff之後的新fiber連結串列的第一個fiber。
    let resultingFirstChild: Fiber | null = null;
    // resultingFirstChild是新連結串列的第一個fiber。
    // previousNewFiber用來將後續的新fiber接到第一個fiber之後
    let previousNewFiber: Fiber | null = null;
    
    // oldFiber節點,新的child節點會和它進行比較
    let oldFiber = currentFirstChild;
    // 儲存固定節點的位置
    let lastPlacedIndex = 0;
    // 儲存遍歷到的新節點的索引
    let newIdx = 0;
    // 記錄目前遍歷到的oldFiber的下一個節點
    let nextOldFiber = null;
    
    // 該輪遍歷來處理節點更新,依據節點是否可複用來決定是否中斷遍歷
    for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
        // newChildren遍歷完了,oldFiber鏈沒有遍歷完,此時需要中斷遍歷
        if (oldFiber.index > newIdx) {
            nextOldFiber = oldFiber; oldFiber = null;
        } else {
            // 用nextOldFiber儲存當前遍歷到的oldFiber的下一個節點
            nextOldFiber = oldFiber.sibling;
        }
        // 生成新的節點,判斷key與tag是否相同就在updateSlot中
        // 對DOM型別的元素來說,key 和 tag都相同才會複用oldFiber
        // 並返回出去,否則返回null
        const newFiber = updateSlot(
            returnFiber,
            oldFiber,
            newChildren[newIdx],
            lanes,
        );
        
        // newFiber為 null說明 key 或 tag 不同,節點不可複用,中斷遍歷
        if (newFiber === null) {
            if (oldFiber === null) {
            // oldFiber 為null說明oldFiber此時也遍歷完了
            // 是以下場景,D為新增節點
            // 舊 A - B - C 
            // 新 A - B - C - D oldFiber = nextOldFiber;
            }
            break;
        }
        if (shouldTrackSideEffects) {
            // shouldTrackSideEffects 為true表示是更新過程
            if (oldFiber && newFiber.alternate === null) {
                // newFiber.alternate 等同於 oldFiber.alternate 
                // oldFiber為WIP節點,它的alternate 就是 current節點
                // oldFiber存在,並且經過更新後的新fiber節點它還沒有current節點,
                // 說明更新後展現在螢幕上不會有current節點,而更新後WIP
                // 節點會稱為current節點,所以需要刪除已有的WIP節點
                deleteChild(returnFiber, oldFiber);
                }
            }
            // 記錄固定節點的位置
            lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
            // 將新fiber連線成以sibling為指標的單向連結串列
            if (previousNewFiber === null) {
                resultingFirstChild = newFiber;
            } else {
                previousNewFiber.sibling = newFiber;
            }
            previousNewFiber = newFiber;
            // 將oldFiber節點指向下一個,與newChildren的遍歷同步移動
            oldFiber = nextOldFiber;
         }
         
        // 處理節點刪除。新子節點遍歷完,說明剩下的oldFiber都是沒用的了,可以刪除.
        if (newIdx === newChildren.length) {
            // newChildren遍歷結束,刪除掉oldFiber鏈中的剩下的節點
            deleteRemainingChildren(returnFiber, oldFiber);
            return resultingFirstChild;
        }
        
        // 處理新增節點。舊的遍歷完了,能複用的都複用了,所以意味著新的都是新插入的了
        if (oldFiber === null) {
            for (; newIdx < newChildren.length; newIdx++) {
            
                // 基於新生成的ReactElement建立新的Fiber節點
                const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
                if (newFiber === null) {
                    continue;
                }
                // 記錄固定節點的位置lastPlacedIndex
                lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx); 
                // 將新生成的fiber節點連線成以sibling為指標的單向連結串列
                if (previousNewFiber === null) {
                    resultingFirstChild = newFiber;
                } else {
                    previousNewFiber.sibling = newFiber; 
                }
                previousNewFiber = newFiber;
            }
            return resultingFirstChild;
        }
        // 執行到這是都沒遍歷完的情況,把剩餘的舊子節點放入一個以key為鍵,值為oldFiber節點的map中
        // 這樣在基於oldFiber節點新建新的fiber節點時,可以通過key快速地找出oldFiber
        const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
        
        // 節點移動
        for (; newIdx < newChildren.length; newIdx++) {
            // 基於map中的oldFiber節點來建立新fiber
            const newFiber = updateFromMap( existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes, ); 
            if (newFiber !== null) {
                if (shouldTrackSideEffects) {
                    if (newFiber.alternate !== null) {
                        // 因為newChildren中剩餘的節點有可能和oldFiber節點一樣,只是位置換了,
                        // 但也有可能是是新增的.
                        
                        // 如果newFiber的alternate不為空,則說明newFiber不是新增的。
                        // 也就說明著它是基於map中的oldFiber節點新建的,意味著oldFiber已經被使用了,所以需
                        // 要從map中刪去oldFiber
                        existingChildren.delete(
                            newFiber.key === null ? newIdx : newFiber.key,
                        );
                     }
                  }
                  
                 // 移動節點,多節點diff的核心,這裡真正會實現節點的移動
                 lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
                 // 將新fiber連線成以sibling為指標的單向連結串列
                if (previousNewFiber === null) {
                    resultingFirstChild = newFiber;
                } else {
                    previousNewFiber.sibling = newFiber; 
                }
                previousNewFiber = newFiber;
            }
         }
        if (shouldTrackSideEffects) {
           // 此時newChildren遍歷完了,該移動的都移動了,那麼刪除剩下的oldFiber
           existingChildren.forEach(child => deleteChild(returnFiber, child));
        }
        return resultingFirstChild;
 }

總結

Diff演算法通過key和tag來對節點進行取捨,可直接將複雜的比對攔截掉,然後降級成節點的移動和增刪這樣比較簡單的操作。對oldFiber和新的ReactElement節點的比對,將會生成新的fiber節點,同時標記上effectTag,這些fiber會被連到workInProgress樹中,作為新的WIP節點。樹的結構因此被一點點地確定,而新的workInProgress節點也基本定型。這意味著,在diff過後,workInProgress節點的beginWork節點就完成了。接下來會進入completeWork階段。

歡迎掃碼關注公眾號,發現更多技術文章

相關文章