React diff 算法

前言

好久之前寫過一篇瞭解虛擬DOM 的文章,主要講解了vue爲何會使用虛擬 DOM 以及 VUE 的 diff 算法。最近技術棧遷移到了 React,就好好研究了一下 React diff 算法的實現。前端

React 版本號 16.9.0

React Fiber

在瞭解 React diff 算法以前,先了解 React Fiber 相關的知識,將有助於後面對 diff 算法中比對的理解。React 16版本以後推出了 Fiber 的概念,React Fiber是對核心算法的一次從新實現,本文主要是講 diff 算法,所以忽略分片、更新優先級這些概念,能夠簡單的將 Fiber理解爲 DOM 結構的 JS 映射。vue

例如以下的 React 代碼react

class App extends React.Component {

  state = {
    list: [{
      key: 'A',
      value: '我是 A'
    }, {
      key: 'B',
      value: '我是 B'
    }, {
      key: 'C',
      value: '我是 C'
    }, {
      key: 'D',
      value: '我是 D'
    }, {
      key: 'E',
      value: '我是 E'
    }]
  };


  btn2Click = () => {
    //
  }

  render () {
    const { list } = this.state;
    return (
    <div className="App">
      <span className="btn-2" onClick={this.btn2Click}>
        點擊調換順序
      </span>
      {
        list.map((item) => (<div key={item.key}>{item.value}</div>))
      }
    </div>
    )};
}

映射爲 React Fiber 的簡略表示爲算法

{ // FiberNode
  memoizedProps: {},
  memoizedState: {
    list: [{
      // 此處省略,是定義在 state 中的 list
    }]
  },
  // 在Fiber樹更新的過程當中,每一個Fiber都會有一個跟其對應的Fiber
  // 咱們稱他爲`current <==> workInProgress`
  // 在渲染完成以後他們會交換位置
  alternate: Fiber | null,
  child: { // FiberNode,爲div.App的第一個子節點
    elementType: "span",
    memoizedProps: {
      children: "點擊調換順序",
      className: "btn-2",
      onClick: () => { }
    },
    memoizedState: null,
    return: { // FiberNode 對應其父節點的FiberNode
      // ... 省略其它
      stateNode: 'div.App'
    },
    sibling: { // FiberNode,span.btn-2的下一個兄弟節點,此處爲一個虛擬的節點
      child: {
        elementType: "div",
        child: null,
        index: 0,
        key: "A",
        memoizedProps: {
          children: "我是 A",
        },
        return: {
          // 虛擬父節點
        },
        sibling: {
          elementType: "div",
          child: null,
          index: 0,
          key: "B",
          memoizedProps: {
            children: "我是 B",
          },
          return: {
            // 同 A,虛擬父節點
          },
          sibling: {
            // 省略,key 爲 C,其 sibing key 爲 D;D的 Sibing 爲 E,結束
          }
        }
      },
      elementType: null,
      memoizedProps: [{
        $$typeof: Symbol(react.element),
        key: "A",
        props: {
          children: "我是 A"
        },
        type: "div"
      }, {
        // 省略其它
      }],
      memoizedState: null,
      return: { // FiberNode 對應其父節點的FiberNode
        // ... 省略其它
        stateNode: 'div.App'
      }
    },
    stateNode: 'span.btn-2' // 實際是真正的span.btn-2 元素
  },
  sibling: null,
  stateNode: 'div.App' // 實際是真正的div.App 元素
}

其中截取了 span.btn-2的完整 FiberNode的表示
Xnip2020-05-26_18-45-33.jpgsegmentfault

使用圖來表示:app

Xnip2020-05-27_16-15-21.jpg

即:oop

  • 每一個 Fiber的 child 指向其第一個孩子節點,沒有孩子節點則爲 null
  • 每一個 Fiber的sibling 指向其下一個兄弟節點,沒有則爲 null
  • 每一個 Fiber的return 指向其父節點
  • 每一個 Fiber有一個 index 屬性表示其在兄弟節點中的排序
  • 每一個 Fiber的 stateNode 指向其原生節點
  • 每一個 Fiber有一個 key 屬性

React diff 算法

給定任意兩棵樹,找到最少的轉換步驟。可是標準的的Diff算法複雜度須要O(n^3)。考慮到前端操做的狀況--咱們不多跨級別的修改節點,虛擬DOM在比較時只比較同層次節點,其複雜度下降到了O(n). 並且比較時只比較其key和type是否相同,相同即爲相同節點ui

// 節選自 updateSlot
if (newChild.key === key) {
    // 節選自 updateElement
    if (current.elementType === element.type) {
       
    } else {
      // 直接建立新節點
    }
} else {
return null;
}

例子:下圖節點從左圖變爲右圖this

虛擬DOM的作法是spa

A.destroy(); 
A = new A(); 
A.append(new B()); 
A.append(new C()); 
D.append(A);

而不是

A.parent.remove(A);
D.append(A);

示例1

對於例子中的state 由[A, B, C, D, E] 變爲[A, F, B, C, D]的操做,diff 算法如何進行的處理?key 在這其中又扮演什麼以爲呢?

btn2Click = () => {
    this.setState({
      list: [{
        key: 'A',
        value: '我是 A'
      }, {
        key: 'F',
        value: '我是 F'
      }, {
        key: 'B',
        value: '我是 B'
      }, {
        key: 'C',
        value: '我是 C'
      }, {
        key: 'D',
        value: '我是 D'
      }]
    })
  }
  
 ...
 <span className="btn-2" onClick={this.btn2Click}>
    點擊調換順序
  </span>

若是按照常規的從頭至尾比較,第一個元素 A 相同,後面的元素依次比較,key 都不相同, 須要刪除 A 後面的 BCDE,再添加上FBCD. 原來的 BCD 節點徹底沒有被複用。React 是怎麼作的呢?

首先經過reconcileChildrenArray對節點進行組裝。

function placeChild(
    newFiber: Fiber,
    lastPlacedIndex: number,
    newIndex: number,
  ): number {
    newFiber.index = newIndex;
    const current = newFiber.alternate;
    // 標記 1-1
    if (current !== null) {
      // 標記 1-2
      const oldIndex = current.index;
      if (oldIndex < lastPlacedIndex) {
        // 標記 1-3 This is a move.
        newFiber.effectTag = Placement;
        return lastPlacedIndex;
      } else {
        // 標記 1-4 This item can stay in place.
        return oldIndex;
      }
    } else {
      // 標記 1-5 This is an insertion.
      newFiber.effectTag = Placement;
      return lastPlacedIndex;
    }
  }

function reconcileChildrenArray(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    newChildren: Array<*>,
    expirationTime: ExpirationTime,
  ): Fiber | null {
    let resultingFirstChild: Fiber | null = null;
    let previousNewFiber: Fiber | null = null;

    let oldFiber = currentFirstChild;
    let lastPlacedIndex = 0;
    let newIdx = 0;
    let nextOldFiber = null;
    // 標記 1
    for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
       // 標記 2
      if (oldFiber.index > newIdx) {
        nextOldFiber = oldFiber;
        oldFiber = null;
      } else {
        nextOldFiber = oldFiber.sibling;
      }
      // 標記 3
      // updateSlot 判斷newChildren[newIdx]與oldFiber的 key 和type 是否一致。若是不一致,返回 null,若是一致,返回return指向returnFiber的newFiber
      const newFiber = updateSlot(
        returnFiber,
        oldFiber,
        newChildren[newIdx],
        expirationTime,
      );
      // 標記 4
      if (newFiber === null) {
        // 沒有 children
        // 標記 5
        if (oldFiber === null) {
          oldFiber = nextOldFiber;
        }
        // 遍歷到 F 節點的時候,命中 break
        // 標記 6
        break;
      }
      // 標記 7
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
      // 標記 8
      if (previousNewFiber === null) {
        // 將新節點經過sibling進行鏈接
        resultingFirstChild = newFiber;
      } else {
        // 將新節點經過sibling進行鏈接
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
      oldFiber = nextOldFiber;
      // 標記 9
    }


    // 標記 10
    if (newIdx === newChildren.length) {
      // 刪除剩餘的舊的節點
      deleteRemainingChildren(returnFiber, oldFiber);
      return resultingFirstChild;
    }

    // 標記 11
    if (oldFiber === null) {
      // 建立新節點
      for (; newIdx < newChildren.length; newIdx++) {
        const newFiber = createChild(
          returnFiber,
          newChildren[newIdx],
          expirationTime,
        );
        if (newFiber === null) {
          continue;
        }
        lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
        if (previousNewFiber === null) {
          // TODO: Move out of the loop. This only happens for the first run.
          resultingFirstChild = newFiber;
        } else {
          previousNewFiber.sibling = newFiber;
        }
        previousNewFiber = newFiber;
      }
      return resultingFirstChild;
    }

    // 標記 12  Add all children to a key map for quick lookups.
    const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

    //  標記 13 Keep scanning and use the map to restore deleted items as moves.
    for (; newIdx < newChildren.length; newIdx++) {
    //  標記 14
      const newFiber = updateFromMap(
        existingChildren,
        returnFiber,
        newIdx,
        newChildren[newIdx],
        expirationTime,
      );
      if (newFiber !== null) {
          // 省略代碼,將 newFiber 從existingChildren中刪除掉
          //  標記 15
        lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
        //  標記 16
        if (previousNewFiber === null) {
          resultingFirstChild = newFiber;
        } else {
          previousNewFiber.sibling = newFiber;
        }
        previousNewFiber = newFiber;
      }
    }
    // 省略代碼,刪除剩餘的existingChildren
    //  標記 17
    return resultingFirstChild;
  }

按照以下流程:
image.png

image.png

image.png
image.png
Xnip2020-06-02_21-10-51.jpg
image.png

組裝爲如下結構:

// 新的第一個節點節選
{
    alternate: { // 舊的位置
        index: 0,
        key: "A",
        sibling: {
            //... 舊的 B 節點,sibling 爲 C 節點 等等
        }
    },
    index: 0,
    key: "A",
    effectTag: 0,
    sibling: {
        alternate: null, // 沒有舊的對應的節點,說明是新增
        index: 1,
        key: "F",
        effectTag: 2,
        sibling: {
            alternate: {
                index: 1,
                key: "B"
            },
            index: 2,
            key: "B",
            effectTag: 0, // 不須要挪位置
            sibling: {
                alternate: {
                    index: 2,
                    key: "C"
                },
                index: 3,
                key: "C",
                effectTag: 0,
                sibling: {
                    alternate: {
                        index: 3,
                        key: "D"
                    },
                    index: 4,
                    key: "D",
                    effectTag: 0,
                }
            }
        }
    }
  }
  
  // 更新節點,commitMutationEffects 中
nextEffect = {
    effectTag: 8, // 刪除
    key: E,
    nextEffect: {
        effectTag: 2, // 新增
        key: F
    }
}

所以,節點渲染的順序爲

  1. ABCD保持不動
  2. 刪除 E 節點
  3. 將 F 節點添加到 B 以前

總結來講:

  • 維護一個 lastPlacedIndexnewIndex
  • lastPlacedIndex = 0, newIndex爲按順序第一個與 old 節點 key 或者 type 不一樣的節點
  • 依次遍歷新節點。取出舊節點對應的oldIndex(若是舊節點中存在), oldIndex < lastPlacedIndex,則newFiber.effectTag = Placement,且lastPlacedIndex不變;不然,節點位置保持不變,將lastPlacedIndex = oldIndex

示例2

若是從[A, B, C, D, E]變爲[B, C, D, E, A], 則

nextEffect = {
    effectTag: 2, // 移動或添加。調用 appendChild,若是是已有節點,原生會操做移動
    key: A,
    nextEffect: null
}
  1. lastPlacedIndex = 0, newIndex = 0
  2. newFiber 遍歷 B 節點,oldIndex = 1 > lastPlacedIndex 不作操做,且 lastPlacedIndex = oldIndex = 1
  3. lastPlacedIndex = 1, newIndex = 1
  4. newFiber 遍歷 C 節點, oldIndex = 2 > lastPlacedIndex 不作操做,且 lastPlacedIndex = oldIndex = 2`
  5. lastPlacedIndex = 2, newIndex = 2
  6. newFiber 遍歷 D 節點, oldIndex = 3 > lastPlacedIndex 不作操做,且 lastPlacedIndex = oldIndex = 3
  7. E 同理...
  8. lastPlacedIndex = 3, newIndex = 3
  9. newFiber 遍歷 A 節點, oldIndex = 0 < lastPlacedIndex`,移動 A 節點

所以,節點渲染的順序爲

  1. 移動 A

示例3

若是從[A, B, C, D, E]變爲[E, A, B, C, D], 則

  1. lastPlacedIndex = 0, newIndex = 0
  2. newFiber 遍歷 E 節點,oldIndex = 4 > lastPlacedIndex 不作操做,且 lastPlacedIndex = oldIndex = 4
  3. lastPlacedIndex = 4, newIndex = 1
  4. newFiber 遍歷 A 節點,oldIndex = 0 < lastPlacedIndex 移動之, lastPlacedIndex保持不變
  5. lastPlacedIndex = 4, newIndex = 2
  6. B 節點 oldIndex 爲 1 < lastPlacedIndex, 同理,移動之
  7. lastPlacedIndex = 4, newIndex = 3
  8. C 節點 oldIndex 爲 2 < lastPlacedIndex, 同理,移動之
  9. D節點同理...
nextEffect = {
    effectTag: 2, // 移動或添加。調用 appendChild,若是是已有節點,原生會操做移動
    key: A,
    nextEffect: {
      effectTag: 2,
      key: B,
      nextEffect: {
          effectTag: 2,
          key: C,
          nextEffect: {
              effectTag: 2,
              key: D,
              nextEffect: null
          }
      }
    }
}
  1. 移動 A
  2. 移動 B
  3. 移動 C
  4. 移動 D

總結

  • diff 算法將 O(n3) 複雜度的問題轉換成 O(n)
  • key 的做用是爲了儘量的複用節點
  • 虛擬DOM的優點並不在於它操做DOM比較快,而是可以經過虛擬DOM的比較,最小化真實DOM操做,參考文檔
相關文章
相關標籤/搜索