剖析 React 源碼:組件更新流程二(diff 策略)

這是個人剖析 React 源碼的第六篇文章。這篇文章鏈接上篇,將會帶着你們學習組件更新過程相關的內容,而且儘量的脫離源碼來了解原理,下降你們的學習難度。html

文章相關資料

這篇文章你能學到什麼?

文章分爲三部分,在這部分的文章中你能夠學習到以下內容:react

  • 調和的過程

三篇文章並無強相關性,固然仍是推薦閱讀下 前一篇文章git

調和的過程

組件更新歸結到底仍是 DOM 的更新。對於 React 來講,這部分的內容會分爲兩個階段:github

  1. 調和階段,基本上也就是你們熟知的虛擬 DOM 的 diff 算法
  2. 提交階段,也就是將上一個階段中 diff 出來的內容體現到 DOM 上

這一小節的內容將會集中在調和階段,提交階段這部分的內容將會在下一篇文章中寫到。另外你們所熟知的虛擬 DOM 的 diff 算法在新版本中其實已經徹底被重寫了。算法

這一小節的內容會有點難度,若是你以爲難以讀懂個人文章或者是別的問題,歡迎在下方評論區與我互動! 數據結構

有個例子能更好地幫助理解,咱們就經過如下組件的更新來了解整個調和的過程。函數

class Test extends React.Component {
  state = {
    data: [{ key: 1, value: 1 }, { key: 2, value: 2 }]
  };
  componentDidMount() {
    setTimeout(() => {
      const data = [{ key: 0, value: 0 }, { key: 2, value: 2 }]
      this.setState({
        data
      })
    }, 3000);
  }
  render() {
    const { data } = this.state;
    return (
      <> { data.map(item => <p key={item.key}>{item.value}</p>) } </> ) } } 複製代碼

在前一篇文章中咱們瞭解到了整個更新過程(不包括渲染)就是在反覆尋找工做單元並運行它們,那麼具體體現到代碼中是怎麼樣的呢?學習

while (nextUnitOfWork !== null && !shouldYield()) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
複製代碼

上述代碼的 while 循環只有當找不到工做單元或者應該打斷的時候纔會終止。找不到工做單元的狀況只有當循環完全部工做單元纔會觸發,打斷的狀況是調度器觸發的。動畫

當更新任務開始時,root 永遠是第一個工做單元,不管以前有沒有被打斷過工做。this

循環尋找工做單元的這個流程其實很簡單,就是自頂向下再向上的一個循環。這個循環的規則以下:

  1. root 永遠是第一個工做單元,無論以前有沒有被打斷過任務
  2. 首先判斷當前節點是否存在第一個子節點,存在的話它就是下一個工做單元,並讓下一個工做節點繼續執行該條規則,不存在的話就跳到規則 3
  3. 判斷當前節點是否存在兄弟節點。若是存在兄弟節點,就回到規則 2,不然跳到規則 4
  4. 回到父節點並判斷父節點是否存在。若是存在則執行規則 3,不然跳到規則 5
  5. 當前工做單元爲 null,即爲完成整個循環

如下動畫是例子代碼的工做循環過程的一個示例:

瞭解了工做循環流程之後,咱們就來深刻學習一下工做單元是如何工做的。爲了精簡流程,咱們就直接認爲當前的工做單元爲 Test 組件實例。

在工做單元工做的這一階段中實際上是分爲不少分支的,由於涉及到不一樣類型組件及 DOM 的處理。Testclass 組件,另外這也是最經常使用的組件類型,所以接下來的內容會着重介紹 class 組件的調和過程。

class 組件的調和過程大體分爲兩個部分:

  1. 生命週期函數的處理
  2. 調和子組件,也就是 diff 算法的過程

處理 class 組件生命週期函數

最早被處理的生命週期函數是 componentWillReceiveProps

可是觸發這個函數的條件有兩個:

  1. props 先後有差異
  2. 沒有使用 getDerivedStateFromProps 或者 getSnapshotBeforeUpdate 這兩個新的生命週期函數。使用其一則 componentWillReceiveProps 不會被觸發

知足以上條件該函數就會被調用。所以該函數在 React 16 中已經不被建議使用。由於調和階段是有可能會打斷的,所以該函數會重複調用。

凡是在調和階段被調用的函數基本是不被建議使用的。

接下來須要處理 getDerivedStateFromProps 函數來獲取最新的 state

而後就是判斷是否須要更新組件了,這一塊的判斷邏輯分爲兩塊:

  1. 判斷是否存在 shouldComponentUpdate 函數,存在就調用
  2. 不存在上述函數的話,就判斷當前組件是否繼承自 PureComponent。若是是的話,就淺比較先後的 propsstate 得出結果

若是得出結論須要更新組件的話,那麼就會先調用 componentWillUpdate 函數,而後處理 componentDidUpdategetSnapshotBeforeUpdate 函數。

這裏須要注意的是:調和階段並不會調用以上兩個函數,而是打上 tag 以便未來使用位運算知曉是否須要使用它們。effectTag 這個屬性在整個更新的流程中都是相當重要的一員,凡是涉及到函數的延遲調用、devTool 的處理、DOM 的更新均可能會使用到它。

if (typeof instance.componentDidUpdate === 'function') {
    workInProgress.effectTag |= Update;
}
if (typeof instance.getSnapshotBeforeUpdate === 'function') {
    workInProgress.effectTag |= Snapshot;
}
複製代碼

調和子組件

處理完生命週期後,就會調用 render 函數獲取新的 child,用於在以後與老的 child 進行對比。

在繼續學習以前咱們先來熟悉三個對象,由於它們在後續的內容中會反覆出現:

  • returnFiber:父組件。
  • currentFirstChild:父組件的第一個 child。若是你還記得 fiber 的數據結構的話,應該知道每一個 fiber 都有一個 sibling 屬性指向它的兄弟節點。所以知道第一個子節點就能知道全部的同級節點。
  • newChild:也就是咱們剛剛 render 出來的內容。

首先咱們會判斷 newChild 的類型,知道類型就能夠進行相應的 diff 策略了。它可能會是一個 Fragment 類型,也多是 objectnumber 或者 string 類型。這幾個類型都會有相應的處理,但這不是咱們的重點,而且它們的處理也至關簡單。

咱們的重點會放在可迭代類型上,也就是 Array 或者 Iterator 類型。這二者的核心邏輯是一致的,所以咱們就只講對 Array 類型的處理了。

如下內容是對於 diff 算法的詳解,雖然有三次 for 循環,可是本質上只是遍歷了一次整個 newChild

正餐開始,第一輪遍歷

第一輪遍歷的核心邏輯是複用和當前節點索引一致的老節點,一旦出現不能複用的狀況就跳出遍歷。

那麼如何複用以前的節點呢?規則以下:

  • 新舊節點都爲文本節點,能夠直接複用,由於文本節點不須要 key
  • 其餘類型節點一概經過判斷 key 是否相同來複用或建立節點(可能類型不一樣但 key 相同)

如下是我簡化後的第一輪遍歷代碼:

for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
  // 找到下一個老的子節點
  nextOldFiber = oldFiber.sibling;
  // 經過 oldFiber 和 newChildren[newIdx] 判斷是否能夠複用
  // 並給複用出來的節點的 return 屬性賦值 returnFiber
  const newFiber = reuse(
    returnFiber,
    oldFiber,
    newChildren[newIdx]
  );
  // 不能複用,跳出
  if (newFiber === null) {
    break;
  }
}
複製代碼

那麼回到上文中的例子中,咱們老的第一個節點的 key 爲 1,新的節點的 key 爲 0。key 不相同不能複用,所以直接跳出循環,此時 newIdx 仍 爲 0。

第二輪遍歷

當第一輪遍歷結束後,會出現兩種狀況:

  • newChild 已經遍歷完
  • 老的節點已經遍歷完了

當出現 newChild 已經遍歷完的狀況時只須要把全部剩餘的老節點都刪除便可。刪除的邏輯也就是設置 effectTagDeletion,另外還有幾個 fiber 節點屬性須要說起下。

當出現須要在渲染階段進行處理的節點時,會把這些節點放入父節點的 effect 鏈表中,好比須要被刪除的節點就會把加入進鏈表。這個鏈表的做用是能夠幫助咱們在渲染階段迅速找到須要更新的節點。

當出現老的節點已經遍歷完了的狀況時,就會開始第二輪遍歷。這輪遍歷的邏輯很簡單,只須要把剩餘新的節點所有建立完畢便可。

這輪遍歷在咱們的例子中是不會執行的,由於咱們以上兩種狀況都不符合。

第三輪遍歷

第三輪遍歷的核心邏輯是找出能夠複用的老節點並移動位置,不能複用的話就只能建立一個新的了。

那麼問題又再次回到瞭如何複用節點並移動位置上。首先咱們會把全部剩餘的老節點都丟到一個 map 中。

咱們例子中的代碼剩餘的老節點爲:

<p key={1}>1</p>
<p key={2}>2</p>
複製代碼

那麼這個 map 的結構就會是這樣的:

// 節點的 key 做爲 map 的 key
// 若是節點不存在 key,那麼 index 爲 key
const map = {
    1: {},
    2: {}
}
複製代碼

在遍歷的過程當中會尋找新的節點的 key 是否存在於這個 map 中,存在便可複用,不存在就只能建立一個新的了。其實這部分的複用及建立的邏輯和第一輪中的是如出一轍的,因此也就再也不贅述了。

那麼若是複用成功,就應該把複用的 keymap 中刪掉,而且給複用的節點移動位置。這裏的移動依舊不涉及 DOM 操做,而是給 effectTag 賦值爲 Placement

此輪遍歷結束後,就把還存在於 map 中的全部老節點刪除。

小結

以上就是 diff 子節點的所有邏輯,對比 React 15 的 diff 策略而言我的認爲代碼好懂了許多。

最後

閱讀源碼是一個很枯燥的過程,可是收益也是巨大的。若是你在閱讀的過程當中有任何的問題,都歡迎你在評論區與我交流。

另外寫這系列是個很耗時的工程,須要維護代碼註釋,還得把文章寫得儘可能讓讀者看懂,最後還得配上畫圖,若是你以爲文章看着還行,就請不要吝嗇你的點贊。

最後,以爲內容有幫助能夠加羣一同交流與學習。

相關文章
相關標籤/搜索