這是第 83 篇不摻水的原創,想獲取更多原創好文,請搜索公衆號關注咱們吧~ 本文首發於政採雲前端博客: 前端工程師的自我修養:React Fiber 是如何實現更新過程可控的
從 React 16 開始,React 採用了 Fiber 機制替代了原先基於原生執行棧遞歸遍歷 VDOM 的方案,提升了頁面渲染性能和用戶體驗。乍一聽 Fiber 好像挺神祕,在原生執行棧都還沒搞懂的狀況下,又整出個 Fiber,還能不能愉快的寫代碼了。別慌,老鐵!下面就來嘮嘮關於 Fiber 那點事兒。javascript
Fiber 的英文含義是「纖維」,它是比線程(Thread)更細的線,比線程(Thread)控制得更精密的執行模型。在廣義計算機科學概念中,Fiber 又是一種協做的(Cooperative)編程模型,幫助開發者用一種【既模塊化又協做化】的方式來編排代碼。html
簡單點說,Fiber 就是 React 16 實現的一套新的更新機制,讓 React 的更新過程變得可控,避免了以前一竿子遞歸到底影響性能的作法。前端
頁面的內容都是一幀一幀繪製出來的,瀏覽器刷新率表明瀏覽器一秒繪製多少幀。目前瀏覽器大可能是 60Hz(60幀/s),每一幀耗時也就是在 16ms 左右。原則上說 1s 內繪製的幀數也多,畫面表現就也細膩。那麼在這一幀的(16ms) 過程當中瀏覽器又幹了啥呢?java
經過上面這張圖能夠清楚的知道,瀏覽器一幀會通過下面這幾個過程:react
第七步的 RIC 事件不是每一幀結束都會執行,只有在一幀的 16ms 中作完了前面 6 件事兒且還有剩餘時間,纔會執行。這裏提一下,若是一幀執行結束後還有時間執行 RIC 事件,那麼下一幀須要在事件執行結束才能繼續渲染,因此 RIC 執行不要超過 30ms,若是長時間不將控制權交還給瀏覽器,會影響下一幀的渲染,致使頁面出現卡頓和事件響應不及時。算法
React Fiber 出現以前,React 經過原生執行棧遞歸遍歷 VDOM。當瀏覽器引擎第一次遇到 JS 代碼時,會產生一個全局執行上下文並將其壓入執行棧,接下來每遇到一個函數調用,又會往棧中壓入一個新的上下文。好比:編程
function A(){ B(); C(); } function B(){} function C(){} A();
引擎在執行的時候,會造成以下這樣的執行棧: 數組
瀏覽器引擎會從執行棧的頂端開始執行,執行完畢就彈出當前執行上下文,開始執行下一個函數,直到執行棧被清空纔會中止。而後將執行權交還給瀏覽器。因爲 React 將頁面視圖視做一個個函數執行的結果。每個頁面每每由多個視圖組成,這就意味着多個函數的調用。瀏覽器
若是一個頁面足夠複雜,造成的函數調用棧就會很深。每一次更新,執行棧須要一次性執行完成,中途不能幹其餘的事兒,只能"一心一意"。結合前面提到的瀏覽器刷新率,JS 一直執行,瀏覽器得不到控制權,就不能及時開始下一幀的繪製。若是這個時間超過 16ms,當頁面有動畫效果需求時,動畫由於瀏覽器不能及時繪製下一幀,這時動畫就會出現卡頓。不只如此,由於事件響應代碼是在每一幀開始的時候執行,若是不能及時繪製下一幀,事件響應也會延遲。前端工程師
時間分片指的是一種將多個粒度小的任務放入一個時間切片(一幀)中執行的一種方案,在 React Fiber 中就是將多個任務放在了一個時間片中去執行。
在 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 用空間換時間,更高效的操做能夠方便根據優先級進行操做。同時能夠根據當前節點找到其餘節點,在下面提到的掛起和恢復過程當中起到了關鍵做用。
前面講完基本知識,如今正式開始介紹今天的主角 Fiber,看看 React Fiber 是如何實現對更新過程的管控。
更新過程的可控主要體如今下面幾個方面:
前面提到,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 操做完成。
再說掛起、恢復、終止以前,不得不提兩棵 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 有可能會執行屢次的緣由。
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 的這個動做是一鼓作氣的,不能中斷,否則會形成視覺上的不連貫。
在 Fiber 機制中,最重要的一點就是須要實現掛起和恢復,從實現角度來講 generator 也能夠實現。那麼爲何官方沒有使用 generator 呢?猜想應該是是性能方面的緣由。生成器不只讓您在堆棧的中間讓步,還必須把每一個函數包裝在一個生成器中。一方面增長了許多語法方面的開銷,另外還增長了任何現有實現的運行時開銷。性能上遠沒有鏈表的方式好,並且鏈表不須要考慮瀏覽器兼容性。
這個問題其實有點搞事情,若是 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