開發與維運

走進 React Fiber 的世界

作者 | F(x) Team - 冷卉

image.png

Fiber 設計思想

Fiber 是對 React 核心算法的重構,facebook 團隊使用兩年多的時間去重構 React 的核心算法,在React16 以上的版本中引入了 Fiber 架構,其中的設計思想是非常值得我們學習的。

為什麼需要 Fiber

我們知道,在瀏覽器中,頁面是一幀一幀繪製出來的,渲染的幀率與設備的刷新率保持一致。一般情況下,設備的屏幕刷新率為1s 60次,當每秒內繪製的幀數(FPS)超過60時,頁面渲染是流暢的;而當FPS小於60時,會出現一定程度的卡頓現象。下面來看完整的一幀中,具體做了哪些事情:
image.png

  1. 首先需要處理輸入事件,能夠讓用戶得到最早的反饋
  2. 接下來是處理定時器,需要檢查定時器是否到時間,並執行對應的回調
  3. 接下來處理 Begin Frame(開始幀),即每一幀的事件,包括 window.resize、scroll、media query change 等
  4. 接下來執行請求動畫幀 requestAnimationFrame(rAF),即在每次繪製之前,會執行 rAF 回調
  5. 緊接著進行 Layout 操作,包括計算佈局和更新佈局,即這個元素的樣式是怎樣的,它應該在頁面如何展示
  6. 接著進行 Paint 操作,得到樹中每個節點的尺寸與位置等信息,瀏覽器針對每個元素進行內容填充
  7. 到這時以上的六個階段都已經完成了,接下來處於空閒階段(Idle Peroid),可以在這時執行 requestIdleCallback 裡註冊的任務(後面會詳細講到這個 requestIdleCallback ,它是 React Fiber 實現的基礎)

js引擎和頁面渲染引擎是在同一個渲染線程之內,兩者是互斥關係。如果在某個階段執行任務特別長,例如在定時器階段或Begin Frame階段執行時間非常長,時間已經明顯超過了16ms,那麼就會阻塞頁面的渲染,從而出現卡頓現象。

在 react16 引入 Fiber 架構之前,react 會採用遞歸對比虛擬DOM樹,找出需要變動的節點,然後同步更新它們,這個過程 react 稱為reconcilation(協調)。在reconcilation期間,react 會一直佔用瀏覽器資源,會導致用戶觸發的事件得不到響應。實現的原理如下所示:
image.png
這裡有7個節點,B1、B2 是 A1 的子節點,C1、C2 是 B1 的子節點,C3、C4 是 B2 的子節點。傳統的做法就是採用深度優先遍歷去遍歷節點,具體代碼如下:

const root = {
  key: 'A1',
  children: [{
    key: 'B1',
    children: [{
      key: 'C1',
      children: []
    }, {
      key: 'C2',
      children: []
    }]
  }, {
    key: 'B2',
    children: [{
      key: 'C3',
      children: []
    }, {
      key: 'C4',
      children: []
    }]
  }]
}
const walk = dom => {
  console.log(dom.key)
  dom.children.forEach(child => walk(child))
}
walk(root)

打印:

A1
B1
C1
C2
B2
C3
C4

這種遍歷是遞歸調用,執行棧會越來越深,而且不能中斷,中斷後就不能恢復了。遞歸如果非常深,就會十分卡頓。如果遞歸花了100ms,則這100ms瀏覽器是無法響應的,代碼執行時間越長卡頓越明顯。傳統的方法存在不能中斷和執行棧太深的問題。

因此,為了解決以上的痛點問題,React希望能夠徹底解決主線程長時間佔用問題,於是引入了 Fiber 來改變這種不可控的現狀,把渲染/更新過程拆分為一個個小塊的任務,通過合理的調度機制來調控時間,指定任務執行的時機,從而降低頁面卡頓的概率,提升頁面交互體驗。通過Fiber架構,讓reconcilation過程變得可被中斷。適時地讓出CPU執行權,可以讓瀏覽器及時地響應用戶的交互。

React16中使用了 Fiber,但是 Vue 是沒有 Fiber 的,為什麼呢?原因是二者的優化思路不一樣:

  1. Vue 是基於 template 和 watcher 的組件級更新,把每個更新任務分割得足夠小,不需要使用到 Fiber 架構,將任務進行更細粒度的拆分
  2. React 是不管在哪裡調用 setState,都是從根節點開始更新的,更新任務還是很大,需要使用到 Fiber 將大任務分割為多個小任務,可以中斷和恢復,不阻塞主進程執行高優先級的任務

下面,讓我們走進 Fiber 的世界,看看具體是怎麼實現的。

什麼是 Fiber

Fiber 可以理解為是一個執行單元,也可以理解為是一種數據結構。

一個執行單元

Fiber 可以理解為一個執行單元,每次執行完一個執行單元,react 就會檢查現在還剩多少時間,如果沒有時間則將控制權讓出去。React Fiber 與瀏覽器的核心交互流程如下:
image.png
首先 React 向瀏覽器請求調度,瀏覽器在一幀中如果還有空閒時間,會去判斷是否存在待執行任務,不存在就直接將控制權交給瀏覽器,如果存在就會執行對應的任務,執行完成後會判斷是否還有時間,有時間且有待執行任務則會繼續執行下一個任務,否則就會將控制權交給瀏覽器。這裡會有點繞,可以結合上述的圖進行理解。

Fiber 可以被理解為劃分一個個更小的執行單元,它是把一個大任務拆分為了很多個小塊任務,一個小塊任務的執行必須是一次完成的,不能出現暫停,但是一個小塊任務執行完後可以移交控制權給瀏覽器去響應用戶,從而不用像之前一樣要等那個大任務一直執行完成再去響應用戶。

一種數據結構

Fiber 還可以理解為是一種數據結構,React Fiber 就是採用鏈表實現的。每個 Virtual DOM 都可以表示為一個 fiber,如下圖所示,每個節點都是一個 fiber。一個 fiber包括了 child(第一個子節點)、sibling(兄弟節點)、return(父節點)等屬性,React Fiber 機制的實現,就是依賴於以下的數據結構。在下文中會講到基於這個鏈表結構,Fiber 究竟是如何實現的。

PS:這裡需要說明一下,Fiber 是 React 進行重構的核心算法,fiber 是指數據結構中的每一個節點,如下圖所示的A1、B1都是一個 fiber。
image.png

requestAnimationFrame

在 Fiber 中使用到了requestAnimationFrame,它是瀏覽器提供的繪製動畫的 api 。它要求瀏覽器在下次重繪之前(即下一幀)調用指定的回調函數更新動畫。

例如我想讓瀏覽器在每一幀中,將頁面 div 元素的寬變長1px,直到寬度達到100px停止,這時就可以採用requestAnimationFrame來實現這個功能。

<body>
  <div id="div" class="progress-bar "></div>
  <button id="start">開始動畫</button>
</body>

<script>
  let btn = document.getElementById('start')
  let div = document.getElementById('div')
  let start = 0
  let allInterval = []

  const progress = () => {
    div.style.width = div.offsetWidth + 1 + 'px'
    div.innerHTML = (div.offsetWidth) + '%'
    if (div.offsetWidth < 100) {
      let current = Date.now()
      allInterval.push(current - start)
      start = current
      requestAnimationFrame(progress)
    } else {
      console.log(allInterval) // 打印requestAnimationFrame的全部時間間隔
    }
  }

  btn.addEventListener('click', () => {
    div.style.width = 0
    let currrent = Date.now()
    start = currrent
    requestAnimationFrame(progress)
    console.log(allInterval)
  })
</script>

瀏覽器會在每一幀中,將div的寬度變寬1px,知道到達100px為止。打印出每一幀的時間間隔如下,大約是16ms左右。

image.png

requestIdleCallback

requestIdleCallback 也是 react Fiber 實現的基礎 api 。我們希望能夠快速響應用戶,讓用戶覺得夠快,不能阻塞用戶的交互,requestIdleCallback能使開發者在主事件循環上執行後臺和低優先級的工作,而不影響延遲關鍵事件,如動畫和輸入響應。正常幀任務完成後沒超過16ms,說明有多餘的空閒時間,此時就會執行requestIdleCallback裡註冊的任務。

具體的執行流程如下,開發者採用requestIdleCallback方法註冊對應的任務,告訴瀏覽器我的這個任務優先級不高,如果每一幀內存在空閒時間,就可以執行註冊的這個任務。另外,開發者是可以傳入timeout參數去定義超時時間的,如果到了超時時間了,瀏覽器必須立即執行,使用方法如下:window.requestIdleCallback(callback, { timeout: 1000 })。瀏覽器執行完這個方法後,如果沒有剩餘時間了,或者已經沒有下一個可執行的任務了,React應該歸還控制權,並同樣使用requestIdleCallback去申請下一個時間片。具體的流程如下圖:
image.png
window.requestIdleCallback(callback)callback中會接收到默認參數 deadline ,其中包含了以下兩個屬性:

  • timeRamining 返回當前幀還剩多少時間供用戶使用
  • didTimeout 返回 callback 任務是否超時

requestIdleCallback 方法非常重要,下面分別講兩個例子來理解這個方法,在每個例子中都需要執行多個任務,但是任務的執行時間是不一樣的,下面來看瀏覽器是如何分配時間執行這些任務的:

一幀執行

直接執行task1、task2、task3,各任務的時間均小於16ms:

let taskQueue = [
  () => {
    console.log('task1 start')
    console.log('task1 end')
  },
  () => {
    console.log('task2 start')
    console.log('task2 end')
  },
  () => {
    console.log('task3 start')
    console.log('task3 end')
  }
]

const performUnitWork = () => {
  // 取出第一個隊列中的第一個任務並執行
  taskQueue.shift()()
}

const workloop = (deadline) => {
  console.log(`此幀的剩餘時間為: ${deadline.timeRemaining()}`)
  // 如果此幀剩餘時間大於0或者已經到了定義的超時時間(上文定義了timeout時間為1000,到達時間時必須強制執行),且當時存在任務,則直接執行這個任務
  // 如果沒有剩餘時間,則應該放棄執行任務控制權,把執行權交還給瀏覽器
  while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && taskQueue.length > 0) {
    performUnitWork()
  }

  // 如果還有未完成的任務,繼續調用requestIdleCallback申請下一個時間片
  if (taskQueue.length > 0) {
    window.requestIdleCallback(workloop, { timeout: 1000 })
  }
}

requestIdleCallback(workloop, { timeout: 1000 })

上面定義了一個任務隊列taskQueue,並定義了workloop函數,其中採用window.requestIdleCallback(workloop, { timeout: 1000 })去執行taskQueue中的任務。每個任務中僅僅做了console.log的工作,時間是非常短的,瀏覽器計算此幀中還剩餘15.52ms,足以一次執行完這三個任務,因此在此幀的空閒時間中,taskQueue中定義的三個任務均執行完畢。打印結果如下:
image.png

多幀執行

在task1、task2、task3中加入睡眠時間,各自執行時間超過16ms:

const sleep = delay => {
  for (let start = Date.now(); Date.now() - start <= delay;) {}
}

let taskQueue = [
  () => {
    console.log('task1 start')
    sleep(20) // 已經超過一幀的時間(16.6ms),需要把控制權交給瀏覽器
    console.log('task1 end')
  },
  () => {
    console.log('task2 start')
    sleep(20) // 已經超過一幀的時間(16.6ms),需要把控制權交給瀏覽器
    console.log('task2 end')
  },
  () => {
    console.log('task3 start')
    sleep(20) // 已經超過一幀的時間(16.6ms),需要把控制權交給瀏覽器
    console.log('task3 end')
  }
]

基於以上的例子做了部分改造,讓taskQueue中的每個任務的執行時間都超過16.6ms,看打印結果知道瀏覽器第一幀的空閒時間為14ms,只能執行一個任務,同理,在第二幀、第三幀的時間也只夠執行一個任務。所有這三個任務分別是在三幀中分別完成的。打印結果如下:

image.png
瀏覽器一幀的時間並不嚴格是16ms,是可以動態控制的(如第三幀剩餘時間為49.95ms)。如果子任務的時間超過了一幀的剩餘時間,則會一直卡在這裡執行,直到子任務執行完畢。如果代碼存在死循環,則瀏覽器會卡死。如果此幀的剩餘時間大於0(有空閒時間)或者已經超時(上文定義了 timeout 時間為1000,必須強制執行了),且當時存在任務,則直接執行該任務。如果沒有剩餘時間,則應該放棄執行任務控制權,把執行權交還給瀏覽器。如果多個任務執行總時間小於空閒時間的話,是可以在一幀內執行多個任務的。

Fiber鏈表結構設計

Fiber結構是使用鏈表實現的,Fiber tree實際上是個單鏈表樹結構,詳見ReactFiber.js源碼,在這裡我們看看Fiber的鏈表結構是怎樣的,瞭解了這個鏈表結構後,能更快地理解後續 Fiber 的遍歷過程。
image.png
以上每一個單元包含了payload(數據)和nextUpdate(指向下一個單元的指針),定義結構如下:

class Update {
  constructor(payload, nextUpdate) {
    this.payload = payload // payload 數據
    this.nextUpdate = nextUpdate // 指向下一個節點的指針
  }
}

接下來定義一個隊列,把每個單元串聯起來,其中定義了兩個指針:頭指針firstUpdate和尾指針lastUpdate,作用是指向第一個單元和最後一個單元,並加入了baseState屬性存儲React中的state狀態。如下所示:

class UpdateQueue {
  constructor() {
    this.baseState = null // state
    this.firstUpdate = null // 第一個更新
    this.lastUpdate = null // 最後一個更新
  }
}

接下來定義兩個方法:插入節點單元(enqueueUpdate)、更新隊列(forceUpdate)。插入節點單元時需要考慮是否已經存在節點,如果不存在直接將firstUpdatelastUpdate指向此節點即可。更新隊列是遍歷這個鏈表,根據payload中的內容去更新state的值。


class UpdateQueue {
  //.....
  
  enqueueUpdate(update) {
    // 當前鏈表是空鏈表
    if (!this.firstUpdate) {
      this.firstUpdate = this.lastUpdate = update
    } else {
      // 當前鏈表不為空
      this.lastUpdate.nextUpdate = update
      this.lastUpdate = update
    }
  }
  
  // 獲取state,然後遍歷這個鏈表,進行更新
  forceUpdate() {
    let currentState = this.baseState || {}
    let currentUpdate = this.firstUpdate
    while (currentUpdate) {
      // 判斷是函數還是對象,是函數則需要執行,是對象則直接返回
      let nextState = typeof currentUpdate.payload === 'function' ? currentUpdate.payload(currentState) : currentUpdate.payload
      currentState = { ...currentState, ...nextState }
      currentUpdate = currentUpdate.nextUpdate
    }
    // 更新完成後清空鏈表
    this.firstUpdate = this.lastUpdate = null
    this.baseState = currentState
    return currentState
  }
}

最後寫一個demo,實例化一個隊列,向其中加入很多節點,再更新這個隊列:

let queue = new UpdateQueue()
queue.enqueueUpdate(new Update({ name: 'www' }))
queue.enqueueUpdate(new Update({ age: 10 }))
queue.enqueueUpdate(new Update(state => ({ age: state.age + 1 })))
queue.enqueueUpdate(new Update(state => ({ age: state.age + 1 })))
queue.forceUpdate()
console.log(queue.baseState);

打印結果如下:

{ name:'www',age:12 }

Fiber 節點設計

Fiber 的拆分單位是 fiber(fiber tree上的一個節點),實際上就是按虛擬DOM節點拆,我們需要根據虛擬dom去生成 Fiber 樹。下文中我們把每一個節點叫做 fiber 。fiber 節點結構如下,源碼詳見ReactInternalTypes.js。

{
    
    type: any, // 對於類組件,它指向構造函數;對於DOM元素,它指定HTML tag
    key: null | string, // 唯一標識符
    stateNode: any, // 保存對組件的類實例,DOM節點或與fiber節點關聯的其他React元素類型的引用
    child: Fiber | null, // 大兒子
    sibling: Fiber | null, // 下一個兄弟
    return: Fiber | null, // 父節點
    tag: WorkTag, // 定義fiber操作的類型, 詳見https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactWorkTags.js
    nextEffect: Fiber | null, // 指向下一個節點的指針
    updateQueue: mixed, // 用於狀態更新,回調函數,DOM更新的隊列
    memoizedState: any, // 用於創建輸出的fiber狀態
    pendingProps: any, // 已從React元素中的新數據更新,並且需要應用於子組件或DOM元素的props
    memoizedProps: any, // 在前一次渲染期間用於創建輸出的props
    // ……     
}

fiber 節點包括了以下的屬性:

(1)type & key

  • fiber 的 type 和 key 與 React 元素的作用相同。fiber 的 type 描述了它對應的組件,對於複合組件,type 是函數或類組件本身。對於原生標籤(div,span等),type 是一個字符串。隨著 type 的不同,在 reconciliation 期間使用 key 來確定 fiber 是否可以重新使用。

(2)stateNode

  • stateNode 保存對組件的類實例,DOM節點或與 fiber 節點關聯的其他 React 元素類型的引用。一般來說,可以認為這個屬性用於保存與 fiber 相關的本地狀態。

(3)child & sibling & return

  • child 屬性指向此節點的第一個子節點(大兒子)。
  • sibling 屬性指向此節點的下一個兄弟節點(大兒子指向二兒子、二兒子指向三兒子)。
  • return 屬性指向此節點的父節點,即當前節點處理完畢後,應該向誰提交自己的成果。如果 fiber 具有多個子 fiber,則每個子 fiber 的 return fiber 是 parent 。

所有 fiber 節點都通過以下屬性:child,sibling 和 return來構成一個 fiber node 的 linked list(後面我們稱之為鏈表)。如下圖所示:
image.png
其他的屬性還有memoizedState(創建輸出的 fiber 的狀態)、pendingProps(將要改變的 props )、memoizedProps(上次渲染創建輸出的 props )、pendingWorkPriority(定義 fiber 工作優先級)等等,在這裡就不展開描述了。

Fiber 執行原理

從根節點開始渲染和調度的過程可以分為兩個階段:render 階段、commit 階段。

  • render 階段:這個階段是可中斷的,會找出所有節點的變更
  • commit 階段:這個階段是不可中斷的,會執行所有的變更

render 階段

此階段會找出所有節點的變更,如節點新增、刪除、屬性變更等,這些變更 react 統稱為副作用(effect),此階段會構建一棵Fiber tree,以虛擬dom節點為維度對任務進行拆分,即一個虛擬dom節點對應一個任務,最後產出的結果是effect list,從中可以知道哪些節點更新、哪些節點增加、哪些節點刪除了。

遍歷流程

React Fiber首先是將虛擬DOM樹轉化為Fiber tree,因此每個節點都有childsiblingreturn屬性,遍歷Fiber tree時採用的是後序遍歷方法:

  1. 從頂點開始遍歷
  2. 如果有大兒子,先遍歷大兒子;如果沒有大兒子,則表示遍歷完成
  3. 大兒子:
    a. 如果有弟弟,則返回弟弟,跳到2
    b. 如果沒有弟弟,則返回父節點,並標誌完成父節點遍歷,跳到2
    d. 如果沒有父節點則標誌遍歷結束

image.png
定義樹結構:

const A1 = { type: 'div', key: 'A1' }
const B1 = { type: 'div', key: 'B1', return: A1 }
const B2 = { type: 'div', key: 'B2', return: A1 }
const C1 = { type: 'div', key: 'C1', return: B1 }
const C2 = { type: 'div', key: 'C2', return: B1 }
const C3 = { type: 'div', key: 'C3', return: B2 }
const C4 = { type: 'div', key: 'C4', return: B2 }

A1.child = B1
B1.sibling = B2
B1.child = C1
C1.sibling = C2
B2.child = C3
C3.sibling = C4

module.exports = A1

寫遍歷方法:

let rootFiber = require('./element')

const beginWork = (Fiber) => {
  console.log(`${Fiber.key} start`)
}

const completeUnitWork = (Fiber) => {
  console.log(`${Fiber.key} end`)
}

// 遍歷函數
const performUnitOfWork = (Fiber) => {
  beginWork(Fiber)
  if (Fiber.child) {
    return Fiber.child
  }
  while (Fiber) {
    completeUnitWork(Fiber)
    if (Fiber.sibling) {
      return Fiber.sibling
    }
    Fiber = Fiber.return
  }
}

const workloop = (nextUnitOfWork) => {
  // 如果有待執行的執行單元則執行,返回下一個執行單元
  while (nextUnitOfWork) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
  }
  if (!nextUnitOfWork) {
    console.log('reconciliation階段結束')
  }
}

workloop(rootFiber)

打印結果:

A1 start
B1 start
C1 start
C1 end // C1完成
C2 start
C2 end // C2完成
B1 end // B1完成
B2 start
C3 start
C3 end // C3完成
C4 start
C4 end // C4完成
B2 end // B2完成
A1 end // A1完成
reconciliation階段結束

收集effect list

知道了遍歷方法之後,接下來需要做的工作就是在遍歷過程中,收集所有節點的變更產出effect list,注意其中只包含了需要變更的節點。通過每個節點更新結束時向上歸併effect list來收集任務結果,最後根節點的effect list裡就記錄了包括了所有需要變更的結果。

收集effect list的具體步驟為:

  1. 如果當前節點需要更新,則打tag更新當前節點狀態(props, state, context等)
  2. 為每個子節點創建fiber。如果沒有產生child fiber,則結束該節點,把effect list歸併到return,把此節點的sibling節點作為下一個遍歷節點;否則把child節點作為下一個遍歷節點
  3. 如果有剩餘時間,則開始下一個節點,否則等下一次主線程空閒再開始下一個節點
  4. 如果沒有下一個節點了,進入pendingCommit狀態,此時effect list收集完畢,結束。

收集effect list的遍歷順序如下所示:
image.png
遍歷子虛擬DOM元素數組,為每個虛擬DOM元素創建子fiber:

const reconcileChildren = (currentFiber, newChildren) => {
  let newChildIndex = 0
  let prevSibling // 上一個子fiber

  // 遍歷子虛擬DOM元素數組,為每個虛擬DOM元素創建子fiber
  while (newChildIndex < newChildren.length) {
    let newChild = newChildren[newChildIndex]
    let tag
    // 打tag,定義 fiber類型
    if (newChild.type === ELEMENT_TEXT) { // 這是文本節點
      tag = TAG_TEXT
    } else if (typeof newChild.type === 'string') {  // 如果type是字符串,則是原生DOM節點
      tag = TAG_HOST
    }
    let newFiber = {
      tag,
      type: newChild.type,
      props: newChild.props,
      stateNode: null, // 還未創建DOM元素
      return: currentFiber, // 父親fiber
      effectTag: INSERT, // 副作用標識,包括新增、刪除、更新
      nextEffect: null, // 指向下一個fiber,effect list通過nextEffect指針進行連接
    }
    if (newFiber) {
      if (newChildIndex === 0) {
        currentFiber.child = newFiber // child為大兒子
      } else {
        prevSibling.sibling = newFiber // 讓大兒子的sibling指向二兒子
      }
      prevSibling = newFiber
    }
    newChildIndex++
  }
}

定義一個方法收集此 fiber 節點下所有的副作用,並組成effect list。注意每個 fiber 有兩個屬性:

  • firstEffect:指向第一個有副作用的子fiber
  • lastEffect:指向最後一個有副作用的子fiber

中間的使用nextEffect做成一個單鏈表。

// 在完成的時候要收集有副作用的fiber,組成effect list
const completeUnitOfWork = (currentFiber) => {
  // 後續遍歷,兒子們完成之後,自己才能完成。最後會得到以上圖中的鏈條結構。
  let returnFiber = currentFiber.return
  if (returnFiber) {
    // 如果父親fiber的firstEffect沒有值,則將其指向當前fiber的firstEffect
    if (!returnFiber.firstEffect) {
      returnFiber.firstEffect = currentFiber.firstEffect
    }
    // 如果當前fiber的lastEffect有值
    if (currentFiber.lastEffect) {
      if (returnFiber.lastEffect) {
        returnFiber.lastEffect.nextEffect = currentFiber.firstEffect
      }
      returnFiber.lastEffect = currentFiber.lastEffect
    }
    const effectTag = currentFiber.effectTag
    if (effectTag) { // 說明有副作用
      // 每個fiber有兩個屬性:
      // 1)firstEffect:指向第一個有副作用的子fiber
      // 2)lastEffect:指向最後一個有副作用的子fiber
      // 中間的使用nextEffect做成一個單鏈表
      if (returnFiber.lastEffect) {
        returnFiber.lastEffect.nextEffect = currentFiber
      } else {
        returnFiber.firstEffect = currentFiber
      }
      returnFiber.lastEffect = currentFiber
    }
  }
}

接下來定義一個遞歸函數,從根節點出發,把全部的 fiber 節點遍歷一遍,產出最終全部的effect list

// 把該節點和子節點任務都執行完
const performUnitOfWork = (currentFiber) => {
  beginWork(currentFiber)
  if (currentFiber.child) {
    return currentFiber.child
  }
  while (currentFiber) {
    completeUnitOfWork(currentFiber) // 讓自己完成
    if (currentFiber.sibling) { // 有弟弟則返回弟弟
      return currentFiber.sibling
    }
    currentFiber = currentFiber.return // 沒有弟弟,則找到父親,讓父親完成,父親會去找他的弟弟即叔叔
  }
}

commit階段

commit 階段需要將上階段計算出來的需要處理的副作用一次性執行,此階段不能暫停,否則會出現UI更新不連續的現象。此階段需要根據effect list,將所有更新都 commit 到DOM樹上。

根據一個 fiber 的 effect list 更新視圖

根據一個 fiber 的effect list列表去更新視圖(這裡只列舉了新增節點、刪除節點、更新節點的三種操作):

const commitWork = currentFiber => {
  if (!currentFiber) return
  let returnFiber = currentFiber.return
  let returnDOM = returnFiber.stateNode // 父節點元素
  if (currentFiber.effectTag === INSERT) {  // 如果當前fiber的effectTag標識位INSERT,則代表其是需要插入的節點
    returnDOM.appendChild(currentFiber.stateNode)
  } else if (currentFiber.effectTag === DELETE) {  // 如果當前fiber的effectTag標識位DELETE,則代表其是需要刪除的節點
    returnDOM.removeChild(currentFiber.stateNode)
  } else if (currentFiber.effectTag === UPDATE) {  // 如果當前fiber的effectTag標識位UPDATE,則代表其是需要更新的節點
    if (currentFiber.type === ELEMENT_TEXT) {
      if (currentFiber.alternate.props.text !== currentFiber.props.text) {
        currentFiber.stateNode.textContent = currentFiber.props.text
      }
    }
  }
  currentFiber.effectTag = null
}

根據全部 fiber 的 effect list 更新視圖

寫一個遞歸函數,從根節點出發,根據effect list完成全部更新:

const commitRoot = () => {
  let currentFiber = workInProgressRoot.firstEffect
  while (currentFiber) {
    commitWork(currentFiber)
    currentFiber = currentFiber.nextEffect
  }
  currentRoot = workInProgressRoot // 把當前渲染成功的根fiber賦給currentRoot
  workInProgressRoot = null
}

完成視圖更新

接下來定義循環執行工作,當計算完成每個 fiber 的effect list後,調用 commitRoot 完成視圖更新:

const workloop = (deadline) => {
  let shouldYield = false // 是否需要讓出控制權
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
    shouldYield = deadline.timeRemaining() < 1 // 如果執行完任務後,剩餘時間小於1ms,則需要讓出控制權給瀏覽器
  }
  if (!nextUnitOfWork && workInProgressRoot) {
    console.log('render階段結束')
    commitRoot() // 沒有下一個任務了,根據effect list結果批量更新視圖
  }
  // 請求瀏覽器進行再次調度
  requestIdleCallback(workloop, { timeout: 1000 })
}

到這時,已經根據收集到的變更信息,完成了視圖的刷新操作。

總結

本文是為了讓大家對 React Fiber 能有一個大致的瞭解,本文介紹了為什麼在 React 中要引入 Fiber 機制,它的設計思想是什麼,以及在代碼中是如何一點點實現的。但是仍然有很多的點沒有覆蓋到,例如如何定義調度任務優先級、如何進行任務中斷與斷點恢復……感興趣的朋友可以結合 react 源碼繼續研究。


image.png

Leave a Reply

Your email address will not be published. Required fields are marked *