【react】什麼是fiber?fiber解決了什麼問題?從原始碼角度深入瞭解fiber執行機制與diff執行

2022-06-27 06:01:03

壹 ❀ 引

我在[react] 什麼是虛擬dom?虛擬dom比操作原生dom要快嗎?虛擬dom是如何轉變成真實dom並渲染到頁面的?一文中,介紹了虛擬dom的概念,以及react中虛擬dom的使用場景。那麼按照之前的約定,本文來聊聊react中另一個非常重要的概念,也就是fiber。那麼通過閱讀本文,你將瞭解到如下幾個知識點:

  • react在使用fiber之前為什麼會出現丟幀(卡頓)?
  • 如何理解瀏覽器中的幀?
  • 什麼是fiber?它解決了什麼問題?
  • fiber有哪些優勢?
  • 瞭解requestIdleCallback
  • react中的fiber是如何運轉的(fiber的兩個階段)
  • diff原始碼分析(基於react 17.0.2)

同樣,若文中涉及到的原始碼部分,我依然會使用17.0.2的版本,保證文章的結論不會過於老舊;其次,fiber的概念理解起來其實比較枯燥,但我會盡量描述的通俗易懂一點,那麼本文開始。

貳 ❀ 在fiber之前

我們學習任何東西,一定會經歷兩個階段,一是這個東西是什麼?二是這個東西有什麼用(解決了什麼問題)?所以在介紹fiber之前,我們還是先說說在fiber之前react遇到了什麼問題,而這個問題,我們可以通過自己手寫一個簡單的render來模擬react 15之前的渲染過程。

通過虛擬dom一文,我們已經知道所謂虛擬dom其實就是一個包含了dom節點型別type,以及dom屬性props的物件,我們假定有如下一段dom資訊,現在需要通過自定義方法render將其渲染到頁面:

const vDom = {
  type: "div",
  props: {
    id: "0",
    children: [
      {
        type: "span",
        children: 111,
      },
    ],
  },
};

其實一共就三步,建立dom,加工屬性,以及遞迴處理子元素,直接上程式碼:

const render = (element, container) => {
  // 建立dom節點
  let dom = document.createElement(element.type);
  // 新增屬性
  const props = Object.keys(element.props);
  props.forEach((e) => {
    if (e !== "children") {
      dom[e] = element.props[e];
    }
  });
  // 處理子元素
  if (Array.isArray(element.props.children)) {
    // 是陣列,那就繼續遞迴
    element.props.children.forEach((c) => render(c, dom));
  } else {
    // 是文位元組點就設定文字
    dom.innerHTML = element.props.children;
  }
  // 將當前加工好的dom節點新增到父容器節點中
  container.appendChild(dom);
};

render(vDom, document.getElementById("root"));

通過這段程式碼,你應該想到了一個問題,假設我們的dom結果非常複雜,react在遞迴進行渲染時一定會非常耗時;而這段程式碼又是同步執行,遞迴一旦開始就不能停止

大家都知道瀏覽器是單執行緒,JS執行緒與UI執行緒互斥,假設這段程式碼執行的時間足夠久,那麼瀏覽器就必須一直等待,嚴重情況下瀏覽器還可能失去響應。

當然,react團隊大佬雲集,不至於說react會在渲染上嚴重卡頓,但在極端情況下,react在渲染大量dom節點時還是會出現丟幀問題,這個現象大家可以對比react 15(棧實現)與react引入fiber之後的渲染差異Fiber vs Stack Demo

Fiber Example

Stack Example

很顯然,在引入fiber概念以及Reconcilation(diff相關)重構後,react在渲染上可以說跟德芙一樣縱享絲滑了。

即便現在我們還未了解fiber,但通過了解傳統的遞迴渲染,我們知道了同步渲染會佔用線層,既然fiber能解決這個問題,我們可以猜測到fiber一定會有類似執行緒控制的操作,不過在介紹fiber之前,我們還是得介紹瀏覽器幀的概念,以及為啥react 15會有掉幀的情況,這對於後續理解fiber也會有一定的幫助,我們接著聊。

叄 ❀ 幀的概念

如何理解幀?很直觀的解釋可以借用動畫製作工藝,傳統的動畫製作其實都是逐幀拍攝,動畫作者需要將一個連貫的畫面一張一張的畫出來,然後再結合畫面的高速切換以達到動畫的效果,我相信不少人在讀書時代應該也做過在課本每一頁畫畫然後玩翻頁動畫的事情。

所以如果一個連貫動作我們用100個畫面去呈現,那麼你會發現這個畫面看起來非常流暢,但如果我們抽幀到只有10幀,人物的動作就會顯得不連貫且卡頓,這時候大家就說開啟眨眼補幀模式。不過在視訊混剪上,也有人還會故意用抽幀來達到王家衛電影的拖影效果,但這都是藝術表現層面的話術了。

所以回到瀏覽器渲染,我們其實也可以將瀏覽器的動畫理解成一張張的圖,而主流的顯示器重新整理率其實都是60幀/S,也就是一秒畫面會高速的重新整理60次,按照計算機1S等於1000ms的設定,那麼一幀的預算時間其實是1000ms/60幀也就是16.66ms

在實現動畫效果時,我們有時候會使用到window.requestAnimationFrame方法,關於其解釋可見requestAnimationFrame MDN

window.requestAnimationFrame() 告訴瀏覽器——你希望執行一個動畫,並且要求瀏覽器在下次重繪之前呼叫指定的回撥函數更新動畫。該方法需要傳入一個回撥函數作為引數,該回撥函數會在瀏覽器下一次重繪之前執行。

16.66ms也不是我們隨口一說,我們可以通過一個簡單的例子來驗證這個結論:

<div id="some-element-you-want-to-animate"></div>
const element = document.getElementById('some-element-you-want-to-animate');
let start;
// callback接受一個由瀏覽器提供的,當函數開始執行的時間timestamp
function step(timestamp) {
  if (start === undefined) {
    start = timestamp;
  }
  // 計算每一幀重新整理時的類增時間
  const elapsed = timestamp - start;
  console.log(elapsed);

  //這裡使用`Math.min()`確保元素剛好停在 200px 的位置。
  element.style.transform = 'translateX(' + Math.min(0.1 * elapsed, 200) + 'px)';

  if (elapsed < 2000) { // 在兩秒後停止動畫
    window.requestAnimationFrame(step);
  }
}

window.requestAnimationFrame(step);

大家有興趣可以在本地執行下這個例子,可以看到當每一幀中執行step方法時,所接受的開始時間的時間差都是16.66ms。如果你的時間差要低於16.66ms,那說明你使用的電腦顯示器重新整理率要高於60幀/S

我們人眼在舒適放鬆時可視幀數是24幀/S,也就是說1S起碼得得有24幀我們才會覺得畫面流暢,但前文也說了,react 15之前的版本實現,渲染任務只要過長就會一直佔用執行緒導致瀏覽器渲染任務推遲,如果這個渲染之間夾雜了多次推遲,瀏覽器1S都不夠渲染60幀甚至更低,那瀏覽器渲染的整體影格率自然就會下降,我們在視覺上的直觀感受就是掉幀了。

那麼到這裡,我們解釋了react 15掉幀的根本原因,傳統的遞迴呼叫棧的實現,在長任務面前會造成執行緒佔用的情況,嚴重的話就會掉幀,react急需另一種策略來解決這個問題,接下來我們就來好好聊聊fiber

肆 ❀ fiber是什麼?

那麼如何理解react中的fiber呢,兩個層面來解釋:

  • 從執行機制上來解釋,fiber是一種流程讓出機制,它能讓react中的同步渲染進行中斷,並將渲染的控制權讓回瀏覽器,從而達到不阻塞瀏覽器渲染的目的。
  • 從資料角度來解釋,fiber能細化成一種資料結構,或者一個執行單元

我們可以結合這兩點來理解,react會在跑完一個執行單元后檢測自己還剩多少時間(這個所剩時間下文會解釋),如果還有時間就繼續執行,反之就終止任務並記錄任務,同時將控制權還給瀏覽器,直到下次瀏覽器自身工作做完,又有了空閒時間,便再將控制權交給react,以此反覆。

傳統遞迴,一條路走到黑

react fiber,靈活讓出控制權保證渲染與瀏覽器響應

而關於fiber資料結構,我在虛擬dom一文其實也簡單提到過,每一個被建立的虛擬dom都會被包裝成一個fiber節點,它具備如下結構:

const fiber = {
stateNode,// dom節點範例
child,// 當前節點所關聯的子節點
sibling,// 當前節點所關聯的兄弟節點
return// 當前節點所關聯的父節點
}

這樣設計的好處就是在資料層已經在不同節點的關係給描述了出來,即便某一次任務被終止,當下次恢復任務時,這種結構也利於react恢復任務現場,知道自己接下來應該處理哪些節點。

當然,上面也抽象只是解釋fiber是個什麼東西,結合react的角度,綜合來講react中的fiber其實具備如下幾點核心特點:

  1. 支援增量渲染,fiberreact中的渲染任務拆分到每一幀。(不是一口氣全部渲染完,走走停停,有時間就繼續渲染,沒時間就先暫停)
  2. 支援暫停,終止以及恢復之前的渲染任務。(沒渲染時間了就將控制權讓回瀏覽器)
  3. 通過fiber賦予了不同任務的優先順序。(讓優先順序高的執行,比如事件互動響應,頁面渲染等,像網路請求之類的往後排)
  4. 支援並行處理(結合第3點理解,面對可變的一堆任務,react始終處理最高優先順序,靈活調整處理順序,保證重要的任務都會在允許的最快時間內響應,而不是死腦筋按順序來)

到這裡,我相信大家腦中應該有了一個模糊的理解了,可能有同學就好奇了,那這個fiber是怎麼做到讓出控制權的呢?react又是怎麼知道接下來自己可以執行的呢?那接下里,我們就不得不介紹另一個API requestIdleCallback

伍 ❀ 關於requestIdleCallback

關於requestIdleCallback詳情大家可以檢視requestIdleCallback mdn介紹,這裡普及下概念:

window.requestIdleCallback()方法插入一個函數,這個函數將在瀏覽器空閒時期被呼叫。這使開發者能夠在主事件迴圈上執行後臺和低優先順序工作,而不會影響延遲關鍵事件,如動畫和輸入響應。

requestAnimationFrame類似,requestIdleCallback也能接受一個callback,而這個callback又能接收一個由瀏覽器告知你執行剩餘時間的引數IdleDeadline,我們來看個簡單的例子:

const process = (deadline) => {
  // 通過deadline.timeRemaining可獲取剩餘時間
  console.log('deadline', deadline.timeRemaining());
}
window.requestIdleCallback(process);

簡單點來說,這個方法其實是瀏覽器在有空閒時間時會自動呼叫,而且瀏覽器會告訴你剩餘時間還剩多少。

因此,我們可以將一些不太重要的,或者優先順序較低的事情丟在requestIdleCallback裡面,然後判斷有沒有剩餘時間,再決定要不要做。當有時間時我們可以去做需要做的事情,而我們決定不做時,控制權也會自然回到瀏覽器手裡,畢竟瀏覽器也不會因為JS沒事幹而自己閒著。那麼這個剩餘時間是怎麼算的呢?

通過上文我們知道,所謂掉幀就是,正常來說瀏覽器1S本來是可以渲染60幀,但由於執行緒一直被JS佔著,導致瀏覽器響應時的時間已經不夠渲染這麼多次了,所以整體上1S能渲染的幀數比較低,這就是我們所謂的掉幀。而一般情況下,1幀的時間是16.66ms,那是不是表示剩餘時間 = 16.66ms - (瀏覽器處理完自己的事情的時間) 呢?

確實是這樣,但需要注意的是,在一些極端情況下,瀏覽器會最多給出50ms的空閒時間給我們處理想做的事情,比如我們一些任務非常耗時,瀏覽器知道我們會耗時,但為了讓頁面呈現儘可能不要太卡頓,同時又要照顧JS執行緒,所以它會主動將一幀的用時從16.66ms提升到50ms,也就是說此時1S瀏覽器至多能渲染20幀。

我們可以通過如下程式碼來故意造成耗時的場景,然後再來檢視剩餘時間:

// 用於造成耗時情況的函數
const delay = (time) => {
  let now = Date.now();
  // 這段邏輯會佔用time時長,所以執行完它需要time時間
  while (time + now > Date.now()) {};
}

// 待辦事項
let work = [
  () => {
    console.log('任務1')
    // 故意佔用1S時間
    delay(1000);
  },
  () => {
    console.log('任務2')
    delay(1000);
  },
  () => {
    console.log('任務3')
  },
  () => {
    console.log('任務4')
  },
];

const process = (deadline) => {
  // 通過deadline.timeRemaining可獲取剩餘時間
  console.log('deadline', deadline.timeRemaining());
  // 還有剩餘時間嗎?還有剩餘工作嗎?如果都滿足,那就再做一個任務吧
  if (deadline.timeRemaining() > 0 && work.length > 0) {
    work.shift()();
  }
  // 如果還有任務,繼續呼叫requestIdleCallback
  if (work.length) {
    window.requestIdleCallback(process);
  }
}
window.requestIdleCallback(process);

可以看到,第一個輸出的剩餘時間還是很少的,但第一個任務結尾處有一個耗時的邏輯,所以瀏覽器直接將1幀的剩餘時間提到了50ms,而為什麼偏偏是50ms呢,其實還是跟效能相關,如下:

延遲時間 使用者感知
0-16ms 非常流暢
0-100ms 基本流暢
100-1000ms 能感覺到有一些延遲
1000ms或更多 失去耐心
10000ms以上 拜拜,再也不來了

在沒有辦法的情況下,又要保持瀏覽器響應,又要儘量保證重新整理看起來流程,50ms也算瀏覽器的一種折中方案了。

那麼在瞭解了requestIdleCallback之後,我們知道了fiber是如何實現控制權讓出的,這很重要。

但需要注意的是,react在最終實現上並未直接採用requestIdleCallback,一方面是requestIdleCallback目前還是實驗中的api,相容性不是非常好,其次考慮到剩餘時間提升到50ms也就20幀左右,體驗依舊不是很好。於是react通過MessageChannel + requestAnimationFrame 自己模擬實現了requestIdleCallback

上文我們已經介紹了requestAnimationFrame會在每一幀繪製前被瀏覽器呼叫,所以react將想要做的事放在requestAnimationFramecallback中,而callback能接受到瀏覽器傳遞過來的幀的起始時間timestamp,所以react自己動手計算幀與幀的時間差,以此判斷是否超出預期時間。這部分知識我個人感覺有些超綱,大家如果自己感興趣,可以直接搜下react 中 requestIdleCallback 的實現原理這個關鍵詞,這裡就不模擬這個實現過程了。

陸 ❀ react中的fiber是如何運轉的?

fiber在渲染中每次都會經歷協調Reconciliation提交Commit兩個階段。

協調階段:這個階段做的事情很多,比如fiber的建立diff對比等等都在這個階段。在對比完成之後即等待下次提交,需要注意的是這個階段可以被暫停。

提交階段:將協調階段計算出來的變更一次性提交,此階段同步進行且不可中斷(優先保證渲染)。

那麼接下來我將從原始碼角度,給大家展示下react是如何建立fiber節點,Reconciliation(diff)是如何對比,以及前文提到的剩餘時間是如何運轉的。

為了更好理解下面的原始碼,我以下面這個元件為模板:

const P = () => {
  const [state, setState] = useState({ a: 1, b: 2 });
  const handleState = useCallback(() => {
    setState({ a: 2, b: 3 });
  }, []);
  return (
    <div>
      <span id="span1">{state.a}</span>
      <span id="span2">{state.b}</span>
      <button onClick={handleState}>點我</button>
    </div>
  );
};

陸 ❀ 壹 fiber的建立與節點關係的建立

react會在準備好虛擬dom之後再基於虛擬dom建立fiber節點,那麼這裡我們就來闡述fiber是如何建立,以及如何建立兄弟父級關係的。

需要注意的是,這次的原始碼分析我不會再從render方法開始,上面的元件P中的div有三個子元素,因為是個陣列,這裡我們直接關注到reconcileChildrenArray方法,如果大家也想跟這個過程,可以在本地專案啟動後,然後在react-dom.development檔案搜尋此方法再斷點,如果只是想看原始碼,可以直接跳轉github地址,具體程式碼如下:

function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes) {
	// 刪除部分邏輯
  // ...
  if (oldFiber === null) {
    // 這裡的newChildren其實就是虛擬dom節點的資料,遍歷依次根據虛擬dom建立fiber階段
    for (; newIdx < newChildren.length; newIdx++) {
      var _newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
      if (_newFiber === null) {
        continue;
      }
      lastPlacedIndex = placeChild(_newFiber, lastPlacedIndex, newIdx);
      if (previousNewFiber === null) {
        resultingFirstChild = _newFiber;
      } else {
        // 在這裡,我們建立了同層級fiber節點兄弟關係
        previousNewFiber.sibling = _newFiber;
      }

      previousNewFiber = _newFiber;
    }
		// 遍歷生成結束後,返回第一個child,這樣父節點就知道自己的第一個孩子是誰了
    return resultingFirstChild;
  } // Add all children to a key map for quick lookups.
}

根據上圖其實可以發現,這裡的newChildren其實就是遍歷到某一層級時的所有子元素的集合,然後遍歷子元素依次呼叫createChild方法從而得到fiber節點,在下層通過previousNewFiber.sibling = _newFiber子元素建立兄弟關係

在方法結尾可以看到返回了resultingFirstChild(第一個子元素),目的是讓父節點知道自己的第一個孩子是誰從而建立父子關係。所以到這我們就知道了兄弟關係,以及父節點的第一個子節點的關係是如何建立的。

那麼如何建立的fiber呢?我們繼續跟蹤createChild方法:

function createChild(returnFiber, newChild, lanes) {
  if (typeof newChild === 'object' && newChild !== null) {
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE:
        {
          // 關注點,這裡又呼叫了createFiberFromElement方法
          var _created = createFiberFromElement(newChild, returnFiber.mode, lanes);

          _created.ref = coerceRef(returnFiber, null, newChild);
          // 在這裡為建立出來的fiber節點繫結父節點,也就是前文說的return
          _created.return = returnFiber;
          return _created;
        }
        // 刪除部分多餘邏輯
    }
}

createChild中核心就兩點,呼叫createFiberFromElement方法,顧名思義,根據element節點(虛擬element節點)來建立fiber節點。其次,在生成fiber後為通過return為其設定父節點。

我們在上個方法提到了fiber是如何建立兄弟節點(sibling欄位),以及如何為父節點繫結第一個孩子(child欄位)。說通俗點,站在父節點角度,我的child只用來繫結第一個子節點,而子節點自己呢都會通過return來建立與父節點的關係,所以到這裡,child、sibling、return三個欄位我們都解釋清楚了,我們接著跟呼叫過程:

function createFiberFromElement(element, mode, lanes) {
  var owner = null;
  {
    owner = element._owner;
  }
  // 獲取虛擬dom的型別,key,props等相關資訊
  var type = element.type;
  var key = element.key;
  var pendingProps = element.props;
  // 關注點在這裡
  var fiber = createFiberFromTypeAndProps(type, key, pendingProps, owner, mode, lanes);
  {
    fiber._debugSource = element._source;
    fiber._debugOwner = element._owner;
  }
  return fiber;
}

這個方法其實也沒做什麼具體的事情,只是從虛擬dom上提取了元素型別,元素props相關屬性,然後呼叫了createFiberFromTypeAndProps方法(根據typeprops建立fiber):

function createFiberFromTypeAndProps(type, key, pendingProps, owner, mode, lanes) {
  var fiberTag = IndeterminateComponent; 
  var resolvedType = type;
 	// 刪除部分特殊預處理邏輯
  // ....
  // 關注點
  var fiber = createFiber(fiberTag, pendingProps, key, mode);
  fiber.elementType = type;
  fiber.type = resolvedType;
  fiber.lanes = lanes;
  {
    fiber._debugOwner = owner;
  }
  return fiber;
}

此處會根據type型別(比如是函數或者型別)做部分預處理,這裡我們的虛擬dom已經能具體到div或者span,所以預設走string型別的處理,所以關注點又到了createFiber方法:

var createFiber = function (tag, pendingProps, key, mode) {
  return new FiberNode(tag, pendingProps, key, mode);
};

function FiberNode(tag, pendingProps, key, mode) {
  // Instance
  this.tag = tag;
  this.key = key;
  this.elementType = null;
  this.type = null;
  this.stateNode = null; // Fiber
	// 節點關係網初始化,兄弟節點,子節點,父節點等等。
  this.return = null;
  this.child = null;
  this.sibling = null;
  this.index = 0;
  this.ref = null;
  this.pendingProps = pendingProps;
  this.memoizedProps = null;
  this.updateQueue = null;
  this.memoizedState = null;
  this.dependencies = null;
  this.mode = mode; // Effects
  this.flags = NoFlags;
  this.nextEffect = null;
  this.firstEffect = null;
  this.lastEffect = null;
  this.lanes = NoLanes;
  this.childLanes = NoLanes;
  this.alternate = null;
  // 時間相關初始化,用於後續剩餘時間計算
  {
    this.actualDuration = Number.NaN;
    this.actualStartTime = Number.NaN;
    this.selfBaseDuration = Number.NaN;
    this.treeBaseDuration = Number.NaN;
    this.actualDuration = 0;
    this.actualStartTime = -1;// 真正的開始時間
    this.selfBaseDuration = 0;
    this.treeBaseDuration = 0;
  }
  if (!hasBadMapPolyfill && typeof Object.preventExtensions === 'function') {
    // 讓fiber節點變的不可延伸,也就是永遠不能再新增新的屬性。
    Object.preventExtensions(this);
  }
}

可以看到最終來到了FiberNode建構函式,通過new呼叫我們得到了一個fiber範例。那麼到這裡,我們清晰的瞭解了fiber節點的建立過程,以及fiber節點的關係網是如何建立的。

事實上,react使用fiber節點的另一個原因就是為了通過這種關係網(連結串列),來模擬傳統的js呼叫棧。為啥這樣說呢?前文也說了傳統的呼叫棧一旦開始就不能停止,而連結串列好的好處是,我即便暫停了,也能通過next提前設定好下次要恢復的節點單元,一旦瀏覽器有了空閒時間,我們還是能快速恢復之前的工作,而fiberfiber之間又存在了父子兄弟的關係,上下文能很自然的再度形成,可想而知fiber節點對於恢復先前的工作具有極大的意義。

陸 ❀ 貳 diff階段的對比過程

之前一直想將fiberdiff做兩篇文章寫,結果在閱讀原始碼後發現,diff本身就是fiber協調階段的一部分,當元件更新時在會根據現有的fiber節點與新的虛擬dom進行對比,若有不同則更新fiber節點,所以這裡我就站在原始碼角度,來看看diff是如何進行的。

為了方便理解如下過程,這裡我提前將fiber結構列出來,它其實是這樣的。

所以一開始更新的起點,其實是一個代表了元件Pfiber節點,它的child指向了我們元件內部的div。而對比過程其實也是在通過連結串列進行遞迴,遞迴的過程依賴瞭如下兩個方法:

function workLoopSync() {
  // Already timed out, so perform work without checking if we need to yield.
  // 只要還有節點單元,一直進行對比
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}


function performUnitOfWork(unitOfWork) {
  // 獲取當前fiber節點
  var current = unitOfWork.alternate;
  setCurrentFiber(unitOfWork);
  // 建立next節點,等會會設定next為下一個要對比的fiber節點
  var next;

  if ( (unitOfWork.mode & ProfileMode) !== NoMode) {
    // 設定fiber節點的開始時間
    startProfilerTimer(unitOfWork);
    // 獲取當前fiber節點的child,將其設定為next
    next = beginWork$1(current, unitOfWork, subtreeRenderLanes);
    stopProfilerTimerIfRunningAndRecordDelta(unitOfWork, true);
  } else {
    next = beginWork$1(current, unitOfWork, subtreeRenderLanes);
  }

  resetCurrentFiber();
  unitOfWork.memoizedProps = unitOfWork.pendingProps;

  if (next === null) {
    // If this doesn't spawn new work, complete the current work.
    completeUnitOfWork(unitOfWork);
  } else {
    // 將next賦予給workInProgress,於是while迴圈會持續進行
    workInProgress = next;
  }
}

workLoopSync方法中可以看到while (workInProgress !== null)的判斷,只要fiber節點不為空,就一直遞迴呼叫performUnitOfWork方法。

而在performUnitOfWork中可以看到前文我們說的連結串列的概念,react通過next = 當前節點child的操作,只要子節點仍存在,就不斷更新next並賦予給workInProgress,所以也驗證了前文所說,即便任務被暫停,react也能通過next繼續先前的工作。

現在我們點選的P元件的更新按鈕按鈕修改狀態,react會以當前元件為根節點依次向下進行重新渲染,所以此時的起點,就是上圖的fiber P,我們跳過多餘的遞迴部分,最終會來到beginWork方法的return updateFunctionComponent這一句,這裡就是P元件真正開始更新的起點。

接下來,因為要重新渲染P元件,所以又會通過呼叫P元件得到其child,也就是虛擬dom節點資訊:

var children = Component(props, secondArg);

拿到了虛擬dom就可以準備開始diff對比了,這裡展示下updateFunctionComponent需要關注的程式碼:

function updateFunctionComponent(current, workInProgress, Component, nextProps, renderLanes) {
  // 刪除多餘的程式碼
  var nextChildren;
  {
    // 獲取函陣列件P的子節點,也就是上面的Component(props, secondArg)
    nextChildren = renderWithHooks(current, workInProgress, Component, nextProps, context, renderLanes);
    if ( workInProgress.mode & StrictMode) {
      try {
        nextChildren = renderWithHooks(current, workInProgress, Component, nextProps, context, renderLanes);
      } finally {
        reenableLogs();
      }
    }
  }
  workInProgress.flags |= PerformedWork;
  // 根據新的虛擬dom節點,更新舊有的fiber節點
  reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  // 更新完當前節點後,繼續遞迴更新child節點
  return workInProgress.child;
}

緊接著我們來到reconcileChildren方法(fiber的第一個階段,Reconciliation協調階段):

// current -- 舊有的fiber節點資訊
// workInProgress -- 也是舊有的fiber節點資訊,結構與current有少許不同
// nextChildren -- 之前呼叫Component(props, secondArg)得到的虛擬dom子節點
function reconcileChildren(current, workInProgress, nextChildren, renderLanes) {
  // 通過current我們能知道此時是初次渲染,還是更新
  if (current === null) {
    // 掛載fiber節點
    workInProgress.child = mountChildFibers(workInProgress, null, nextChildren, renderLanes);
  } else {
    // diff fiber節點
    workInProgress.child = reconcileChildFibers(workInProgress, current.child, nextChildren, renderLanes);
  }
}

reconcileChildren做的事情很簡單,就是看current(舊fiber節點)存不存在,初次渲染肯定不存在,所以會走掛載路線mountChildFibers,我們前面分析fiber的建立過程其實就是走的mountChildFibers

由於此時我們是更新state,所以current肯定是存在的,緊接著我們將舊節點以及新的虛擬dom節點傳遞下去,可以看到此時的nextChildrenprops已經是更新後的了:

那麼接下來我想大家也猜得到,肯定得根據新的虛擬dom來更新fiber節點了,我們將關注點放在reconcileChildFibers上:

// returnFiber -- 當前fiber節點的父節點,此時就是P元件
// currentFirstChild -- returnFiber節點的子節點,也就是舊的div fiber節點
// newChild -- 新的div 虛擬dom節點
function reconcileChildFibers(returnFiber, currentFirstChild, newChild, lanes) {
  // 判斷傳遞的新虛擬dom是不是物件
  var isObject = typeof newChild === 'object' && newChild !== null;
  if (isObject) {
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE:
        // 是物件,且是虛擬dom型別,繼續呼叫
        return placeSingleChild(reconcileSingleElement(returnFiber, currentFirstChild, newChild, lanes));
    }
  }
	// 刪除部分無用程式碼
  if (isArray$1(newChild)) {
    return reconcileChildrenArray(returnFiber, currentFirstChild, newChild, lanes);
  }
}

(注意引數註解,便於你理解當前在幹啥)

reconcileChildFibers方法會判斷新的節點是什麼型別,比如當前我們傳遞的是虛擬dom div,它是個物件,所以會繼續呼叫placeSingleChild方法,根據遞迴的特性,等會還會對比divprops,也就是包含了2個span一個button的陣列,因此下一輪會呼叫reconcileChildrenArray方法,這裡提前打聲招呼,那我們先看placeSingleChild方法:

// 引數與上個方法的引數註解相同,按值傳遞
function reconcileSingleElement(returnFiber, currentFirstChild, element, lanes) {
  // 獲取新虛擬dom的key
  var key = element.key;
  // 舊有的div fiber節點
  var child = currentFirstChild;
	// 判斷舊有fiber存不存在,一定是存在才能diff,否則就是走fiber建立初始化了
  while (child !== null) {
    // TODO: If key === null and child.key === null, then this only applies to
    // the first item in the list.
    if (child.key === key) {
      switch (child.tag) {
				// 刪除部分無用邏輯
        default:
          {
            if (child.elementType === element.type || ( 
             isCompatibleFamilyForHotReloading(child, element) )) {
              deleteRemainingChildren(returnFiber, child.sibling);
							// 根據新的虛擬dom的props來更新舊有div fiber節點
              var _existing3 = useFiber(child, element.props);
							// 更新完成後重新設定ref以及父節點
              _existing3.ref = coerceRef(returnFiber, child, element);
              _existing3.return = returnFiber;
              return _existing3;
            }
            break;
          }
      }
      deleteRemainingChildren(returnFiber, child);
      break;
    } else {
      // 如果key不相等,直接在父節點上把自己整個都刪掉
      deleteChild(returnFiber, child);
    }
    child = child.sibling;
  }
	// 如果不存在舊的fiber節點,那說明是掛載,因此否則走fiber的初始化
  // 這裡的初始化我刪掉了
}

placeSingleChild其實就是diff了,大家可以看看我新增的註釋,這裡我簡單描述這個過程:

  • 判斷是否存在舊有的fiber節點,如果不存在說明沒必要diff,直接走fiber新建掛載邏輯。
  • child說明有舊有fiber,那就對比key,如果不相等,直接執行deleteChild(returnFiber, child),也就是從div節點的舊有父節點上,將整個div都刪除掉,div的子節點都不需要比了,這也驗證了react的逐級比較,父不同,子一律都不比較視為不同。
  • key相同,那就比較新舊fibertype(標籤型別),如果type不相同,跟key不相同一樣,呼叫了deleteRemainingChildren(returnFiber, child)方法,直接從div的舊有父節點上將自己整個刪除。
  • key type都相同,那隻能說明是props變了,因此呼叫var _existing3 = useFiber(child, element.props)方法,根據新的props來更新舊有的div fiber節點。

我們將關注點放到useFiber上,程式碼如下:

function useFiber(fiber, pendingProps) {
	// 使用舊有的fiber節點以及新的props來建立一個新的clone fiber
  var clone = createWorkInProgress(fiber, pendingProps);
  clone.index = 0;
  clone.sibling = null;
  return clone;
}

做的事情很清晰,使用舊有的fiber div節點以及新的虛擬dom divprops建立了一個全新的div fiber,建立過程的程式碼跟前面fiber一樣,這裡就不展示了。

建立完成之後返回,然後為新的fiber設定ref,父節點等相關資訊,那麼到這裡div這個fiber就更新完成了。程式碼會一層層返回,直到updateFunctionComponentreturn workInProgress.child這一句,一直返回到next的賦值。啥意思呢?

前面的對比,其實是站在fiber P的角度把fiber div更新完了,而fiber div還有自己的孩子呢,所以接下來又以div為父節點依次更新它的三個子節點,還記得前文我們提前打的招呼嗎?接下來它就會執行下面這段:

// 可在本文搜尋程式碼,回顧上文劇情
if (isArray$1(newChild)) {
  return reconcileChildrenArray(returnFiber, currentFirstChild, newChild, lanes);
}

reconcileChildrenArray方法在fiber建立階段已經給大家分析了部分原始碼,當時執行的邏輯是if (oldFiber === null),因為不存在舊有fiber,所以直接重新建立,而此時因為咱們有,所以就不是重新建立,而是執行下面這段程式碼:

function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes) {
  		// for迴圈,依次更新兩個span以及button
      for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
      if (oldFiber.index > newIdx) {
        nextOldFiber = oldFiber;
        oldFiber = null;
      } else {
        // 建立兄弟關係
        nextOldFiber = oldFiber.sibling;
      }
			// 呼叫updateSlot,使用新的props來更新舊有fiber節點
      var newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx], lanes);
			// 刪除多餘程式碼....
}

updateSlot又是一次diff,原始碼如下:

// returnFiber -- 當前節點的父級,此時是div
// oldFiber -- 舊span1節點
// newChild -- 新的span1的虛擬dom
function updateSlot(returnFiber, oldFiber, newChild, lanes) {
  // 獲取舊有fiber的key
  var key = oldFiber !== null ? oldFiber.key : null;
  if (typeof newChild === 'object' && newChild !== null) {
    switch (newChild.$$typeof) {
      // 是react node型別嗎?
      case REACT_ELEMENT_TYPE:
        {
          // 判斷舊fiber與信虛擬dom的key
          if (newChild.key === key) {
            // 判斷是不是fragment節點
            if (newChild.type === REACT_FRAGMENT_TYPE) {
              return updateFragment(returnFiber, oldFiber, newChild.props.children, lanes, key);
            }
						// 利用新的虛擬dom來更新舊fiber span
            return updateElement(returnFiber, oldFiber, newChild, lanes);
          } else {
            return null;
          }
        }
				// 刪除部分無用程式碼
    }
  }
  return null;
}

這一段邏輯與之前div的對比大同小異,同樣是對比type與key,因為都相等,所以我們來到了updateElement方法,顧名思義,根據新虛擬dom的屬性來更新舊fiber節點:

function updateElement(returnFiber, current, element, lanes) {
  // 有舊fiber就單純的更新
  if (current !== null) {
    if (current.elementType === element.type || ( // Keep this check inline so it only runs on the false path:
     isCompatibleFamilyForHotReloading(current, element) )) {
      // 與前面更新div的邏輯一模一樣
      var existing = useFiber(current, element.props);
      existing.ref = coerceRef(returnFiber, current, element);
      existing.return = returnFiber;

      {
        existing._debugSource = element._source;
        existing._debugOwner = element._owner;
      }

      return existing;
    }
  } 
	// 沒有就重新建立
  var created = createFiberFromElement(element, returnFiber.mode, lanes);
  created.ref = coerceRef(returnFiber, current, element);
  created.return = returnFiber;
  return created;
}

updateElement做的事情很簡單,判斷存不存在舊fiber節點,存在就同樣呼叫useFiber,以舊fiber clone一個新fiber出來,沒有就重新建立。

不知道大家發現沒,react雖然明確做了很多的條件判斷,即便如此,依舊會在某個地方底層內部再做一次兜底的處理,所以程式碼看著挺多,其實大部分是為了邏輯的健壯性。

之後做的事情相比大家也清晰了,更新span2以及button,以及考慮span1 span2 button有沒有child,很明顯他們都沒有,於是程式碼最終又來到了workLoopSync,可見此時已經沒有可執行的任務單元了,於是協調階段完整結束。

由於協調階段結束,緊接著來到commit階段,我們直接關注到performSyncWorkOnRoot方法:

function performSyncWorkOnRoot(root){
  // 刪除意義不大的程式碼
  var finishedWork = root.current.alternate;
  root.finishedWork = finishedWork;
  root.finishedLanes = lanes;
  // 提交root節點
  commitRoot(root); // Before exiting, make sure there's a callback scheduled for the next
  // pending level.
  ensureRootIsScheduled(root, now());
  return null;
}

我們再關注到commitRoot方法,這裡會對當前任務進行優先順序判斷,再決定後續處理:

function commitRoot(root) {
  // 斷點發現這裡的優先順序是99,最高優先順序
  var renderPriorityLevel = getCurrentPriorityLevel();
  runWithPriority$1(ImmediatePriority$1, commitRootImpl.bind(null, root, renderPriorityLevel));
  return null;
}

由於是優先順序最高的render,因此後續react會在瀏覽器允許的情況下將最終建立的真實dom重新更新到頁面,這裡我就不再展示程式碼過程了。

柒 ❀ 總結

那麼到這裡,我們闡述了react 15以及之前的大量dom渲染時卡頓的原因,從而介紹了幀的概念。

緊接著我們引出了fiber,那麼什麼是fiber呢?往小了說它就是一種資料結構,包含了任務開始時間,節點關係資訊(return,child這些),我們把視角往上擡一點,我們也可以說fiber是一種模擬呼叫棧的特殊連結串列,目的是為了解決傳統呼叫棧無法暫停的問題。

而站在宏觀角度fiber又是一種排程讓出機制,它讓react達到了增量渲染的目的,在保證幀數流暢的同時,fiber總是在瀏覽器有剩餘時間的情況下去完成目前目前最高優先順序的任務。

所以如果讓我來提煉fiber的關鍵詞,我大概給出如下幾點:

  • fiber是一種資料結構
  • fiber使用父子關係以及next的妙用,以連結串列形式模擬了傳統呼叫棧
  • fiber是一種排程讓出機制,只在有剩餘時間的情況下執行。
  • fiber實現了增量渲染,在瀏覽器允許的情況下一點點拼湊出最終渲染效果。
  • fiber實現了並行,為任務賦予不同優先順序,保證了一有時間總是做最高優先順序的事,而不是先來先佔位死板的去執行。
  • fiber協調與提交兩個階段,協調包含了fiber建立與diff更新,此過程可暫停。而提交必須同步執行,保證渲染不卡頓。

而通過fiber的協調階段,我們瞭解了diff的對比過程,如果將fiber的結構理解成一棵樹,那麼這個過程本質上還是深度遍歷,其順序為父---父的第一個孩子---孩子的每一個兄弟。

通過原始碼,我們瞭解到reactdiff是同層比較,最先比較key,如果key不相同,那麼不用比較剩餘節點直接刪除,這也強調了key的重要性,其次會比較元素的type以及props。而且這個比較過程其實是拿舊的fiber與新的虛擬dom在比,而不是fiberfiber或者虛擬dom與虛擬dom比較,其實也不難理解,如果keytype都相同,那說明這個fiber只用做簡單的替換,而不是完整重新建立,站在效能角度這確實更有優勢。

最後,附上fiber更新排程的執行過程:

那麼到這裡,本文結束。

捌 ❀ 參考

手撕React Fiber 原始碼

這可能是最通俗的 React Fiber(時間分片) 開啟方式

React Fiber 原理介紹

A deep dive into React Fiber

Introduction to React Fiber