淺析 React Fiber

_墨白發表於2018-11-12

引言

在 react 進入大家視野之初,Virtual DOM(VDOM)的概念讓人眼前一亮,在操作真正的 DOM 之前,先通過 VDOM 前後對比得出需要更新的部分,再去操作真實的 DOM,減少了瀏覽器多次操作 DOM 的成本。這一過程,官方起名 reconciliation,可翻譯為協調演算法。但是 react 發展到今日,隨著前端應用的量級越來越大,reconciliation 已經日顯疲憊,React Fiber 應運而出。React Fiber 是對 React 核心演算法的重寫,由 React 團隊歷時兩年多完成。

動機

當時被大家拍手叫好的 VDOM,為什麼今日會略顯疲態,這還要從它的工作原理說起。在 react 釋出之初,設想未來的 UI 渲染會是非同步的,從 setState() 的設計和 react 內部的事務機制可以看出這點。在 react@16 以前的版本,reconciler(現被稱為 stack reconciler )採用自頂向下遞迴,從根元件或 setState() 後的元件開始,更新整個子樹。如果元件樹不大不會有問題,但是當元件樹越來越大,遞迴遍歷的成本就越高,持續佔用主執行緒,這樣主執行緒上的佈局、動畫等週期性任務以及互動響應就無法立即得到處理,造成頓卡的視覺效果。

理論上人眼最高能識別的幀數不超過 30 幀,電影的幀數大多固定在 24,瀏覽器最優的幀率是 60,即16.5ms 左右渲染一次。 瀏覽器正常的工作流程應該是這樣的,運算 -> 渲染 -> 運算 -> 渲染 -> 運算 -> 渲染 …

img

但是當 JS 執行時間過長,就變成了這個樣子,FPS(每秒顯示幀數)下降造成視覺上的頓卡。

img

那麼這個問題如何解決,這就是 fiber reconciler 要做的事了。簡而言之可以看下圖,將要執行的 JS 做拆分,保證不會阻塞主執行緒(Main thread)即可。

img

工作原理

將同步任務拆分大家都能理解,但在拆分之前我們面臨以下幾個問題:

  • 拆什麼?
  • 如何拆?
  • 拆分後的執行順序如何?

拆什麼

# React@15
DOM 真實DOM節點
-------
Instances React 維護的 VDOM tree node
-------
Elements 描述 UI 長什麼樣子(type, props)
複製程式碼

在 react@15 中,更新主要分為兩個步驟完成: 1. diff diff 的實際工作是對比 prevInstance 和 nextInstance 的狀態,找出差異及其對應的 VDOM change。diff 本質上是一些計算(遍歷、比較),是可拆分的(算一半待會兒接著算)。 2. patch 將 diff 演算法計算出來的差異佇列更新到真實的 DOM 節點上。React 並不是計算出一個差異就執行一次 patch,而是計算出全部的差異並放入差異佇列後,再一次性的去執行 patch 方法完成真實的DOM更新。

最後的 patch 階段更新,是一連串的 DOM 操作,雖然可以根據 diff 後得到的 change list 做拆分,但是意義不大,不僅會導致內部維護的 DOM 狀態和實際的不一致,也會影響體驗,所以應該做的是對 diff 階段進行拆分。從下圖是 ReactDOM 渲染 10000 個子元件的過程。可以看到,在 diff 執行階段主執行緒一直被佔用,無法進行其他任何操作 I/O 操作,直到執行完成。

img

怎麼拆

由此引出了 React Fiber 的解決方案,以一個 fiber 為單位來進行拆分,fiber tree 是根據 VDOM tree 構造出來的,樹形結構完全一致,只是包含的資訊不同。以下是 fiber tree 節點的部分結構:

{
    alternate: Fiber|null, // 在fiber更新時克隆出的映象fiber,對fiber的修改會標記在這個fiber上
    nextEffect: Fiber | null, // 單連結串列結構,方便遍歷 Fiber Tree 上有副作用的節點
    pendingWorkPriority: PriorityLevel, // 標記子樹上待更新任務的優先順序

	stateNode: any, // 管理 instance 自身的特性
    return: Fiber|null, // 指向 Fiber Tree 中的父節點
    child: Fiber|null, // 指向第一個子節點
    sibling: Fiber|null, // 指向兄弟節點
}
複製程式碼

Fiber 依次通過 return、child 及 sibling 的順序對 ReactElement 做處理,將之前簡單的樹結構,變成了基於單連結串列的樹結構,維護了更多的節點關係。

img

執行順序

Stack 在執行時是以一個 tree 為單位處理;Fiber 則是以一個 fiber 的單位執行。Stack 只能同步的執行;Fiber 則可以針對該 Fiber 做排程處理。也就是說,假設現在有個 Fiber 其單連結串列(Linked List)結構為 A → B → C,當 A 執行到 B 被中斷的話,可以之後再次執行 B → C,這對 Stack 的同步處理結構來說是很難做到的。

在 React Fiber 執行的過程中,主要分為兩個階段(phase):

  1. render / reconciliation (interruptible)
  2. commit (not interruptible)

第一個階段主要工作是自頂向下構建一顆完整的 Fiber Tree, 在 rerender 的過程中,根據之前生成的樹,構建名為 workInProgress 的 Fiber Tree 用於更新操作。 淺析 React Fiber

淺析 React Fiber

假設我有上圖所示的 DOM 結構需要渲染,第一次 render 的時候會生成下圖所示的 Fiber Tree:

img

因為我需要對 Item 裡面的數值做平方運算,於是我點選了 Button,react 根據之前生成的 Fiber Tree 開始構建workInProgress Tree。在構建的過程中,以一個 fiber 節點為單位自頂向下對比,如果發現根節點沒有發生改變,根據其 child 指標,把 List 節點複製到 workinprogress Tree 中。 每處理完一個 fiber 節點,react 都會檢查當前時間片是否夠用,如果發現當前時間片不夠用了,就是會標記下一個要處理的任務優先順序,根據優先順序來決定下一個時間片要處理什麼任務。

requestIdleCallback 會讓一個低優先順序的任務在空閒期被呼叫,而 requestAnimationFrame 會讓一個高優先順序的任務在下一個棧幀被呼叫,從而保證了主執行緒按照優先順序執行 fiber 單元。 優先順序順序為:文字框輸入 > 本次排程結束需完成的任務 > 動畫過渡 > 互動反饋 > 資料更新 > 不會顯示但以防將來會顯示的任務。

module.exports = {  
  // heigh level
  NoWork: 0, // No work is pending.
  SynchronousPriority: 1, // For controlled text inputs. 
  TaskPriority: 2, // Completes at the end of the current tick.
  AnimationPriority: 3, // Needs to complete before the next frame.
  
  // low level
  HighPriority: 4, // Interaction that needs to complete pretty soon to feel responsive.
  LowPriority: 5, // Data fetching, or result from updating stores.
  OffscreenPriority: 6, // Won't be visible but do the work in case it becomes visible.
};
複製程式碼

在平方運算這一過程中,react 通過依次對比 fiber 節點發現 List,Item2,Item3 發生了變化,就會在對應生成的 workInProgress Tree 中打一個 Tag,並且推送到 effect list 中。

img

img

當 reconciliation 結束後,根節點的 effect list 裡記錄了包括 DOM change 在內的所有 side effect,在第二階段(commit)執行更新操作,這樣一個流程就算結束了。

在這個示例中,詳細的比對流程並沒有細講,推薦觀看 Lin Clark 去年 react conf 中的演講,非常淺顯易懂,本文中示例也來自這個演講。

暢想未來

  • 非同步渲染 今年在冰島舉行的 JS Conf,Dan 提到了非同步渲染的概念,非同步渲染不是說一個個載入元件,而是說在以非同步的方式載入的同時給人以同步流程的體驗,在老裝置上,通過犧牲一些載入時間來獲得一種流暢的體驗。其實在 React@16 版本中,非同步渲染預設是關閉的,雖然可以通過 hack 的方式實現非同步,但是因為沒有寫測試,還是會有 BUG 存在。
  • 生命週期大換血 在 react@16 版本中,雖然依舊支援之前的生命週期函式,但是官方已經說明在下個版本中會將廢棄其中的部分,這麼做的原因,主要是 reconciliation 的重寫導致。在 render/reconciliation 的過程中,因為存在優先順序和時間片的概念,一個任務很可能執行到一半就被其他優先順序更高的任務所替代,或者因為時間原因而被終止。當再次執行這個任務時,是從頭開始執行一遍,就會導致元件的某些 will 生命週期可能被多次呼叫而影響效能。react 團隊給了我們很長一段時間來處理這個問題,官方也提供了很多參考案例,可以平滑過渡到下個版本。

react@16 與其說是一個分水嶺,不如說是一個過渡,做的很多工作都是在給使用者打預防針,告訴你接下來該怎麼做,react@17才會是掀起風浪的那一個。reconciliation 的重寫給 react 的未來帶來太多的可能,包括最近社群討論的如火如荼的 Hooks,其實也是 Fiber 帶來一種可能性。在後續的版本中,個人以為寫法上會有不小的改變,主要是為了更加優秀的效能服務;還有就是將一些社群產生的方案做優化,讓寫法更加人性化(HOC 中的 refs 以及 context 傳遞),以及對常見的問題給出官方的解決方案(非同步資料處理)等等。除了優點,當然也會帶來些問題。隨著版本的迭代,react 中的概念越來越多,新手學習的曲線只怕是會越來越陡峭。

總結

在處理大型應用時,react 的表現不盡人意。主要原因在於計算耗時太長,導致主執行緒一直被佔用,無法處理其他任務。react 團隊為了解決這個問題,提出了 Fiber reconciliation 的方案來代替之前的 Stack reconciliation。Fiber 相較於 Stack,採用了非同步的方式將之前同步執行的計算過程做拆分,使得主執行緒不會一直處於被佔用的狀態,可以有時間去處理其他任務,比如 I/O 操作,互動反饋等。

參考文獻

相關文章