熟悉 react 的朋友都知道,在 react 中有個核心的算法,叫 diff 算法。web 界面由 dom 樹組成,不同的 dom 樹會渲染出不同的界面。react 使用 virtual dom 來表示 dom 樹,而 diff 算法就是用於比較 virtual dom 樹的區別,並更新界面需要更新的部分。
diff 算法和 virtual dom 的完美結合的過程被稱為 reconciler,這可是 react 攻城拔寨的絕對利器。有了 reconciler,開發者可以脫身操作真實的 dom 樹,只需要向 react 描述界面的狀態,而 react 會幫助你高效的完成真正 dom 操作。
在 react16 之前的 reconciler 叫 stack reconciler,fiber 是 react 新的 reconciler,這次更新到 fiber 架構是一次重量級的核心架構的替換,react 為了完成這次替換已經准備了兩三年的時間了。
那么 fiber 究竟有什么好的呢?Fiber為何出現呢?
不知道大家有沒有遇到過這樣的情況,點擊一個頁面的按鈕時感覺到頁面沒有任何的反應,讓你懷疑電腦是不是死機了,然后你快速切出瀏覽器,發現電腦並沒有死機,於是再切回瀏覽器,這時候才發現頁面終於更新了。為什么會出現這種情況?在多數情況下,可能是因為瀏覽器忙着執行相關的 js 代碼,導致瀏覽器主線程沒有及時響應用戶的操作或者沒有及時更新界面。本着顧客是上帝的原則,作為一名優秀的開發者,怎么能夠允許出現這種情況降低用戶的體驗呢。因此 react 團隊引入了異步渲染這個概念,而采用的 fiber 架構可以實現這種異步渲染的方式。
原先的 stack reconciler 像是一個遞歸執行的函數,從父組件調用子組件的 reconciler 過程就是一個遞歸執行的過程,這也是為什么被稱為 stack reconciler 的原因。當我們調用 setState 的時候,react 從根節點開始遍歷,找出所有的不同,而對於特別龐大的 dom 樹來說,這個遞歸遍歷的過程會消耗特別長的時間。在這個期間,任何交互和渲染都會被阻塞,這樣就給用戶一種“死機”的感覺。
fiber 的出現解決了這個問題,它把 reconciler 的過程拆分成了一個個的小任務,並在完成了小任務之后暫停執行 js 代碼,然后檢查是否有需要更新的內容和需要響應的事件,做出相應的處理后再繼續執行 js 代碼。這樣就給了用戶一種應用一直在運行的感覺,提高了用戶的體驗。
一、React 15 存在的問題
在頁面元素很多,且需要頻繁刷新的場景下,React 15 會出現掉幀的現象。
其根本原因,是大量的同步計算任務阻塞了瀏覽器的 UI 渲染。默認情況下,JS 運算、頁面布局和頁面繪制都是運行在瀏覽器的主線程當中,他們之間是互斥的關系。如果 JS 運算持續占用主線程,頁面就沒法得到及時的更新。當我們調用setState
更新頁面的時候,React 會遍歷應用的所有節點,計算出差異,然后再更新 UI。整個過程是一氣呵成,不能被打斷的。如果頁面元素很多,整個過程占用的時機就可能超過 16 毫秒,就容易出現掉幀的現象。
針對這一問題,React 團隊從框架層面對 web 頁面的運行機制做了優化,得到很好的效果。
二、解題思路
解決主線程長時間被 JS 運算占用這一問題的基本思路,是將運算切割為多個步驟,分批完成。也就是說在完成一部分任務之后,將控制權交回給瀏覽器,讓瀏覽器有時間進行頁面的渲染。等瀏覽器忙完之后,再繼續之前未完成的任務。
舊版 React 通過遞歸的方式進行渲染,使用的是 JS 引擎自身的函數調用棧,它會一直執行到棧空為止。而 Fiber架構
實現了自己的組件調用棧,它以鏈表的形式遍歷組件樹,可以靈活的暫停、繼續和丟棄執行的任務。實現方式是使用了瀏覽器的requestIdleCallback
這一 API。
官方的解釋是這樣的:
window.requestIdleCallback()會在瀏覽器空閑時期依次調用函數,這就可以讓開發者在主事件循環中執行后台或低優先級的任務,而且不會對像動畫和用戶交互這些延遲觸發但關鍵的事件產生影響。函數一般會按先進先調用的順序執行,除非函數在瀏覽器調用它之前就到了它的超時時間。
有了解題思路后,我們再來看看 React 具體是怎么做的。
三、Fiber 如何做到異步渲染
1、異步渲染設計原理
在做顯示方面的工作時,經常會聽到一個目標叫 60 幀,這表示的是畫面的更新頻率,也就是畫面每秒鍾更新 60 次。這是因為在 60 幀的更新頻率下,頁面在人眼中顯得流暢,無明顯卡頓。每秒鍾更新 60 次也就是每 16ms 需要更新一次頁面,如果更新頁面消耗的時間不到 16ms,那么在下一次更新時機來到之前會剩下一點時間執行其他的任務,只要保證及時在 16ms 的間隔下更新界面就完全不會影響到頁面的流暢程度。
Fiber 的核心正是利用了 60 幀原則,實現了一個基於優先級和 requestIdleCallback 的循環任務調度算法。
requestIdleCallback 是瀏覽器提供的一個 api,可以讓瀏覽器在空閑的時候執行回調,在回調參數中可以獲取到當前幀剩余的時間,fiber 利用了這個參數,判斷當前剩下的時間是否足夠繼續執行任務,如果足夠則繼續執行,否則暫停任務,並調用 requestIdleCallback 通知瀏覽器空閑的時候繼續執行當前的任務。
詳見之前博客:淺析requestIdleCallback
function fiber(剩余時間) { if (剩余時間 > 任務所需時間) { 做任務; } else { requestIdleCallback(fiber); } }
2、fiber 還會為不同的任務設置不同的優先級
高優先級任務是需要馬上展示到頁面上的,比如你正在輸入框中輸入文字,你肯定希望你的手指在鍵盤上敲下每一個按鍵時,輸入框能立馬做出反饋,這樣你才能知道你的輸入是否正確,是否有效。
低優先級的任務則是像從服務器傳來了一些數據,這個時候需要更新頁面,比如這篇文章喜歡的人數+1 或是評論+1,這並不是那么緊急的更新,延遲 100-200ms 並不會有多大差別,完全可以在后面進行處理。
fiber 會根據任務優先級來動態調整任務調度,優先完成高優先級的任務。
{ Synchronous: 1, // 同步任務,優先級最高
Task: 2, // 當前調度正執行的任務
Animation 3, // 動畫
High: 4, // 高優先級
Low: 5, // 低優先級
Offscreen: 6, // 當前屏幕外的更新,優先級最低
}
3、React 框架內部的運作可以分為 3 層:
- Virtual DOM 層,描述頁面長什么樣。
- Reconciler 層,負責調用組件生命周期方法,進行 Diff 運算等。
- Renderer 層,根據不同的平台,渲染出相應的頁面,比較常見的是 ReactDOM 和 ReactNative。
這次改動最大的當屬 Reconciler 層了,React 團隊也給它起了個新的名字,叫 Fiber Reconciler
。這就引入另一個關鍵詞:Fiber。
在 fiber 架構中,有一種數據結構,它的名字就叫做 fiber,這也是為什么新的 reconciler 叫做 fiber 的原因。
Fiber 其實指的就是這種數據結構,它可以用一個純 JS 對象來表示:這個對象的屬性中比較重要的有 stateNode、tag、return、child、sibling 和 alternate。
const Fiber = { stateNode // 節點實例
tag // 標記任務的進度
return // 父節點
child // 子節點
sibling // 兄弟節點
alternate // 變化記錄
..... };
我們可以看出 fiber 基於鏈表結構,擁有一個個指針,指向它的父節點子節點和兄弟節點,在 diff 的過程中,依照節點連接的關系進行遍歷。
為了加以區分,以前的 Reconciler 被命名為Stack Reconciler
。Stack Reconciler 運作的過程是不能被打斷的,必須一條道走到黑:
而 Fiber Reconciler 每執行一段時間,都會將控制權交回給瀏覽器,可以分段執行:
為了達到這種效果,就需要有一個調度器 (Scheduler) 來進行任務分配。任務的優先級有六種:
- synchronous,與之前的Stack Reconciler操作一樣,同步執行
- task,在next tick之前執行
- animation,下一幀之前執行
- high,在不久的將來立即執行
- low,稍微延遲執行也沒關系
- offscreen,下一次render時或scroll時才執行
優先級高的任務(如鍵盤輸入)可以打斷優先級低的任務(如Diff)的執行,從而更快的生效。
Fiber Reconciler 在執行過程中,會分為 2 個階段。
- 階段一,生成 Fiber 樹,得出需要更新的節點信息。這一步是一個漸進的過程,可以被打斷。
- 階段二,將需要更新的節點一次過批量更新,這個過程不能被打斷。
階段一可被打斷的特性,讓優先級更高的任務先執行,從框架層面大大降低了頁面掉幀的概率。
5、fiber 可能存在的問題:
在 fiber 中,更新是分階段的,具體分為兩個階段,首先是 reconciliation 的階段,這個階段在計算前后 dom 樹的差異,然后是 commit 的階段,這個階段將把更新渲染到頁面上。第一個階段是可以打斷的,因為這個階段耗時可能會很長,因此需要暫停下來去執行其他更高優先級的任務,第二個階段則不會被打斷,會一口氣把更新渲染到頁面上。
由於 reconciliation 的階段會被打斷,可能會導致 commit 前的這些生命周期函數多次執行。react 官方目前已經把 componentWillMount、componentWillReceiveProps 和 componetWillUpdate 標記為 unsafe,並使用新的生命周期函數 getDerivedStateFromProps 和 getSnapshotBeforeUpdate 進行替換。
還有一個問題是飢餓問題,意思是如果高優先級的任務一直插入,導致低優先級的任務無法得到機會執行,這被稱為飢餓問題。對於這個問題官方提出的解決方案是盡量復用已經完成的操作來緩解。相信官方也正在努力提出更好的方法去解決這個問題。
四、Fiber 樹
Fiber Reconciler 在階段一進行 Diff 計算的時候,會生成一棵 Fiber 樹。這棵樹是在 Virtual DOM 樹的基礎上增加額外的信息來生成的,它本質來說是一個鏈表。
Fiber 樹在首次渲染的時候會一次生成。在后續需要 Diff 的時候,會根據已有樹和最新 Virtual DOM 的信息,生成一棵新的樹。
這顆新樹每生成一個新的節點,都會將控制權交回給主線程,去檢查有沒有優先級更高的任務需要執行。如果沒有,則繼續構建樹的過程:
如果過程中有優先級更高的任務需要進行,則 Fiber Reconciler 會丟棄正在生成的樹,在空閑的時候再重新執行一遍。
在構造 Fiber 樹的過程中,Fiber Reconciler 會將需要更新的節點信息保存在 Effect List
當中,在階段二執行的時候,會批量更新相應的節點。
參考文章: