前端工程師的自我修養:React Fiber 是如何實現更新過程可控的

這是第 83 篇不摻水的原創,想獲取更多原創好文,請搜索公衆號關注咱們吧~ 本文首發於政採雲前端博客: 前端工程師的自我修養:React Fiber 是如何實現更新過程可控的

前言

從 React 16 開始,React 採用了 Fiber 機制替代了原先基於原生執行棧遞歸遍歷 VDOM 的方案,提升了頁面渲染性能和用戶體驗。乍一聽 Fiber 好像挺神祕,在原生執行棧都還沒搞懂的狀況下,又整出個 Fiber,還能不能愉快的寫代碼了。別慌,老鐵!下面就來嘮嘮關於 Fiber 那點事兒。javascript

什麼是 Fiber

Fiber 的英文含義是「纖維」,它是比線程(Thread)更細的線,比線程(Thread)控制得更精密的執行模型。在廣義計算機科學概念中,Fiber 又是一種協做的(Cooperative)編程模型,幫助開發者用一種【既模塊化又協做化】的方式來編排代碼。html

圖片

簡單點說,Fiber 就是 React 16 實現的一套新的更新機制,讓 React 的更新過程變得可控,避免了以前一竿子遞歸到底影響性能的作法。前端

關於 Fiber 你須要知道的基礎知識

1 瀏覽器刷新率(幀)

頁面的內容都是一幀一幀繪製出來的,瀏覽器刷新率表明瀏覽器一秒繪製多少幀。目前瀏覽器大可能是 60Hz(60幀/s),每一幀耗時也就是在 16ms 左右。原則上說 1s 內繪製的幀數也多,畫面表現就也細膩。那麼在這一幀的(16ms) 過程當中瀏覽器又幹了啥呢?java

圖片

經過上面這張圖能夠清楚的知道,瀏覽器一幀會通過下面這幾個過程:react

  1. 接受輸入事件
  2. 執行事件回調
  3. 開始一幀
  4. 執行 RAF (RequestAnimationFrame)
  5. 頁面佈局,樣式計算
  6. 渲染
  7. 執行 RIC (RequestIdelCallback)

第七步的 RIC 事件不是每一幀結束都會執行,只有在一幀的 16ms 中作完了前面 6 件事兒且還有剩餘時間,纔會執行。這裏提一下,若是一幀執行結束後還有時間執行 RIC 事件,那麼下一幀須要在事件執行結束才能繼續渲染,因此 RIC 執行不要超過 30ms,若是長時間不將控制權交還給瀏覽器,會影響下一幀的渲染,致使頁面出現卡頓和事件響應不及時。算法

2. JS 原生執行棧

React Fiber 出現以前,React 經過原生執行棧遞歸遍歷 VDOM。當瀏覽器引擎第一次遇到 JS 代碼時,會產生一個全局執行上下文並將其壓入執行棧,接下來每遇到一個函數調用,又會往棧中壓入一個新的上下文。好比:編程

function A(){
  B();
  C();
}
function B(){}
function C(){}
A();

引擎在執行的時候,會造成以下這樣的執行棧:
圖片 數組

瀏覽器引擎會從執行棧的頂端開始執行,執行完畢就彈出當前執行上下文,開始執行下一個函數,直到執行棧被清空纔會中止。而後將執行權交還給瀏覽器。因爲 React 將頁面視圖視做一個個函數執行的結果。每個頁面每每由多個視圖組成,這就意味着多個函數的調用。瀏覽器

若是一個頁面足夠複雜,造成的函數調用棧就會很深。每一次更新,執行棧須要一次性執行完成,中途不能幹其餘的事兒,只能"一心一意"。結合前面提到的瀏覽器刷新率,JS 一直執行,瀏覽器得不到控制權,就不能及時開始下一幀的繪製。若是這個時間超過 16ms,當頁面有動畫效果需求時,動畫由於瀏覽器不能及時繪製下一幀,這時動畫就會出現卡頓。不只如此,由於事件響應代碼是在每一幀開始的時候執行,若是不能及時繪製下一幀,事件響應也會延遲。前端工程師

3. 時間分片(Time Slicing)

時間分片指的是一種將多個粒度小的任務放入一個時間切片(一幀)中執行的一種方案,在 React Fiber 中就是將多個任務放在了一個時間片中去執行。

4. 鏈表

在 React Fiber 中用鏈表遍歷的方式替代了 React 16 以前的棧遞歸方案。在 React 16 中使用了大量的鏈表。例如:

  • 使用多向鏈表的形式替代了原來的樹結構

例以下面這個組件:

<div id="id">
  A1
  <div id="B1">
    B1
     <div id="C1"></div>
  </div>
  <div id="B2">
      B2
  </div>
</div>

會使用下面這樣的鏈表表示:
圖片

  • 反作用單鏈表

圖片

  • 狀態更新單鏈表

圖片

  • ...

鏈表是一種簡單高效的數據結構,它在當前節點中保存着指向下一個節點的指針,就好像火車同樣一節連着一節

圖片

遍歷的時候,經過操做指針找到下一個元素。可是操做指針時(調整順序和指向)必定要當心。

鏈表相比順序結構數據格式的好處就是:

  1. 操做更高效,好比順序調整、刪除,只須要改變節點的指針指向就行了。
  2. 不只能夠根據當前節點找到下一個節點,在多向鏈表中,還能夠找到他的父節點或者兄弟節點。

但鏈表也不是完美的,缺點就是:

  1. 比順序結構數據更佔用空間,由於每一個節點對象還保存有指向下一個對象的指針。
  2. 不能自由讀取,必須找到他的上一個節點。

React 用空間換時間,更高效的操做能夠方便根據優先級進行操做。同時能夠根據當前節點找到其餘節點,在下面提到的掛起和恢復過程當中起到了關鍵做用。

React Fiber 是如何實現更新過程可控?

前面講完基本知識,如今正式開始介紹今天的主角 Fiber,看看 React Fiber 是如何實現對更新過程的管控。

圖片

更新過程的可控主要體如今下面幾個方面:

  1. 任務拆分
  2. 任務掛起、恢復、終止
  3. 任務具有優先級

1. 任務拆分

前面提到,React Fiber 以前是基於原生執行棧,每一次更新操做會一直佔用主線程,直到更新完成。這可能會致使事件響應延遲,動畫卡頓等現象。

在 React Fiber 機制中,它採用"化整爲零"的戰術,將調和階段(Reconciler)遞歸遍歷 VDOM 這個大任務分紅若干小任務,每一個任務只負責一個節點的處理。例如:

import React from "react";
import ReactDom from "react-dom"
const jsx = (
    <div id="A1">
    A1
    <div id="B1">
      B1
      <div id="C1">C1</div>
      <div id="C2">C2</div>
    </div>
    <div id="B2">B2</div>
  </div>
)
ReactDom.render(jsx,document.getElementById("root"))

這個組件在渲染的時候會被分紅八個小任務,每一個任務用來分別處理 A1(div)、A1(text)、B1(div)、B1(text)、C1(div)、C1(text)、C2(div)、C2(text)、B2(div)、B2(text)。再經過時間分片,在一個時間片中執行一個或者多個任務。這裏提一下,全部的小任務並非一次性被切分完成,而是處理當前任務的時候生成下一個任務,若是沒有下一個任務生成了,就表明本次渲染的 Diff 操做完成。

2. 掛起、恢復、終止

再說掛起、恢復、終止以前,不得不提兩棵 Fiber 樹,workInProgress tree 和 currentFiber tree。

workInProgress 表明當前正在執行更新的 Fiber 樹。在 render 或者 setState 後,會構建一顆 Fiber 樹,也就是 workInProgress tree,這棵樹在構建每個節點的時候會收集當前節點的反作用,整棵樹構建完成後,會造成一條完整的反作用鏈。

currentFiber 表示上次渲染構建的 Filber 樹。在每一次更新完成後 workInProgress 會賦值給 currentFiber。在新一輪更新時 workInProgress tree 再從新構建,新 workInProgress 的節點經過 alternate 屬性和 currentFiber 的節點創建聯繫。

在新 workInProgress tree 的建立過程當中,會同 currentFiber 的對應節點進行 Diff 比較,收集反作用。同時也會複用和 currentFiber 對應的節點對象,減小新建立對象帶來的開銷。也就是說不管是建立仍是更新,掛起、恢復以及終止操做都是發生在 workInProgress tree 建立過程當中。workInProgress tree 構建過程其實就是循環的執行任務和建立下一個任務,大體過程以下:

圖片

當沒有下一個任務須要執行的時候,workInProgress tree 構建完成,開始進入提交階段,完成真實 DOM 更新。

在構建 workInProgressFiber tree 過程當中能夠經過掛起、恢復和終止任務,實現對更新過程的管控。下面簡化了一下源碼,大體實現以下:

let nextUnitWork = null;//下一個執行單元
//開始調度
function shceduler(task){
     nextUnitWork = task; 
}
//循環執行工做
function workLoop(deadline){
  let shouldYield = false;//是否要讓出時間片交出控制權
  while(nextUnitWork && !shouldYield){
    nextUnitWork = performUnitWork(nextUnitWork)
    shouldYield = deadline.timeRemaining()<1 // 沒有時間了,檢出控制權給瀏覽器
  }
  if(!nextUnitWork) {
    conosle.log("全部任務完成")
    //commitRoot() //提交更新視圖
  }
  // 若是還有任務,可是交出控制權後,請求下次調度
  requestIdleCallback(workLoop,{timeout:5000}) 
}
/*
 * 處理一個小任務,其實就是一個 Fiber 節點,若是還有任務就返回下一個須要處理的任務,沒有就表明整個
 */
function performUnitWork(currentFiber){
  ....
  return FiberNode
}

掛起

當第一個小任務完成後,先判斷這一幀是否還有空閒時間,沒有就掛起下一個任務的執行,記住當前掛起的節點,讓出控制權給瀏覽器執行更高優先級的任務。

恢復

在瀏覽器渲染完一幀後,判斷當前幀是否有剩餘時間,若是有就恢復執行以前掛起的任務。若是沒有任務須要處理,表明調和階段完成,能夠開始進入渲染階段。這樣完美的解決了調和過程一直佔用主線程的問題。

那麼問題來了他是如何判斷一幀是否有空閒時間的呢?答案就是咱們前面提到的 RIC (RequestIdleCallback) 瀏覽器原生 API,React 源碼中爲了兼容低版本的瀏覽器,對該方法進行了 Polyfill。

當恢復執行的時候又是如何知道下一個任務是什麼呢?答案在前面提到的鏈表。在 React Fiber 中每一個任務其實就是在處理一個 FiberNode 對象,而後又生成下一個任務須要處理的 FiberNode。順便提一嘴,這裏提到的FiberNode 是一種數據格式,下面是它沒有開美顏的樣子:

class FiberNode {
  constructor(tag, pendingProps, key, mode) {
    // 實例屬性
    this.tag = tag; // 標記不一樣組件類型,如函數組件、類組件、文本、原生組件...
    this.key = key; // react 元素上的 key 就是 jsx 上寫的那個 key ,也就是最終 ReactElement 上的
    this.elementType = null; // createElement的第一個參數,ReactElement 上的 type
    this.type = null; // 表示fiber的真實類型 ,elementType 基本同樣,在使用了懶加載之類的功能時可能會不同
    this.stateNode = null; // 實例對象,好比 class 組件 new 完後就掛載在這個屬性上面,若是是RootFiber,那麼它上面掛的是 FiberRoot,若是是原生節點就是 dom 對象
    // fiber
    this.return = null; // 父節點,指向上一個 fiber
    this.child = null; // 子節點,指向自身下面的第一個 fiber
    this.sibling = null; // 兄弟組件, 指向一個兄弟節點
    this.index = 0; //  通常若是沒有兄弟節點的話是0 當某個父節點下的子節點是數組類型的時候會給每一個子節點一個 index,index 和 key 要一塊兒作 diff
    this.ref = null; // reactElement 上的 ref 屬性
    this.pendingProps = pendingProps; // 新的 props
    this.memoizedProps = null; // 舊的 props
    this.updateQueue = null; // fiber 上的更新隊列執行一次 setState 就會往這個屬性上掛一個新的更新, 每條更新最終會造成一個鏈表結構,最後作批量更新
    this.memoizedState = null; // 對應  memoizedProps,上次渲染的 state,至關於當前的 state,理解成 prev 和 next 的關係
    this.mode = mode; // 表示當前組件下的子組件的渲染方式
    // effects
    this.effectTag = NoEffect; // 表示當前 fiber 要進行何種更新
    this.nextEffect = null; // 指向下個須要更新的fiber
    this.firstEffect = null; // 指向全部子節點裏,須要更新的 fiber 裏的第一個
    this.lastEffect = null; // 指向全部子節點中須要更新的 fiber 的最後一個
    this.expirationTime = NoWork; // 過時時間,表明任務在將來的哪一個時間點應該被完成
    this.childExpirationTime = NoWork; // child 過時時間
    this.alternate = null; // current 樹和 workInprogress 樹之間的相互引用
  }
}

額…看着好像有點上頭,這是開了美顏的樣子:

圖片

是否是好看多了?在每次循環的時候,找到下一個執行須要處理的節點。

function performUnitWork(currentFiber){
    //beginWork(currentFiber) //找到兒子,並經過鏈表的方式掛到currentFiber上,每一偶兒子就找後面那個兄弟
  //有兒子就返回兒子
  if(currentFiber.child){
    return currentFiber.child;
  } 
  //若是沒有兒子,則找弟弟
  while(currentFiber){//一直往上找
    //completeUnitWork(currentFiber);//將本身的反作用掛到父節點去
    if(currentFiber.sibling){
      return currentFiber.sibling
    }
    currentFiber = currentFiber.return;
  }
}

在一次任務結束後返回該處理節點的子節點或兄弟節點或父節點。只要有節點返回,說明還有下一個任務,下一個任務的處理對象就是返回的節點。經過一個全局變量記住當前任務節點,當瀏覽器再次空閒的時候,經過這個全局變量,找到它的下一個任務須要處理的節點恢復執行。就這樣一直循環下去,直到沒有須要處理的節點返回,表明全部任務執行完成。最後你們手拉手,就造成了一顆 Fiber 樹。

圖片

終止

其實並非每次更新都會走到提交階段。當在調和過程當中觸發了新的更新,在執行下一個任務的時候,判斷是否有優先級更高的執行任務,若是有就終止原來將要執行的任務,開始新的 workInProgressFiber 樹構建過程,開始新的更新流程。這樣能夠避免重複更新操做。這也是在 React 16 之後生命週期函數 componentWillMount 有可能會執行屢次的緣由。

3. 任務具有優先級

React Fiber 除了經過掛起,恢復和終止來控制更新外,還給每一個任務分配了優先級。具體點就是在建立或者更新 FiberNode 的時候,經過算法給每一個任務分配一個到期時間(expirationTime)。在每一個任務執行的時候除了判斷剩餘時間,若是當前處理節點已通過期,那麼不管如今是否有空閒時間都必須執行改任務。

圖片

同時過時時間的大小還表明着任務的優先級。

任務在執行過程當中順便收集了每一個 FiberNode 的反作用,將有反作用的節點經過 firstEffect、lastEffect、nextEffect 造成一條反作用單鏈表 AI(TEXT)-B1(TEXT)-C1(TEXT)-C1-C2(TEXT)-C2-B1-B2(TEXT)-B2-A。

圖片

其實最終都是爲了收集到這條反作用鏈表,有了它,在接下來的渲染階段就經過遍歷反作用鏈完成 DOM 更新。這裏須要注意,更新真實 DOM 的這個動做是一鼓作氣的,不能中斷,否則會形成視覺上的不連貫。

關於 React Fiber 的思考

1. 可否使用生成器(generater)替代鏈表

在 Fiber 機制中,最重要的一點就是須要實現掛起和恢復,從實現角度來講 generator 也能夠實現。那麼爲何官方沒有使用 generator 呢?猜想應該是是性能方面的緣由。生成器不只讓您在堆棧的中間讓步,還必須把每一個函數包裝在一個生成器中。一方面增長了許多語法方面的開銷,另外還增長了任何現有實現的運行時開銷。性能上遠沒有鏈表的方式好,並且鏈表不須要考慮瀏覽器兼容性。

2. Vue 是否會採用 Fiber 機制來優化複雜頁面的更新

這個問題其實有點搞事情,若是 Vue 真這麼作了是否是就是變相認可 Vue 是在"集成" Angular 和 React 的優勢呢?React 有 Fiber,Vue 就必定要有?

二者雖然都依賴 DOM Diff,可是實現上且有區別,DOM Diff 的目的都是收集反作用。Vue 經過 Watcher 實現了依賴收集,自己就是一種很好的優化。因此 Vue 沒有采用 Fiber 機制,也無傷大雅。

總結

React Fiber 的出現至關因而在更新過程當中引進了一箇中場指揮官,負責掌控更新過程,足球世界裏管這叫前腰。拋開帶來的性能和效率提高外,這種「化整爲零」和任務編排的思想,能夠應用到咱們平時的架構設計中。

招賢納士

政採雲前端團隊(ZooTeam),一個年輕富有激情和創造力的前端團隊,隸屬於政採雲產品研發部,Base 在風景如畫的杭州。團隊現有 40 餘個前端小夥伴,平均年齡 27 歲,近 3 成是全棧工程師,妥妥的青年風暴團。成員構成既有來自於阿里、網易的「老」兵,也有浙大、中科大、杭電等校的應屆新人。團隊在平常的業務對接以外,還在物料體系、工程平臺、搭建平臺、性能體驗、雲端應用、數據分析及可視化等方向進行技術探索和實戰,推進並落地了一系列的內部技術產品,持續探索前端技術體系的新邊界。

若是你想改變一直被事折騰,但願開始能折騰事;若是你想改變一直被告誡須要多些想法,卻無從破局;若是你想改變你有能力去作成那個結果,卻不須要你;若是你想改變你想作成的事須要一個團隊去支撐,但沒你帶人的位置;若是你想改變既定的節奏,將會是「5 年工做時間 3 年工做經驗」;若是你想改變原本悟性不錯,但老是有那一層窗戶紙的模糊… 若是你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的本身。若是你但願參與到隨着業務騰飛的過程,親手推進一個有着深刻的業務理解、完善的技術體系、技術創造價值、影響力外溢的前端團隊的成長曆程,我以爲咱們該聊聊。任什麼時候間,等着你寫點什麼,發給 ZooTeam@cai-inc.com

相關文章
相關標籤/搜索