React 16 Diff 算法

文章首發(fā)于個(gè)人博客

這是我 Deep In React 系列的第二篇文章,如果還沒有讀過的強(qiáng)烈建議你先讀第一篇:詳談 React Fiber 架構(gòu)(1)。

前言

我相信在看這篇文章的讀者一般都已經(jīng)了解過 React 16 以前的 Diff 算法了,這個(gè)算法也算是 React 跨時(shí)代或者說最有影響力的一點(diǎn)了,使 React 在保持了可維護(hù)性的基礎(chǔ)上性能大大的提高,但 Diff 過程不僅不是免費(fèi)的,而且對(duì)性能影響很大,有時(shí)候更新頁(yè)面的時(shí)候往往 Diff 所花的時(shí)間 js 運(yùn)行時(shí)間比 Rendering 和 Painting 花費(fèi)更多的時(shí)間,所以我一直傳達(dá)的觀念是 React 或者說框架的意義是為了提高代碼的可維護(hù)性,而不是為了提高性能的,現(xiàn)在所做的提升性能的操作,只是在可維護(hù)性的基礎(chǔ)上對(duì)性能的優(yōu)化。具體可以參考我公眾號(hào)以前發(fā)的這兩篇文章:

如果你對(duì)標(biāo)題不滿意,請(qǐng)把文章看完,至少也得把文章最后的結(jié)論好好看下

在上一篇將 React Fiber 架構(gòu)中,已經(jīng)說到過,React 現(xiàn)在將整體的數(shù)據(jù)結(jié)構(gòu)從樹改為了鏈表結(jié)構(gòu)。所以相應(yīng)的 Diff 算法也得改變,以為以前的 Diff 算法就是基于樹的。

老的 Diff 算法提出了三個(gè)策略來(lái)保證整體界面構(gòu)建的性能,具體是:

  1. Web UI 中 DOM 節(jié)點(diǎn)跨層級(jí)的移動(dòng)操作特別少,可以忽略不計(jì)。
  2. 擁有相同類的兩個(gè)組件將會(huì)生成相似的樹形結(jié)構(gòu),擁有不同類的兩個(gè)組件將會(huì)生成不同的樹形結(jié)構(gòu)。
  3. 對(duì)于同一層級(jí)的一組子節(jié)點(diǎn),它們可以通過唯一 id 進(jìn)行區(qū)分。

基于以上三個(gè)前提策略,React 分別對(duì) tree diff、component diff 以及 element diff 進(jìn)行算法優(yōu)化。

具體老的算法可以見這篇文章:React 源碼剖析系列 - 不可思議的 react diff

說實(shí)話,老的 Diff 算法還是挺復(fù)雜的,你僅僅看上面這篇文章估計(jì)一時(shí)半會(huì)都不能理解,更別說看源碼了。對(duì)于 React 16 的 Diff 算法(我覺得都不能把它稱作算法,最多叫個(gè) Diff 策略)其實(shí)還是蠻簡(jiǎn)單的,React 16 是整個(gè)調(diào)度流程感覺比較難,我在前面將 Fiber 的文章已經(jīng)簡(jiǎn)單的梳理過了,后面也會(huì)慢慢的逐個(gè)攻破。

接下來(lái)就開始正式的講解 React 16 的 Diff 策略吧!

Diff 簡(jiǎn)介

做 Diff 的目的就是為了復(fù)用節(jié)點(diǎn)。

鏈表的每一個(gè)節(jié)點(diǎn)是 Fiber,而不是在 16 之前的虛擬DOM 節(jié)點(diǎn)。

我這里說的虛擬 DOM 節(jié)點(diǎn)是指 React.createElement 方法所產(chǎn)生的節(jié)點(diǎn)。虛擬 DOM tree 只維護(hù)了組件狀態(tài)以及組件與 DOM 樹的關(guān)系,F(xiàn)iber Node 承載的東西比 虛擬 DOM 節(jié)點(diǎn)多很多。

Diff 就是新舊節(jié)點(diǎn)的對(duì)比,在上一篇中也說道了,這里面的 Diff 主要是構(gòu)建 currentInWorkProgress 的過程,同時(shí)得到 Effect List,給下一個(gè)階段 commit 做準(zhǔn)備。

React16 的 diff 策略采用從鏈表頭部開始比較的算法,是層次遍歷,算法是建立在一個(gè)節(jié)點(diǎn)的插入、刪除、移動(dòng)等操作都是在節(jié)點(diǎn)樹的同一層級(jí)中進(jìn)行的。

對(duì)于 Diff, 新老節(jié)點(diǎn)的對(duì)比,我們以新節(jié)點(diǎn)為標(biāo)準(zhǔn),然后來(lái)構(gòu)建整個(gè) currentInWorkProgress,對(duì)于新的 children 會(huì)有四種情況。

  • TextNode(包含字符串和數(shù)字)
  • 單個(gè) React Element(通過該節(jié)點(diǎn)是否有 $$typeof 區(qū)分)
  • 數(shù)組
  • 可迭代的 children,跟數(shù)組的處理方式差不多

那么我們就來(lái)一步一步的看這四種類型是如何進(jìn)行 diff 的。

前置知識(shí)介紹

這篇文章主要是從 React 的源碼的邏輯出發(fā)介紹的,所以介紹之前了解下只怎么進(jìn)入到這個(gè) diff 函數(shù)的,react 的 diff 算法是從 reconcileChildren 開始的

export function reconcileChildren(
  current: Fiber | null,
  workInProgress: Fiber,
  nextChildren: any,
  renderExpirationTime: ExpirationTime,
) {
  if (current === null) {
    workInProgress.child = mountChildFibers(
      workInProgress,
      null,
      nextChildren,
      renderExpirationTime,
    );
  } else {
    workInProgress.child = reconcileChildFibers(
      workInProgress,
      current.child,
      nextChildren,
      renderExpirationTime,
    );
  }
}

reconcileChildren 只是一個(gè)入口函數(shù),如果首次渲染,current 空 null,就通過 mountChildFibers 創(chuàng)建子節(jié)點(diǎn)的 Fiber 實(shí)例。如果不是首次渲染,就調(diào)用 reconcileChildFibers去做 diff,然后得出 effect list。

接下來(lái)再看看 mountChildFibers 和 reconcileChildFibers 有什么區(qū)別:

export const reconcileChildFibers = ChildReconciler(true);
export const mountChildFibers = ChildReconciler(false);

他們都是通過 ChildReconciler 函數(shù)來(lái)的,只是傳遞的參數(shù)不同而已。這個(gè)參數(shù)叫shouldTrackSideEffects,他的作用是判斷是否要增加一些effectTag,主要是用來(lái)優(yōu)化初次渲染的,因?yàn)槌醮武秩緵]有更新操作。

function reconcileChildFibers(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChild: any,
  expirationTime: ExpirationTime,
): Fiber | null {
  // 主要的 Diff 邏輯
}

reconcileChildFibers 就是 Diff 部分的主體代碼,這個(gè)函數(shù)超級(jí)長(zhǎng),是一個(gè)包裝函數(shù),下面所有的 diff 代碼都在這里面,詳細(xì)的源碼注釋可以見這里。

參數(shù)介紹

  • returnFiber 是即將 Diff 的這層的父節(jié)點(diǎn)。
  • currentFirstChild是當(dāng)前層的第一個(gè) Fiber 節(jié)點(diǎn)。
  • newChild 是即將更新的 vdom 節(jié)點(diǎn)(可能是 TextNode、可能是 ReactElement,可能是數(shù)組),不是 Fiber 節(jié)點(diǎn)
  • expirationTime 是過期時(shí)間,這個(gè)參數(shù)是跟調(diào)度有關(guān)系的,本系列還沒講解,當(dāng)然跟 Diff 也沒有關(guān)系。

再次提醒,reconcileChildFibers 是 reconcile(diff) 的一層。

前置知識(shí)介紹完畢,就開始詳細(xì)介紹每一種節(jié)點(diǎn)是如何進(jìn)行 Diff 的。

Diff TextNode

首先看 TextNode,因?yàn)樗亲詈?jiǎn)單的,擔(dān)心直接看到難的,然后就打擊你的信心。

看下面兩個(gè)小 demo:

// demo1:當(dāng)前 ui 對(duì)應(yīng)的節(jié)點(diǎn)的 jsx
return (
  <div>
  // ...
    <div>
        <xxx></xxx>
        <xxx></xxx>
    </div>
  //...
    </div>
)

// demo2:更新成功后的節(jié)點(diǎn)對(duì)應(yīng)的 jsx

return (
  <div>
  // ...
    <div>
        前端桃園
    </div>
  //...
    </div>
)

對(duì)應(yīng)的單鏈表結(jié)構(gòu)圖:

對(duì)于 diff TextNode 會(huì)有兩種情況。

  1. currentFirstNode 是 TextNode
  2. currentFirstNode 不是 TextNode

currentFirstNode 是當(dāng)前該層的第一個(gè)節(jié)點(diǎn),reconcileChildFibers 傳進(jìn)來(lái)的參數(shù)。

為什么要分兩種情況呢?原因就是為了復(fù)用節(jié)點(diǎn)

第一種情況。xxx 是一個(gè) TextNode,那么就代表這這個(gè)節(jié)點(diǎn)可以復(fù)用,有復(fù)用的節(jié)點(diǎn),對(duì)性能優(yōu)化很有幫助。既然新的 child 只有一個(gè) TextNode,那么復(fù)用節(jié)點(diǎn)之后,就把剩下的 aaa 節(jié)點(diǎn)就可以刪掉了,那么 div 的 child 就可以添加到 workInProgress 中去了。

源碼如下:

if (currentFirstChild !== null && currentFirstChild.tag === HostText) {
      // We already have an existing node so let's just update it and delete
      // the rest.
      deleteRemainingChildren(returnFiber, currentFirstChild.sibling);
      const existing = useFiber(currentFirstChild, textContent, expirationTime);
      existing.return = returnFiber;
      return existing;
}

在源碼里 useFiber 就是復(fù)用節(jié)點(diǎn)的方法,deleteRemainingChildren 就是刪除剩余節(jié)點(diǎn)的方法,這里是從 currentFirstChild.sibling 開始刪除的。

第二種情況。xxx 不是一個(gè) TextNode,那么就代表這個(gè)節(jié)點(diǎn)不能復(fù)用,所以就從 currentFirstChild開始刪掉剩余的節(jié)點(diǎn),對(duì)應(yīng)到上面的圖中就是刪除掉 xxx 節(jié)點(diǎn)和 aaa 節(jié)點(diǎn)。

對(duì)于源碼如下:

deleteRemainingChildren(returnFiber, currentFirstChild);
const created = createFiberFromText(
    textContent,
    returnFiber.mode,
    expirationTime,
);
created.return = returnFiber;

其中 createFiberFromText 就是根據(jù) textContent 來(lái)創(chuàng)建節(jié)點(diǎn)的方法。

注意:刪除節(jié)點(diǎn)不會(huì)真的從鏈表里面把節(jié)點(diǎn)刪除,只是打一個(gè) delete 的 tag,當(dāng) commit 的時(shí)候才會(huì)真正的去刪除。

Diff React Element

有了上面 TextNode 的 Diff 經(jīng)驗(yàn),那么來(lái)理解 React Element 的 Diff 就比較簡(jiǎn)單了,因?yàn)樗麄兊乃悸肥且恢碌模合日矣袥]有可以復(fù)用的節(jié)點(diǎn),如果沒有就另外創(chuàng)建一個(gè)。

那么就有一個(gè)問題,如何判斷這個(gè)節(jié)點(diǎn)是否可以復(fù)用呢?

有兩個(gè)點(diǎn):1. key 相同。 2. 節(jié)點(diǎn)的類型相同。

如果以上兩點(diǎn)相同,就代表這個(gè)節(jié)點(diǎn)只是變化了內(nèi)容,不需要?jiǎng)?chuàng)建新的節(jié)點(diǎn),可以復(fù)用的。

對(duì)應(yīng)的源碼如下:

if (child.key === key) {
  if (
    child.tag === Fragment
    ? element.type === REACT_FRAGMENT_TYPE
    : child.elementType === element.type
  ) {
    // 為什么要?jiǎng)h除老的節(jié)點(diǎn)的兄弟節(jié)點(diǎn)?
    // 因?yàn)楫?dāng)前節(jié)點(diǎn)是只有一個(gè)節(jié)點(diǎn),而老的如果是有兄弟節(jié)點(diǎn)是要?jiǎng)h除的,是多于的。刪掉了之后就可以復(fù)用老的節(jié)點(diǎn)了
    deleteRemainingChildren(returnFiber, child.sibling);
    // 復(fù)用當(dāng)前節(jié)點(diǎn)
    const existing = useFiber(
      child,
      element.type === REACT_FRAGMENT_TYPE
      ? element.props.children
      : element.props,
      expirationTime,
    );
    existing.ref = coerceRef(returnFiber, child, element);
    existing.return = returnFiber;
    return existing;
}

相信這些代碼都很好理解了,除了判斷條件跟前面 TextNode 的判斷條件不一樣,其余的基本都一樣,只是 React Element 多了一個(gè)跟新 ref 的過程。

同樣,如果節(jié)點(diǎn)的類型不相同,就將節(jié)點(diǎn)從當(dāng)前節(jié)點(diǎn)開始把剩余的都刪除。

deleteRemainingChildren(returnFiber, child);

到這里,可能你們就會(huì)覺得接下來(lái)應(yīng)該就是講解當(dāng)沒有可以復(fù)用的節(jié)點(diǎn)的時(shí)候是如果創(chuàng)建節(jié)點(diǎn)的。

不過可惜你們猜錯(cuò)了。因?yàn)?Facebook 的工程師很厲害,另外還做了一個(gè)工作來(lái)優(yōu)化,來(lái)找到復(fù)用的節(jié)點(diǎn)。

我們現(xiàn)在來(lái)看這種情況:

這種情況就是有可能更新的時(shí)候刪除了一個(gè)節(jié)點(diǎn),但是另外的節(jié)點(diǎn)還留著。

那么在對(duì)比 xxx 節(jié)點(diǎn)和 AAA 節(jié)點(diǎn)的時(shí)候,它們的節(jié)點(diǎn)類型是不一樣,按照我們上面的邏輯,還是應(yīng)該把 xxx 和 AAA 節(jié)點(diǎn)刪除,然后創(chuàng)建一個(gè) AAA 節(jié)點(diǎn)。

但是你看,明明 xxx 的 slibling 有一個(gè) AAA 節(jié)點(diǎn)可以復(fù)用,但是被刪了,多浪費(fèi)呀。所以還有另外有一個(gè)策略來(lái)找 xxx 的所有兄弟節(jié)點(diǎn)中有沒有可以復(fù)用的節(jié)點(diǎn)。

這種策略就是從 div 下面的所有子節(jié)點(diǎn)去找有沒有可以復(fù)用的節(jié)點(diǎn),而不是像 TextNode 一樣,只是找第一個(gè) child 是否可以復(fù)用,如果當(dāng)前節(jié)點(diǎn)的 key 不同,就代表肯定不是同一個(gè)節(jié)點(diǎn),所以把當(dāng)前節(jié)點(diǎn)刪除,然后再去找當(dāng)前節(jié)點(diǎn)的兄弟節(jié)點(diǎn),直到找到 key 相同,并且節(jié)點(diǎn)的類型相同,否則就刪除所有的子節(jié)點(diǎn)。

你有木有這樣的問題:為什么 TextNode 不采用這樣的循環(huán)策略來(lái)找可以復(fù)用的節(jié)點(diǎn)呢?這個(gè)問題留給你思考,歡迎在評(píng)論區(qū)留下你的答案。

對(duì)應(yīng)的源碼邏輯如下:

// 找到 key 相同的節(jié)點(diǎn),就會(huì)復(fù)用當(dāng)前節(jié)點(diǎn)
while (child !== null) {
  if (child.key === key) {
    if (
      child.tag === Fragment
      ? element.type === REACT_FRAGMENT_TYPE
      : child.elementType === element.type
    ) {
      // 復(fù)用節(jié)點(diǎn)邏輯,省略該部分代碼,和上面復(fù)用節(jié)點(diǎn)的代碼相同
      // code ...
      return existing;
    } else {
      deleteRemainingChildren(returnFiber, child);
      break;
    }
  } else {
    // 如果沒有可以復(fù)用的節(jié)點(diǎn),就把這個(gè)節(jié)點(diǎn)刪除
    deleteChild(returnFiber, child);
  }
  child = child.sibling;
}

在上面這段代碼我們需要注意的是,當(dāng) key 相同,React 會(huì)認(rèn)為是同一個(gè)節(jié)點(diǎn),所以當(dāng) key 相同,節(jié)點(diǎn)類型不同的時(shí)候,React 會(huì)認(rèn)為你已經(jīng)把這個(gè)節(jié)點(diǎn)重新覆蓋了,所以就不會(huì)再去找剩余的節(jié)點(diǎn)是否可以復(fù)用。只有在 key 不同的時(shí)候,才會(huì)去找兄弟節(jié)點(diǎn)是否可以復(fù)用。

接下來(lái)才是我們前面說的,如果沒有找到可以復(fù)用的節(jié)點(diǎn),然后就重新創(chuàng)建節(jié)點(diǎn),源碼如下:

// 前面的循環(huán)已經(jīng)把該刪除的已經(jīng)刪除了,接下來(lái)就開始創(chuàng)建新的節(jié)點(diǎn)了
if (element.type === REACT_FRAGMENT_TYPE) {
  const created = createFiberFromFragment(
    element.props.children,
    returnFiber.mode,
    expirationTime,
    element.key,
  );
  created.return = returnFiber;
  return created;
} else {
  const created = createFiberFromElement(
    element,
    returnFiber.mode,
    expirationTime,
  );
  created.ref = coerceRef(returnFiber, currentFirstChild, element);
  created.return = returnFiber;
  return created;
}

對(duì)于 Fragment 節(jié)點(diǎn)和一般的 Element 節(jié)點(diǎn)創(chuàng)建的方式不同,因?yàn)?Fragment 本來(lái)就是一個(gè)無(wú)意義的節(jié)點(diǎn),他真正需要?jiǎng)?chuàng)建 Fiber 的是它的 children,而不是它自己,所以 createFiberFromFragment 傳遞的不是 element,而是 element.props.children

Diff Array

Diff Array 算是 Diff 中最難的一部分了,比較的復(fù)雜,因?yàn)樽隽撕芏嗟膬?yōu)化,不過請(qǐng)你放心,認(rèn)真看完我的講解,最難的也會(huì)很容易理解,廢話不多說,開始吧!

因?yàn)?Fiber 樹是單鏈表結(jié)構(gòu),沒有子節(jié)點(diǎn)數(shù)組這樣的數(shù)據(jù)結(jié)構(gòu),也就沒有可以供兩端同時(shí)比較的尾部游標(biāo)。所以React的這個(gè)算法是一個(gè)簡(jiǎn)化的兩端比較法,只從頭部開始比較。

前面已經(jīng)說了,Diff 的目的就是為了復(fù)用,對(duì)于 Array 就不能像之前的節(jié)點(diǎn)那樣,僅僅對(duì)比一下元素的 key 或者 元素類型就行,因?yàn)閿?shù)組里面是好多個(gè)元素。你可以在頭腦里思考兩分鐘如何進(jìn)行復(fù)用節(jié)點(diǎn),再看 React 是怎么做的,然后對(duì)比一下孰優(yōu)孰劣。

1. 相同位置(index)進(jìn)行比較

相同位置進(jìn)行對(duì)比,這個(gè)是比較容易想到的一種方式,還是舉個(gè)例子加深一下印象。

這已經(jīng)是一個(gè)非常簡(jiǎn)單的例子了,div 的 child 是一個(gè)數(shù)組,有 AAA、BBB 然后還有其他的兄弟節(jié)點(diǎn),在做 diff 的時(shí)候就可以從新舊的數(shù)組中按照索引一一對(duì)比,如果可以復(fù)用,就把這個(gè)節(jié)點(diǎn)從老的鏈表里面刪除,不能復(fù)用的話再進(jìn)行其他的復(fù)用策略。

那如果判斷節(jié)點(diǎn)是否可以復(fù)用呢?有了前面的 ReactElement 和 TextNode 復(fù)用的經(jīng)驗(yàn),這個(gè)也類似,因?yàn)槭且灰粚?duì)比嘛,相當(dāng)于是一個(gè)節(jié)點(diǎn)一個(gè)節(jié)點(diǎn)的對(duì)比。

不過對(duì)于 newChild 可能會(huì)有很多種類型,簡(jiǎn)單的看下源碼是如何進(jìn)行判斷的。

 const key = oldFiber !== null ? oldFiber.key : null;

前面的經(jīng)驗(yàn)可得,判斷是否可以復(fù)用,常常會(huì)根據(jù) key 是否相同來(lái)決定,所以首先獲取了老節(jié)點(diǎn)的 key 是否存在。如果不存在老節(jié)點(diǎn)很可能是 TextNode 或者是 Fragment。

接下來(lái)再看 newChild 為不同類型的時(shí)候是如何進(jìn)行處理的。

當(dāng) newChild 是 TextNode 的時(shí)候

if (typeof newChild === 'string' || typeof newChild === 'number') {
  // 對(duì)于新的節(jié)點(diǎn)如果是 string 或者 number,那么都是沒有 key 的,
  // 所有如果老的節(jié)點(diǎn)有 key 的話,就不能復(fù)用,直接返回 null。
  // 老的節(jié)點(diǎn) key 為 null 的話,代表老的節(jié)點(diǎn)是文本節(jié)點(diǎn),就可以復(fù)用
  if (key !== null) {
    return null;
  }

  return updateTextNode(
    returnFiber,
    oldFiber,
    '' + newChild,
    expirationTime,
  );
}

如果 key 不為 null,那么就代表老節(jié)點(diǎn)不是 TextNode,而新節(jié)點(diǎn)又是 TextNode,所以返回 null,不能復(fù)用,反之則可以復(fù)用,調(diào)用 updateTextNode 方法。

注意,updateTextNode 里面包含了首次渲染的時(shí)候的邏輯,首次渲染的時(shí)候回插入一個(gè) TextNode,而不是復(fù)用。

當(dāng) newChild 是 Object 的時(shí)候

newChild 是 Object 的時(shí)候基本上走的就是 ReactElement 的邏輯了,判斷 key 和 元素的類型是否相等來(lái)判斷是否可以復(fù)用。

if (typeof newChild === 'object' && newChild !== null) {
  // 有 $$typeof 代表就是 ReactElement
  switch (newChild.$$typeof) {
    case REACT_ELEMENT_TYPE: {
                // ReactElement 的邏輯 
    }
    case REACT_PORTAL_TYPE: {
                // 調(diào)用 updatePortal
    }
  }

  if (isArray(newChild) || getIteratorFn(newChild)) {
    if (key !== null) {
      return null;
    }

    return updateFragment(
      returnFiber,
      oldFiber,
      newChild,
      expirationTime,
      null,
    );
  }
}

首先判斷是否是對(duì)象,用的是 typeof newChild === 'object' && newChild !== null ,注意要加 !== null,因?yàn)?typeof null 也是 object。

然后通過 $$typeof 判斷是 REACT_ELEMENT_TYPE 還是 REACT_PORTAL_TYPE,分別調(diào)用不同的復(fù)用邏輯,然后由于數(shù)組也是 Object ,所以這個(gè) if 里面也有數(shù)組的復(fù)用邏輯。

我相信到這里應(yīng)該對(duì)于應(yīng)該對(duì)于如何相同位置的節(jié)點(diǎn)如何對(duì)比有清晰的認(rèn)識(shí)了。另外還有問題,那就是如何循環(huán)一個(gè)一個(gè)對(duì)比呢?

這里要注意,新的節(jié)點(diǎn)的 children 是虛擬 DOM,所以這個(gè) children 是一個(gè)數(shù)組,而對(duì)于之前提到的老的節(jié)點(diǎn)樹是鏈表。

那么循環(huán)一個(gè)一個(gè)對(duì)比,就是遍歷數(shù)組的過程。

let newIdx = 0 // 新數(shù)組的索引
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
  // 遍歷老的節(jié)點(diǎn)
  nextOldFiber = oldFiber.sibling; 
  // 返回復(fù)用節(jié)點(diǎn)的函數(shù),newFiber 就是復(fù)用的節(jié)點(diǎn)。
  // 如果為空,就代表同位置對(duì)比已經(jīng)不能復(fù)用了,循環(huán)結(jié)束。
  const newFiber = updateSlot(
    returnFiber,
    oldFiber,
    newChildren[newIdx],
    expirationTime,
  );
  
  if (newFiber === null) {
    break;
  }
  
  // 其他 code,比如刪除復(fù)用的節(jié)點(diǎn)
}

這并不是源碼的全部源碼,我只是把思路給貼出來(lái)了。

這是第一次遍歷新數(shù)組,通過調(diào)用 updateSlot 來(lái)對(duì)比新老元素,前面介紹的如何對(duì)比新老節(jié)點(diǎn)的代碼都是在這個(gè)函數(shù)里。這個(gè)循環(huán)會(huì)把所以的從前面開始能復(fù)用的節(jié)點(diǎn),都復(fù)用到。比如上面我們畫的圖,如果兩個(gè)鏈表里面的 ???節(jié)點(diǎn),不相同,那么 newFiber 為 null,這個(gè)循環(huán)就會(huì)跳出。

跳出來(lái)了,就會(huì)有兩種情況。

  • 新節(jié)點(diǎn)已經(jīng)遍歷完畢
  • 老節(jié)點(diǎn)已經(jīng)遍歷完畢

2. 新節(jié)點(diǎn)已經(jīng)遍歷完畢

如果新節(jié)點(diǎn)已經(jīng)遍歷完畢的話,也就是沒有要更新的了,這種情況一般就是從原來(lái)的數(shù)組里面刪除了元素,那么直接把剩下的老節(jié)點(diǎn)刪除了就行了。還是拿上面的圖的例子舉例,老的鏈表里???還有很多節(jié)點(diǎn),而新的鏈表???已經(jīng)沒有節(jié)點(diǎn)了,所以老的鏈表???不管是有多少節(jié)點(diǎn),都不能復(fù)用了,所以沒用了,直接刪除。

if (newIdx === newChildren.length) {
  // 新的 children 長(zhǎng)度已經(jīng)夠了,所以把剩下的刪除掉
  deleteRemainingChildren(returnFiber, oldFiber);
  return resultingFirstChild;
}

注意這里是直接 return 了哦,沒有繼續(xù)往下執(zhí)行了。

3. 老節(jié)點(diǎn)已經(jīng)遍歷完畢

如果老的節(jié)點(diǎn)在第一次循環(huán)的時(shí)候就被復(fù)用完了,新的節(jié)點(diǎn)還有,很有可能就是新增了節(jié)點(diǎn)的情況。那么這個(gè)時(shí)候只需要根據(jù)把剩余新的節(jié)點(diǎn)直接創(chuàng)建 Fiber 就行了。

if (oldFiber === null) {
  // 如果老的節(jié)點(diǎn)已經(jīng)被復(fù)用完了,對(duì)剩下的新節(jié)點(diǎn)進(jìn)行操作
  for (; newIdx < newChildren.length; newIdx++) {
    const newFiber = createChild(
      returnFiber,
      newChildren[newIdx],
      expirationTime,
    );
  }
  return resultingFirstChild;
}

oldFiber === null 就是用來(lái)判斷老的 Fiber 節(jié)點(diǎn)變量完了的代碼,F(xiàn)iber 鏈表是一個(gè)單向鏈表,所以為 null 的時(shí)候代表已經(jīng)結(jié)束了。所以就直接把剩余的 newChild 通過循環(huán)創(chuàng)建 Fiber。

到這里,目前簡(jiǎn)單的對(duì)數(shù)組進(jìn)行增、刪節(jié)點(diǎn)的對(duì)比還是比較簡(jiǎn)單,接下來(lái)就是移動(dòng)的情況是如何進(jìn)行復(fù)用的呢?

4. 移動(dòng)的情況如何進(jìn)行節(jié)點(diǎn)復(fù)用

對(duì)于移動(dòng)的情況,首先要思考,怎么能判斷數(shù)組是否發(fā)生過移動(dòng)操作呢?

如果給你兩個(gè)數(shù)組,你是否能判斷出來(lái)數(shù)組是否發(fā)生過移動(dòng)。

答案是:老的數(shù)組和新的數(shù)組里面都有這個(gè)元素,而且位置不相同。

從兩個(gè)數(shù)組中找到相同元素(是指可復(fù)用的節(jié)點(diǎn)),方法有很多種,來(lái)看看 React 是如何高效的找出來(lái)的。

把所有老數(shù)組元素按 key 或者是 index 放 Map 里,然后遍歷新數(shù)組,根據(jù)新數(shù)組的 key 或者 index 快速找到老數(shù)組里面是否有可復(fù)用的。

function mapRemainingChildren(
 returnFiber: Fiber,
 currentFirstChild: Fiber,
): Map<string | number, Fiber> {
  const existingChildren: Map<string | number, Fiber> = new Map();

  let existingChild = currentFirstChild; // currentFirstChild 是老數(shù)組鏈表的第一個(gè)元素
  while (existingChild !== null) {
  // 看到這里可能會(huì)疑惑怎么在 Map 里面的key 是 fiber 的key 還是 fiber 的 index 呢?
  // 我覺得是根據(jù)數(shù)據(jù)類型,fiber 的key 是字符串,而 index 是數(shù)字,這樣就能區(qū)分了
  // 所以這里是用的 map,而不是對(duì)象,如果是對(duì)象的key 就不能區(qū)分 字符串類型和數(shù)字類型了。
    if (existingChild.key !== null) {
      existingChildren.set(existingChild.key, existingChild);
    } else {
      existingChildren.set(existingChild.index, existingChild);
    }
    existingChild = existingChild.sibling;
    }
    return existingChildren;
}

這個(gè) mapRemainingChildren 就是將老數(shù)組存放到 Map 里面。元素有 key 就 Map 的鍵就存 key,沒有 key 就存 index,key 一定是字符串,index 一定是 number,所以取的時(shí)候是能區(qū)分的,所以這里用的是 Map,而不是對(duì)象,如果是對(duì)象,屬性是字符串,就沒辦法區(qū)別是 key 還是 index 了。

現(xiàn)在有了這個(gè) Map,剩下的就是循環(huán)新數(shù)組,找到 Map 里面可以復(fù)用的節(jié)點(diǎn),如果找不到就創(chuàng)建,這個(gè)邏輯基本上跟 updateSlot 的復(fù)用邏輯很像,一個(gè)是從老數(shù)組鏈表中獲取節(jié)點(diǎn)對(duì)比,一個(gè)是從 Map 里獲取節(jié)點(diǎn)對(duì)比。

// 如果前面的算法有復(fù)用,那么 newIdx 就不從 0 開始
for (; newIdx < newChildren.length; newIdx++) {
  const newFiber = updateFromMap(
    existingChildren,
    returnFiber,
    newIdx,
    newChildren[newIdx],
    expirationTime,
  );
 // 省略刪除 existingChildren 中的元素和添加 Placement 副作用的情況
}

到這里新數(shù)組遍歷完畢,也就是同一層的 Diff 過程完畢,接下來(lái)進(jìn)行總結(jié)一下。

效果演示

以下效果動(dòng)態(tài)演示來(lái)自于文章:React Diff 源碼分析,我覺得這個(gè)演示非常的形象,有助于理解。

這里渲染一個(gè)可輸入的數(shù)組。


當(dāng)?shù)谝环N情況,新數(shù)組遍歷完了,老數(shù)組剩余直接刪除(12345→1234 刪除 5):

新數(shù)組沒完,老數(shù)組完了(1234→1234567 插入 567):

移動(dòng)的情況,即之前就存在這個(gè)元素,后續(xù)只是順序改變(123 → 4321 插入4,移動(dòng)2 1):

最后刪除沒有涉及的元素。

總結(jié)

對(duì)于數(shù)組的 diff 策略,相對(duì)比較復(fù)雜,最后來(lái)梳理一下這個(gè)策略,其實(shí)還是很簡(jiǎn)單,只是看源碼的時(shí)候比較難懂。

我們可以把整個(gè)過程分為三個(gè)階段:

  1. 第一遍歷新數(shù)組,新老數(shù)組相同 index 進(jìn)行對(duì)比,通過 updateSlot方法找到可以復(fù)用的節(jié)點(diǎn),直到找到不可以復(fù)用的節(jié)點(diǎn)就退出循環(huán)。
  2. 第一遍歷完之后,刪除剩余的老節(jié)點(diǎn),追加剩余的新節(jié)點(diǎn)的過程。如果是新節(jié)點(diǎn)已遍歷完成,就將剩余的老節(jié)點(diǎn)批量刪除;如果是老節(jié)點(diǎn)遍歷完成仍有新節(jié)點(diǎn)剩余,則將新節(jié)點(diǎn)直接插入。
  3. 把所有老數(shù)組元素按 key 或 index 放 Map 里,然后遍歷新數(shù)組,插入老數(shù)組的元素,這是移動(dòng)的情況。

后記

剛開始閱讀源碼的過程是非常的痛苦的,但是當(dāng)你一遍一遍的把作者想要表達(dá)的理解了,為什么要這么寫 理解了,會(huì)感到作者的設(shè)計(jì)是如此的精妙絕倫,每一個(gè)變量,每一行代碼感覺都是精心設(shè)計(jì)過的,然后感受到自己與大牛的差距,激發(fā)自己的動(dòng)力。

更多的對(duì)于 React 原理相關(guān),源碼相關(guān)的內(nèi)容,請(qǐng)關(guān)注我的 github:Deep In React 或者 個(gè)人博客:桃園

我是桃翁,一個(gè)愛思考的前端er,想了解關(guān)于更多的前端相關(guān)的,請(qǐng)關(guān)注我的公號(hào):「前端桃園」

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容