從 React 16 開始,React 採用了 Fiber 機制替代了原先基於原生執行棧遞歸遍歷 VDOM 的方案,提高了頁面渲染性能和用戶體驗。乍一聽 Fiber 好像挺神秘,在原生執行棧都還沒搞懂的情況下,又整出個 Fiber,還能不能愉快的寫代碼了。別慌,老鐵!下面就來嘮嘮關於 Fiber 那點事兒。
什麼是 FiberFiber 的英文含義是「纖維」,它是比線程(Thread)更細的線,比線程(Thread)控制得更精密的執行模型。在廣義計算機科學概念中,Fiber 又是一種協作的(Cooperative)編程模型,幫助開發者用一種【既模塊化又協作化】的方式來編排代碼。
簡單點說,Fiber 就是 React 16 實現的一套新的更新機制,讓 React 的更新過程變得可控,避免了之前一竿子遞歸到底影響性能的做法。
關於 Fiber 你需要知道的基礎知識1. 瀏覽器刷新率(幀)頁面的內容都是一幀一幀繪製出來的,瀏覽器刷新率代表瀏覽器一秒繪製多少幀。目前瀏覽器大多是 60Hz(60幀/s),每一幀耗時也就是在 16ms 左右。原則上說 1s 內繪製的幀數也多,畫面表現就也細膩。那麼在這一幀的(16ms) 過程中瀏覽器又幹了啥呢?
通過上面這張圖可以清楚的知道,瀏覽器一幀會經過下面這幾個過程:
執行 RAF (RequestAnimationFrame)執行 RIC (RequestIdelCallback)第七步的 RIC 事件不是每一幀結束都會執行,只有在一幀的 16ms 中做完了前面 6 件事兒且還有剩餘時間,才會執行。這裡提一下,如果一幀執行結束後還有時間執行 RIC 事件,那麼下一幀需要在事件執行結束才能繼續渲染,所以 RIC 執行不要超過 30ms,如果長時間不將控制權交還給瀏覽器,會影響下一幀的渲染,導致頁面出現卡頓和事件響應不及時。
2. JS 原生執行棧React Fiber 出現之前,React 通過原生執行棧遞歸遍歷 VDOM。當瀏覽器引擎第一次遇到 JS 代碼時,會產生一個全局執行上下文並將其壓入執行棧,接下來每遇到一個函數調用,又會往棧中壓入一個新的上下文。比如:
function A(){
B();
C();
}
function B(){}
function C(){}
A();瀏覽器引擎會從執行棧的頂端開始執行,執行完畢就彈出當前執行上下文,開始執行下一個函數,直到執行棧被清空才會停止。然後將執行權交還給瀏覽器。由於 React 將頁面視圖視作一個個函數執行的結果。每一個頁面往往由多個視圖組成,這就意味著多個函數的調用。
如果一個頁面足夠複雜,形成的函數調用棧就會很深。每一次更新,執行棧需要一次性執行完成,中途不能幹其他的事兒,只能"一心一意"。結合前面提到的瀏覽器刷新率,JS 一直執行,瀏覽器得不到控制權,就不能及時開始下一幀的繪製。如果這個時間超過 16ms,當頁面有動畫效果需求時,動畫因為瀏覽器不能及時繪製下一幀,這時動畫就會出現卡頓。不僅如此,因為事件響應代碼是在每一幀開始的時候執行,如果不能及時繪製下一幀,事件響應也會延遲。
3. 時間分片(Time Slicing)時間分片指的是一種將多個粒度小的任務放入一個時間切片(一幀)中執行的一種方案,在 React Fiber 中就是將多個任務放在了一個時間片中去執行。
4. 鍊表在 React Fiber 中用鍊表遍歷的方式替代了 React 16 之前的棧遞歸方案。在 React 16 中使用了大量的鍊表。例如:
例如下面這個組件:
<div id="id">
A1
<div id="B1">
B1
<div id="C1"></div>
</div>
<div id="B2">
B2
</div>
</div>會使用下面這樣的鍊表表示:
鍊表是一種簡單高效的數據結構,它在當前節點中保存著指向下一個節點的指針,就好像火車一樣一節連著一節
遍歷的時候,通過操作指針找到下一個元素。但是操作指針時(調整順序和指向)一定要小心。
鍊表相比順序結構數據格式的好處就是:
操作更高效,比如順序調整、刪除,只需要改變節點的指針指向就好了。不僅可以根據當前節點找到下一個節點,在多向鍊表中,還可以找到他的父節點或者兄弟節點。但鍊表也不是完美的,缺點就是:
比順序結構數據更佔用空間,因為每個節點對象還保存有指向下一個對象的指針。React 用空間換時間,更高效的操作可以方便根據優先級進行操作。同時可以根據當前節點找到其他節點,在下面提到的掛起和恢復過程中起到了關鍵作用。
React Fiber 是如何實現更新過程可控?前面講完基本知識,現在正式開始介紹今天的主角 Fiber,看看 React Fiber 是如何實現對更新過程的管控。
更新過程的可控主要體現在下面幾個方面:
1. 任務拆分前面提到,React Fiber 之前是基於原生執行棧,每一次更新操作會一直佔用主線程,直到更新完成。這可能會導致事件響應延遲,動畫卡頓等現象。
在 React Fiber 機制中,它採用"化整為零"的戰術,將調和階段(Reconciler)遞歸遍歷 VDOM 這個大任務分成若干小任務,每個任務只負責一個節點的處理。例如:
import React from "react";
import ReactDom from "react-dom"
const jsx = (
<div id="A1">
A1
<div id="B1">
B1
<div id="C1">C1</div>
<div id="C2">C2</div>
</div>
<div id="B2">B2</div>
</div>
)
ReactDom.render(jsx,document.getElementById("root"))這個組件在渲染的時候會被分成八個小任務,每個任務用來分別處理 A1(div)、A1(text)、B1(div)、B1(text)、C1(div)、C1(text)、C2(div)、C2(text)、B2(div)、B2(text)。再通過時間分片,在一個時間片中執行一個或者多個任務。這裡提一下,所有的小任務並不是一次性被切分完成,而是處理當前任務的時候生成下一個任務,如果沒有下一個任務生成了,就代表本次渲染的 Diff 操作完成。
2. 掛起、恢復、終止再說掛起、恢復、終止之前,不得不提兩棵 Fiber 樹,workInProgress tree 和 currentFiber tree。
workInProgress 代表當前正在執行更新的 Fiber 樹。在 render 或者 setState 後,會構建一顆 Fiber 樹,也就是 workInProgress tree,這棵樹在構建每一個節點的時候會收集當前節點的副作用,整棵樹構建完成後,會形成一條完整的副作用鏈。
currentFiber 表示上次渲染構建的 Filber 樹。在每一次更新完成後 workInProgress 會賦值給 currentFiber。在新一輪更新時 workInProgress tree 再重新構建,新 workInProgress 的節點通過 alternate 屬性和 currentFiber 的節點建立聯繫。
在新 workInProgress tree 的創建過程中,會同 currentFiber 的對應節點進行 Diff 比較,收集副作用。同時也會復用和 currentFiber 對應的節點對象,減少新創建對象帶來的開銷。也就是說無論是創建還是更新,掛起、恢復以及終止操作都是發生在 workInProgress tree 創建過程中。workInProgress tree 構建過程其實就是循環的執行任務和創建下一個任務,大致過程如下:
當沒有下一個任務需要執行的時候,workInProgress tree 構建完成,開始進入提交階段,完成真實 DOM 更新。
在構建 workInProgressFiber tree 過程中可以通過掛起、恢復和終止任務,實現對更新過程的管控。下面簡化了一下源碼,大致實現如下:
let nextUnitWork = null;//下一個執行單元
//開始調度
function shceduler(task){
nextUnitWork = task;
}
//循環執行工作
function workLoop(deadline){
let shouldYield = false;//是否要讓出時間片交出控制權
while(nextUnitWork && !shouldYield){
nextUnitWork = performUnitWork(nextUnitWork)
shouldYield = deadline.timeRemaining()<1 // 沒有時間了,檢出控制權給瀏覽器
}
if(!nextUnitWork) {
conosle.log("所有任務完成")
//commitRoot() //提交更新視圖
}
// 如果還有任務,但是交出控制權後,請求下次調度
requestIdleCallback(workLoop,{timeout:5000})
}
/*
* 處理一個小任務,其實就是一個 Fiber 節點,如果還有任務就返回下一個需要處理的任務,沒有就代表整個
*/
function performUnitWork(currentFiber){
....
return FiberNode
}
掛起當第一個小任務完成後,先判斷這一幀是否還有空閒時間,沒有就掛起下一個任務的執行,記住當前掛起的節點,讓出控制權給瀏覽器執行更高優先級的任務。
恢復在瀏覽器渲染完一幀後,判斷當前幀是否有剩餘時間,如果有就恢復執行之前掛起的任務。如果沒有任務需要處理,代表調和階段完成,可以開始進入渲染階段。這樣完美的解決了調和過程一直佔用主線程的問題。
那麼問題來了他是如何判斷一幀是否有空閒時間的呢?答案就是我們前面提到的 RIC (RequestIdleCallback) 瀏覽器原生 API,React 源碼中為了兼容低版本的瀏覽器,對該方法進行了 Polyfill。
當恢復執行的時候又是如何知道下一個任務是什麼呢?答案在前面提到的鍊表。在 React Fiber 中每個任務其實就是在處理一個 FiberNode 對象,然後又生成下一個任務需要處理的 FiberNode。順便提一嘴,這裡提到的FiberNode 是一種數據格式,下面是它沒有開美顏的樣子:
class FiberNode {
constructor(tag, pendingProps, key, mode) {
// 實例屬性
this.tag = tag; // 標記不同組件類型,如函數組件、類組件、文本、原生組件...
this.key = key; // react 元素上的 key 就是 jsx 上寫的那個 key ,也就是最終 ReactElement 上的
this.elementType = null; // createElement的第一個參數,ReactElement 上的 type
this.type = null; // 表示fiber的真實類型 ,elementType 基本一樣,在使用了懶加載之類的功能時可能會不一樣
this.stateNode = null; // 實例對象,比如 class 組件 new 完後就掛載在這個屬性上面,如果是RootFiber,那麼它上面掛的是 FiberRoot,如果是原生節點就是 dom 對象
// fiber
this.return = null; // 父節點,指向上一個 fiber
this.child = null; // 子節點,指向自身下面的第一個 fiber
this.sibling = null; // 兄弟組件, 指向一個兄弟節點
this.index = 0; // 一般如果沒有兄弟節點的話是0 當某個父節點下的子節點是數組類型的時候會給每個子節點一個 index,index 和 key 要一起做 diff
this.ref = null; // reactElement 上的 ref 屬性
this.pendingProps = pendingProps; // 新的 props
this.memoizedProps = null; // 舊的 props
this.updateQueue = null; // fiber 上的更新隊列執行一次 setState 就會往這個屬性上掛一個新的更新, 每條更新最終會形成一個鍊表結構,最後做批量更新
this.memoizedState = null; // 對應 memoizedProps,上次渲染的 state,相當於當前的 state,理解成 prev 和 next 的關係
this.mode = mode; // 表示當前組件下的子組件的渲染方式
// effects
this.effectTag = NoEffect; // 表示當前 fiber 要進行何種更新
this.nextEffect = null; // 指向下個需要更新的fiber
this.firstEffect = null; // 指向所有子節點裡,需要更新的 fiber 裡的第一個
this.lastEffect = null; // 指向所有子節點中需要更新的 fiber 的最後一個
this.expirationTime = NoWork; // 過期時間,代表任務在未來的哪個時間點應該被完成
this.childExpirationTime = NoWork; // child 過期時間
this.alternate = null; // current 樹和 workInprogress 樹之間的相互引用
}
}額…看著好像有點上頭,這是開了美顏的樣子:
是不是好看多了?在每次循環的時候,找到下一個執行需要處理的節點。
function performUnitWork(currentFiber){
//beginWork(currentFiber) //找到兒子,並通過鍊表的方式掛到currentFiber上,每一偶兒子就找後面那個兄弟
//有兒子就返回兒子
if(currentFiber.child){
return currentFiber.child;
}
//如果沒有兒子,則找弟弟
while(currentFiber){//一直往上找
//completeUnitWork(currentFiber);//將自己的副作用掛到父節點去
if(currentFiber.sibling){
return currentFiber.sibling
}
currentFiber = currentFiber.return;
}
}在一次任務結束後返回該處理節點的子節點或兄弟節點或父節點。只要有節點返回,說明還有下一個任務,下一個任務的處理對象就是返回的節點。通過一個全局變量記住當前任務節點,當瀏覽器再次空閒的時候,通過這個全局變量,找到它的下一個任務需要處理的節點恢復執行。就這樣一直循環下去,直到沒有需要處理的節點返回,代表所有任務執行完成。最後大家手拉手,就形成了一顆 Fiber 樹。
終止其實並不是每次更新都會走到提交階段。當在調和過程中觸發了新的更新,在執行下一個任務的時候,判斷是否有優先級更高的執行任務,如果有就終止原來將要執行的任務,開始新的 workInProgressFiber 樹構建過程,開始新的更新流程。這樣可以避免重複更新操作。這也是在 React 16 以後生命周期函數 componentWillMount 有可能會執行多次的原因。
3. 任務具備優先級React Fiber 除了通過掛起,恢復和終止來控制更新外,還給每個任務分配了優先級。具體點就是在創建或者更新 FiberNode 的時候,通過算法給每個任務分配一個到期時間(expirationTime)。在每個任務執行的時候除了判斷剩餘時間,如果當前處理節點已經過期,那麼無論現在是否有空閒時間都必須執行改任務。
同時過期時間的大小還代表著任務的優先級。
任務在執行過程中順便收集了每個 FiberNode 的副作用,將有副作用的節點通過 firstEffect、lastEffect、nextEffect 形成一條副作用單鍊表 AI(TEXT)-B1(TEXT)-C1(TEXT)-C1-C2(TEXT)-C2-B1-B2(TEXT)-B2-A。
其實最終都是為了收集到這條副作用鍊表,有了它,在接下來的渲染階段就通過遍歷副作用鏈完成 DOM 更新。這裡需要注意,更新真實 DOM 的這個動作是一氣呵成的,不能中斷,不然會造成視覺上的不連貫。
關於 React Fiber 的思考1. 能否使用生成器(generater)替代鍊表在 Fiber 機制中,最重要的一點就是需要實現掛起和恢復,從實現角度來說 generator 也可以實現。那麼為什麼官方沒有使用 generator 呢?猜測應該是是性能方面的原因。生成器不僅讓您在堆棧的中間讓步,還必須把每個函數包裝在一個生成器中。一方面增加了許多語法方面的開銷,另外還增加了任何現有實現的運行時開銷。性能上遠沒有鍊表的方式好,而且鍊表不需要考慮瀏覽器兼容性。
2. Vue 是否會採用 Fiber 機制來優化複雜頁面的更新這個問題其實有點搞事情,如果 Vue 真這麼做了是不是就是變相承認 Vue 是在"集成" Angular 和 React 的優點呢?React 有 Fiber,Vue 就一定要有?
兩者雖然都依賴 DOM Diff,但是實現上卻有區別,DOM Diff 的目的都是收集副作用。Vue 通過 Watcher 實現了依賴收集,本身就是一種很好的優化。所以 Vue 沒有採用 Fiber 機制,也無傷大雅。
總結React Fiber 的出現相當於是在更新過程中引進了一個中場指揮官,負責掌控更新過程,足球世界裡管這叫前腰。拋開帶來的性能和效率提升外,這種「化整為零」和任務編排的思想,可以應用到我們平時的架構設計中。
政採雲前端團隊(ZooTeam),一個年輕富有激情和創造力的前端團隊,隸屬於政採雲產品研發部,Base 在風景如畫的杭州。團隊現有 40 餘個前端小夥伴,平均年齡 27 歲,近 3 成是全棧工程師,妥妥的青年風暴團。成員構成既有來自於阿里、網易的「老」兵,也有浙大、中科大、杭電等校的應屆新人。團隊在日常的業務對接之外,還在物料體系、工程平臺、搭建平臺、性能體驗、雲端應用、數據分析及可視化等方向進行技術探索和實戰,推動並落地了一系列的內部技術產品,持續探索前端技術體系的新邊界。
如果你想改變一直被事折騰,希望開始能折騰事;如果你想改變一直被告誡需要多些想法,卻無從破局;如果你想改變你有能力去做成那個結果,卻不需要你;如果你想改變你想做成的事需要一個團隊去支撐,但沒你帶人的位置;如果你想改變既定的節奏,將會是「5 年工作時間 3 年工作經驗」;如果你想改變本來悟性不錯,但總是有那一層窗戶紙的模糊… 如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望參與到隨著業務騰飛的過程,親手推動一個有著深入的業務理解、完善的技術體系、技術創造價值、影響力外溢的前端團隊的成長曆程,我覺得我們該聊聊。任何時間,等著你寫點什麼,發給 ZooTeam@cai-inc.com