跳到主要內容
黯羽輕揚每天積累一點點

完全理解 React Fiber

免費2018-01-06#Front-End#JS#React Fiber介绍#React Fiber工作原理#React Fiber源码剖析#React Fiber guide#Fiber reconciler

已知 - 求 - 解 - 舉一反三

一、目標

Fiber 是對 React 核心算法的重構,2 年重構的產物就是 Fiber reconciler

核心目標:擴大其適用性,包括動畫,佈局和手勢。分為 5 個具體目標(後 2 個算送的):

  • 把可中斷的工作拆分成小任務

  • 對正在做的工作調整優先次序、重做、複用上次(做了一半的)成果

  • 在父子任務之間從容切換(yield back and forth),以支持 React 執行過程中的佈局刷新

  • 支持 render() 返回多個元素

  • 更好地支持 error boundary

既然初衷是不希望 JS 不受控制地長時間執行(想要手動調度),那麼,為什麼 JS 長時間執行會影響交互響應、動畫?

因為 JavaScript 在瀏覽器的主線程上運行,恰好與樣式計算、佈局以及許多情況下的繪製一起運行。如果 JavaScript 運行時間過長,就會阻塞這些其他工作,可能導致掉幀。

(引自 Optimize JavaScript Execution

React 希望通過 Fiber 重構來改變這種不可控的現狀,進一步提升交互體驗

P.S. 關於 Fiber 目標的更多信息,請查看 Codebase Overview

二、關鍵特性

Fiber 的關鍵特性如下:

  • 增量渲染(把渲染任務拆分成塊,勻到多幀)

  • 更新時能夠暫停,終止,複用渲染任務

  • 給不同類型的更新賦予優先級

  • 並發方面新的基礎能力

增量渲染用來解決掉幀的問題,渲染任務拆分之後,每次只做一小段,做完一段就把時間控制權交還給主線程,而不像之前長時間佔用。這種策略叫做 cooperative scheduling(合作式調度),操作系統的 3 種任務調度策略之一(Firefox 還對真實 DOM 應用了這項技術)

另外,React 自身的killer feature是 virtual DOM,2 個原因:

  • coding UI 變簡單了(不用關心瀏覽器應該怎麼做,而是把下一刻的 UI 描述給 React 聽)

  • 既然 DOM 能 virtual,別的(硬件、VR、native App)也能

React 實現上分為 2 部分:

  • reconciler 尋找某時刻前後兩版 UI 的差異。包括之前的 Stack reconciler 與現在的 Fiber reconciler

  • renderer 插件式的,平台相關的部分。包括 React DOM、React Native、React ART、ReactHardware、ReactAframe、React-pdf、ReactThreeRenderer、ReactBlessed 等等

這一波是對 reconciler 的徹底改造,對 killer feature 的增強

三、fiber 與 fiber tree

React 運行時存在 3 種實例:

DOM 真實 DOM 節點
-------
Instances React 維護的 vDOM tree node
-------
Elements 描述 UI 長什麼樣子(type, props)

Instances 是根據 Elements 創建的,對組件及 DOM 節點的抽象表示,vDOM tree 維護了組件狀態以及組件與 DOM 樹的關係

在首次渲染過程中構建出 vDOM tree,後續需要更新時(setState()),diff vDOM tree 得到 DOM change,並把 DOM change 應用(patch)到 DOM 樹

Fiber 之前的 reconciler(被稱為 Stack reconciler)自頂向下的遞歸 mount/update無法中斷(持續佔用主線程),這樣主線程上的佈局、動畫等週期性任務以及交互響應就無法立即得到處理,影響體驗

Fiber 解決這個問題的思路是把渲染/更新過程(遞歸 diff)拆分成一系列小任務,每次檢查樹上的一小部分,做完看是否還有時間繼續下一個任務,有的話繼續,沒有話把自己掛起,主線程不忙的時候再繼續

增量更新需要更多的上下文信息,之前的 vDOM tree 顯然難以滿足,所以擴展出了fiber tree(即 Fiber 上下文的 vDOM tree),更新過程就是根據輸入數據以及現有的 fiber tree 構造出新的 fiber tree(workInProgress tree)。因此,Instance 層新增了這些實例:

DOM
    真實 DOM 節點
-------
effect
    每個 workInProgress tree 節點上都有一個 effect list
    用來存放 diff 結果
    當前節點更新完畢會向上 merge effect list(queue 收集 diff 結果)
- - - -
workInProgress
    workInProgress tree 是 reconcile 過程中從 fiber tree 建立的當前進度快照,用於斷點恢復
- - - -
fiber
    fiber tree 與 vDOM tree 類似,用來描述增量更新所需的上下文信息
-------
Elements
    描述 UI 長什麼樣子(type, props)

注意:放在虛線上的 2 層都是臨時的結構,僅在更新時有用,日常不持續維護。effect指的就是 side effect,包括將要做的 DOM change

fiber tree 上各節點的主要結構(每個節點稱為fiber)如下:

// fiber tree 節點結構
{
    stateNode,
    child,
    return,
    sibling,
    ...
}

return 表示當前節點處理完畢後,應該向誰提交自己的成果(effect list)

P.S. fiber tree 實際上是個單鏈表(Singly Linked List)樹結構,見 react/packages/react-reconciler/src/ReactFiber.js

P.S. 注意小 fiber 與大 Fiber,前者表示 fiber tree 上的節點,後者表示 React Fiber

四、Fiber reconciler

reconcile 過程分為 2 個階段(phase):

  1. (可中斷)render/reconciliation 通過構造 workInProgress tree 得出 change

  2. (不可中斷)commit 應用這些 DOM change

render/reconciliation

以 fiber tree 為藍本,把每個 fiber 作為一個工作單元,自頂向下逐節點構造workInProgress tree(構建中的新 fiber tree)

具體過程如下(以組件節點為例):

  1. 如果當前節點不需要更新,直接把子節點 clone 過來,跳到 5;要更新的話打個 tag

  2. 更新當前節點狀態(props, state, context 等)

  3. 調用 shouldComponentUpdate()false 的話,跳到 5

  4. 調用 render() 獲得新的子節點,並為子節點創建 fiber(創建過程會盡量複用現有 fiber,子節點增刪也發生在這裡)

  5. 如果沒有產生 child fiber,該工作單元結束,把 effect list 歸併到 return,並把當前節點的 sibling 作為下一個工作單元;否則把 child 作為下一個工作單元

  6. 如果沒有剩餘可用時間了,等到下一次主線程空閒時才開始下一個工作單元;否則,立即開始做

  7. 如果沒有下一個工作單元了(回到了 workInProgress tree 的根節點),第 1 階段結束,進入 pendingCommit 狀態

實際上是 1-6 的工作循環,7 是出口,工作循環每次只做一件事,做完看要不要喘口氣。工作循環結束時,workInProgress tree 的根節點身上的 effect list 就是收集到的所有 side effect(因為每做完一個都向上歸併)

所以,構建 workInProgress tree 的過程就是 diff 的過程,通過 requestIdleCallback 來調度執行一組任務,每完成一個任務後回來看看有沒有插隊的(更緊急的),每完成一組任務,把時間控制權交還給主線程,直到下一次 requestIdleCallback 回調再繼續構建 workInProgress tree

P.S. Fiber 之前的 reconciler 被稱為 Stack reconciler,就是因為這些調度上下文信息是由系統棧來保存的。雖然之前一次性做完,強調棧沒什麼意義,起個名字只是為了便於區分 Fiber reconciler

requestIdleCallback

通知主線程,要求在不忙的時候告訴我,我有幾個不太著急的事情要做

具體用法如下:

window.requestIdleCallback(callback[, options])
// 示例
let handle = window.requestIdleCallback((idleDeadline) => {
    const {didTimeout, timeRemaining} = idleDeadline;
    console.log(`超時了嗎?${didTimeout}`);
    console.log(`可用時間剩餘${timeRemaining.call(idleDeadline)}ms`);
    // do some stuff
    const now = +new Date, timespent = 10;
    while (+new Date < now + timespent);
    console.log(`花了${timespent}ms 搞事情`);
    console.log(`可用時間剩餘${timeRemaining.call(idleDeadline)}ms`);
}, {timeout: 1000});
// 輸出結果
// 超時了嗎?false
// 可用時間剩餘 49.535000000000004ms
// 花了 10ms 搞事情
// 可用時間剩餘 38.64ms

注意requestIdleCallback 調度只是希望做到流暢體驗,並不能絕對保證什麼,例如:

// do some stuff
const now = +new Date, timespent = 300;
while (+new Date < now + timespent);

如果搞事情(對應 React 中的生命週期函數等時間上不受 React 控制的東西)就花了 300ms,什麼機制也保證不了流暢

P.S. 一般剩餘可用時間也就 10-50ms,可調度空間不很寬裕

commit

第 2 階段直接一口氣做完:

  1. 處理 effect list(包括 3 種處理:更新 DOM 樹、調用組件生命週期函數以及更新 ref 等內部狀態)

  2. 出對結束,第 2 階段結束,所有更新都 commit 到 DOM 樹上了

注意,真的是一口氣做完(同步執行,不能喊停)的,這個階段的實際工作量是比較大的,所以盡量不要在後 3 個生命週期函數裡幹重活兒

生命週期 hook

生命週期函數也被分為 2 個階段了:

// 第 1 階段 render/reconciliation
componentWillMount
componentWillReceiveProps
shouldComponentUpdate
componentWillUpdate

// 第 2 階段 commit
componentDidMount
componentDidUpdate
componentWillUnmount

第 1 階段的生命週期函數可能會被多次調用,默認以 low 優先級(後面介紹的 6 種優先級之一)執行,被高優先級任務打斷的話,稍後重新執行

五、fiber tree 與 workInProgress tree

雙緩衝技術(double buffering),就像 [redux 裡的 nextListeners](/articles/redux 源碼解讀/#articleHeader7),以 fiber tree 為主,workInProgress tree 為輔

雙緩衝具體指的是 workInProgress tree 構造完畢,得到的就是新的 fiber tree,然後喜新厭舊(把 current 指針指向 workInProgress tree,丟掉舊的 fiber tree)就好了

這樣做的好處:

  • 能夠複用內部對象(fiber)

  • 節省內存分配、GC 的時間開銷

每個 fiber 上都有個 alternate 屬性,也指向一個 fiber,創建 workInProgress 節點時優先取 alternate,沒有話就創建一個:

let workInProgress = current.alternate;
if (workInProgress === null) {
  //...這裡很有意思
  workInProgress.alternate = current;
  current.alternate = workInProgress;
} else {
  // We already have an alternate.
  // Reset the effect tag.
  workInProgress.effectTag = NoEffect;

  // The effect list is no longer valid.
  workInProgress.nextEffect = null;
  workInProgress.firstEffect = null;
  workInProgress.lastEffect = null;
}

如注釋指出的,fiber 與 workInProgress 互相持有引用,「喜新厭舊」之後,舊 fiber 就作為新 fiber 更新的預留空間,達到複用 fiber 實例的目的

P.S. 源碼裡還有一些有意思的技巧,比如 tag 的位運算

六、優先級策略

每個工作單元運行時有 6 種優先級:

  • synchronous 與之前的 Stack reconciler 操作一樣,同步執行

  • task 在 next tick 之前執行

  • animation 下一幀之前執行

  • high 在不久的將來立即執行

  • low 稍微延遲(100-200ms)執行也沒關係

  • offscreen 下一次 render 時或 scroll 時才執行

synchronous 首屏(首次渲染)用,要求盡量快,不管會不會阻塞 UI 線程。animation 通過 requestAnimationFrame 來調度,這樣在下一幀就能立即開始動畫過程;後 3 個都是由 requestIdleCallback 回調執行的;offscreen 指的是當前隱藏的、屏幕外的(看不見的)元素

高優先級的比如鍵盤輸入(希望立即得到反饋),低優先級的比如網絡請求,讓評論顯示出來等等。另外,緊急的事件允許插隊

這樣的優先級機制存在2 個問題

  • 生命週期函數怎麼執行(可能被頻頻中斷):觸發順序、次數沒有保證了

  • starvation(低優先級餓死):如果高優先級任務很多,那麼低優先級任務根本沒機會執行(就餓死了)

生命週期函數的問題有一個官方例子:

low A
componentWillUpdate()
---
high B
componentWillUpdate()
componentDidUpdate()
---
restart low A
componentWillUpdate()
componentDidUpdate()

第 1 個問題正在解決(還沒解決),生命週期的問題會破壞一些現有 App,給平滑升級帶來困難,Fiber 團隊正在努力尋找優雅的升級途徑

第 2 個問題通過盡量複用已完成的操作(reusing work where it can)來緩解,聽起來也是正在想辦法解決

這兩個問題本身不太好解決,只是解決到什麼程度的問題。比如第一個問題,如果組件生命週期函數摻雜副作用太多,就沒有辦法無傷解決。這些問題雖然會給升級 Fiber 帶來一定阻力,但絕不是不可解的(退一步講,如果新特性有足夠的吸引力,第一個問題大家自己想辦法就解決了)

七、總結

已知

React 在一些響應體驗要求較高的場景不適用,比如動畫,佈局和手勢

根本原因是渲染/更新過程一旦開始無法中斷,持續佔用主線程,主線程忙於執行 JS,無暇他顧(佈局、動畫),造成掉幀、延遲響應(甚至無響應)等不佳體驗

一種能夠徹底解決主線程長時間佔用問題的機制,不僅能夠應對眼前的問題,還要有長遠意義

The "fiber" reconciler is a new effort aiming to resolve the problems inherent in the stack reconciler and fix a few long-standing issues.

把渲染/更新過程拆分為小塊任務,通過合理的調度機制來控制時間(更細粒度、更強的控制力)

那麼,面臨 5 個子問題:

1. 拆什麼?什麼不能拆?

把渲染/更新過程分為 2 個階段(diff + patch):

1.diff ~ render/reconciliation
2.patch ~ commit

diff 的實際工作是對比 prevInstancenextInstance 的狀態,找出差異及其對應的 DOM change。diff 本質上是一些計算(遍歷、比較),是可拆分的(算一半待會兒接著算)

patch 階段把本次更新中的所有 DOM change 應用到 DOM 樹,是一連串的 DOM 操作。這些 DOM 操作雖然看起來也可以拆分(按照 change list 一段一段做),但這樣做一方面可能造成 DOM 實際狀態與維護的內部狀態不一致,另外還會影響體驗。而且,一般場景下,DOM 更新的耗時比起 diff 及生命週期函數耗時不算什麼,拆分的意義不很大

所以,render/reconciliation 階段的工作(diff)可以拆分,commit 階段的工作(patch)不可拆分

P.S. diff 與 reconciliation 只是對應關係,並不等價,如果非要區分的話,reconciliation 包括 diff:

This is a part of the process that React calls reconciliation which starts when you call ReactDOM.render() or setState(). By the end of the reconciliation, React knows the result DOM tree, and a renderer like react-dom or react-native applies the minimal set of changes necessary to update the DOM nodes (or the platform-specific views in case of React Native).

(引自 Top-Down Reconciliation

2. 怎麼拆?

先憑空亂來幾種 diff 工作拆分方案:

  • 按組件結構拆。不好分,無法預估各組件更新的工作量

  • 按實際工序拆。比如分為 getNextState(), shouldUpdate(), updateState(), checkChildren() 再穿插一些生命週期函數

按組件拆太粗,顯然對大組件不太公平。按工序拆太細,任務太多,頻繁調度不划算。那麼有沒有合適的拆分單位?

有。Fiber 的拆分單位是 fiber(fiber tree 上的一個節點),實際上就是按虛擬 DOM 節點拆,因為 fiber tree 是根據 vDOM tree 構造出來的,樹結構一模一樣,只是節點攜帶的信息有差異

所以,實際上是 vDOM node 粒度的拆分(以 fiber 為工作單元),每個組件實例和每個 DOM 節點抽象表示的實例都是一個工作單元。工作循環中,每次處理一個 fiber,處理完可以中斷/掛起整個工作循環

3. 如何調度任務?

分 2 部分:

  • 工作循環

  • 優先級機制

工作循環是基本的任務調度機制,工作循環中每次處理一個任務(工作單元),處理完畢有一次喘息的機會:

// Flush asynchronous work until the deadline runs out of time.
while (nextUnitOfWork !== null && !shouldYield()) {
  nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}

shouldYield 就是看時間用完了沒(idleDeadline.timeRemaining()),沒用完的話繼續處理下一個任務,用完了就結束,把時間控制權還給主線程,等下一次 requestIdleCallback 回調再接著做:

// If there's work left over, schedule a new callback.
if (nextFlushedExpirationTime !== NoWork) {
  scheduleCallbackWithExpiration(nextFlushedExpirationTime);
}

也就是說,(不考慮突發事件的)正常調度是由工作循環來完成的,基本規則是:每個工作單元結束檢查是否還有時間做下一個,沒時間了就先「掛起」

優先級機制用來處理突發事件與優化次序,例如:

  • 到 commit 階段了,提高優先級

  • 高優任務做一半出錯了,給降一下優先級

  • 抽空關注一下低優任務,別給餓死了

  • 如果對應 DOM 節點此刻不可見,給降到最低優先級

這些策略用來動態調整任務調度,是工作循環的輔助機制,最先做最重要的事情

4. 如何中斷/斷點恢復?

中斷:檢查當前正在處理的工作單元,保存當前成果(firstEffect, lastEffect),修改 tag 標記一下,迅速收尾並再開一個 requestIdleCallback,下次有機會再做

斷點恢復:下次再處理到該工作單元時,看 tag 是被打斷的任務,接著做未完成的部分或者重做

P.S. 無論是時間用盡「自然」中斷,還是被高優任務粗暴打斷,對中斷機制來說都一樣

5. 如何收集任務結果?

Fiber reconciliation 的工作循環具體如下:

  1. 找到根節點優先級最高的 workInProgress tree,取其待處理的節點(代表組件或 DOM 節點)

  2. 檢查當前節點是否需要更新,不需要的話,直接到 4

  3. 標記一下(打個 tag),更新自己(組件更新 propscontext 等,DOM 節點記下 DOM change),並為孩子生成 workInProgress node

  4. 如果沒有產生子節點,歸併 effect list(包含 DOM change)到父級

  5. 把孩子或兄弟作為待處理節點,準備進入下一個工作循環。如果沒有待處理節點(回到了 workInProgress tree 的根節點),工作循環結束

通過每個節點更新結束時向上歸併 effect list 來收集任務結果,reconciliation 結束後,根節點的 effect list 裡記錄了包括 DOM change 在內的所有 side effect

舉一反三

既然任務可拆分(只要最終得到完整 effect list 就行),那就允許並行執行(多個 Fiber reconciler + 多個 worker),首屏也更容易分塊加載/渲染(vDOM 森林)

並行渲染的話,據說 Firefox 測試結果顯示,130ms 的頁面,只需要 30ms 就能搞定,所以在這方面是值得期待的,而 React 已經做好準備了,這也就是在 React Fiber 上下文經常聽到的待unlock 的更多特性之一

八、源碼簡析

從 15 到 16,源碼結構發生了很大變化:

fiber tree 來張圖感受一下:

[caption id="attachment_1628" align="alignnone" width="970"]fiber-tree fiber-tree[/caption]

其實稍一細想,從 Stack reconciler 到 Fiber reconciler,源碼層面就是干了一件遞歸改循環的事情(當然,實際做的事情遠不止遞歸改循環,但這是第一步)

總之,源碼變化很大,如果對 Fiber 思路沒有預先了解的話,看源碼會比較艱難(看過 React[15-] 的源碼的話,就更容易迷惑了)

P.S. 這張 清明流程圖 要正式退役了

參考資料

評論

暫無評論,快來發表你的看法吧

提交評論