一、目標
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):
-
(可中斷)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](/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 的實際工作是對比 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).
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 來張圖感受一下:
[caption id="attachment_1628" align="alignnone" width="970"]
fiber-tree[/caption]
其實稍一細想,從 Stack reconciler 到 Fiber reconciler,源碼層面就是干了一件遞歸改循環的事情(當然,實際做的事情遠不止遞歸改循環,但這是第一步)
總之,源碼變化很大,如果對 Fiber 思路沒有預先了解的話,看源碼會比較艱難(看過 React[15-] 的源碼的話,就更容易迷惑了)
P.S. 這張 清明流程圖 要正式退役了
參考資料
-
Lin Clark - A Cartoon Intro to Fiber - React Conf 2017:5 星推薦,聲音很好聽,比 Jing Chen 好 100 倍
-
A look inside React Fiber - how work will get done.:Fiber 源碼解讀,小說體看著有點費勁
暫無評論,快來發表你的看法吧