徹底理解React Fiber

一.目標
Fiber是對React核心算法的重構,2年重構的產物就是Fiber reconcilernode

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

把可中斷的工做拆分紅小任務算法

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

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

支持render()返回多個元素瀏覽器

更好地支持error boundary網絡

既然初衷是不但願JS不受控制地長時間執行(想要手動調度),那麼,爲何JS長時間執行會影響交互響應、動畫?併發

由於JavaScript在瀏覽器的主線程上運行,剛好與樣式計算、佈局以及許多狀況下的繪製一塊兒運行。若是JavaScript運行時間過長,就會阻塞這些其餘工做,可能致使掉幀。app

(引自Optimize JavaScript Execution)less

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):

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

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

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

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

若是當前節點不須要更新,直接把子節點clone過來,跳到5;要更新的話打個tag

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

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

調用render()得到新的子節點,併爲子節點建立fiber(建立過程會盡可能複用現有fiber,子節點增刪也發生在這裏)

若是沒有產生child fiber,該工做單元結束,把effect list歸併到return,並把當前節點的sibling做爲下一個工做單元;不然把child做爲下一個工做單元

若是沒有剩餘可用時間了,等到下一次主線程空閒時纔開始下一個工做單元;不然,當即開始作

若是沒有下一個工做單元了(回到了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階段直接一口氣作完:

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

出對結束,第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,以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的實際工做是對比prevInstance和nextInstance的狀態,找出差別及其對應的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的工做循環具體以下:

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

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

標記一下(打個tag),更新本身(組件更新props,context等,DOM節點記下DOM change),併爲孩子生成workInProgress node

若是沒有產生子節點,歸併effect list(包含DOM change)到父級

把孩子或兄弟做爲待處理節點,準備進入下一個工做循環。若是沒有待處理節點(回到了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,源碼結構發生了很大變化:

再也看不到mountComponent/updateComponent()了,被拆分重組成了(beginWork/completeWork/commitWork())

ReactDOMComponent也被去掉了,在Fiber體系下DOM節點抽象用ReactDOMFiberComponent表示,組件用ReactFiberClassComponent表示,以前是ReactCompositeComponent

Fiber體系的核心機制是負責任務調度的ReactFiberScheduler,至關於以前的ReactReconciler

vDOM tree變成fiber tree了,之前是自上而下的簡單樹結構,如今是基於單鏈表的樹結構,維護的節點關係更多一些

fiber tree來張圖感覺一下:

徹底理解React Fiber

fiber-tree

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

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

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

參考資料
Lin Clark – A Cartoon Intro to Fiber – React Conf 2017:5星推薦,聲音很好聽,比Jing Chen好100倍

acdlite/react-fiber-architecture

Codebase Overview

A look inside React Fiber – how work will get done.:Fiber源碼解讀,小說體看着有點費勁

相關文章
相關標籤/搜索