React Fiber架構有必定的複雜度,若是硬着頭皮去啃源碼,咱們會深陷於龐大的代碼量和實現細節之中,每每學不到什麼東西。html
React併發模式是ReactFiber架構的重要應用,本文不貼任何React源碼,純粹使用文字幫助你們從併發模式的角度去理解React Fiber架構。react
☕️這不是一篇關於React併發模式API使用的文章。爲節約篇幅,下文不會詳細介紹API的用法,只會講解原理。所以,本文假設讀者已經瞭解過React併發模式相關概念。git
🍺爲了方便讀者理解,本文中的一些關鍵名詞,在最後一小節給出了簡單的解釋github
目前React代碼庫(v16.12.0)已經全面使用Fiber架構重構,並同時存在三種模式:web
ReactDOM.render(...)
ReactDOM.createBlockingRoot(...).render(...)
ReactDOM.createRoot(...).render(...)
可是源碼編譯後只會暴露出Legacy Mode的接口,由於併發模式如今還不是很穩定。chrome
它們的特色以下:npm
更多細節請看👉:詳細區別promise
注意,Concurrent Mode所謂的併發,只是一種假象,跟多線程併發徹底不同。多線程併發是多個Task跑在多個線程中,這是真正意義上的併發。而Concurrent Mode的併發是指多個Task跑在同一個主線程(JS主線程)中,只不過每一個Task均可以不斷在「運行」和「暫停」兩種狀態之間切換,從而給用戶形成了一種Task併發執行的假象。這其實跟CPU的執行原理同樣,這就叫時間分片(Time Slicing)。瀏覽器
所以,如今咱們經過npm i react
所安裝的包使用ReactDOM.render(...)
所建立的React應用的狀態是這樣的:多線程
總的來講就是:雖然已經使用Fiber重構了,可是其實仍是老樣子😔
若是想體驗併發模式請看👉:Adopting Concurrent Mode
在分析以前,咱們先來探究一下卡頓的本質。
刷新率是硬件層面的概念,它是恆定不變的。大部分顯示器的刷新速率都是60Hz,也就是每隔16ms讀取GPU Buffer中數據,顯示到屏幕上,對外表現爲一次屏幕刷新,這個過程叫v-sync。
幀率是軟件層面的概念,它是存在波動的。每一個軟件都會有一套本身的幀率控制策略,拿瀏覽器來講,它有多方面考慮:
刷新率跟頁面卡頓沒有一毛錢關係,頁面的卡頓只跟幀率有關係。
衆所周知,光線打到人類的視網膜能停留的時間大概是24ms,在考慮一些其餘因素,若是光每隔16ms打到視網膜上,人類看到的"連續畫面"就算比較舒服的了。
注意,若是是一個靜態畫面,就算每隔1天輸入一幀咱們也不會感受有什麼不一樣。但事實上,咱們所使用的大多數人機交互設備都是輸出動態畫面的,好比動畫、滾動、交互等。
若是一個連續畫面,沒有按照16ms/幀的速率輸出,那麼咱們就會感受到畫面不連續,也就是所謂的卡頓。
咱們能夠看到,首先要執行JavaScript生成DOM節點,以後纔會進行後續的Style/Layout/Pain/Composite過程,最終把畫面渲染出來。爲了方便分析問題,咱們把這些階段分爲兩個部分:
每一幀的頁面渲染都由這兩部分組成,也就是說這兩部分須要保證在16ms以內完成任務並輸出一幀畫面,用戶纔不會感受到卡頓。事實上,瀏覽器還有別的工做要作,因此可能最多隻有10ms左右的時間。
所以,最終能不能在10ms的時間內完成一幀畫面,取決於兩點:
本文將主要分析事件循環的問題,UI渲染的問題請看 👉:瀏覽器層合成與頁面渲染優化
若是這二者其中一個或多個耗費的時間過長,就會致使這一幀畫面花費了超過16ms才得以輸出,最終致使瀏覽器沒達到60FPS的幀率,體現到使用者的眼中就變成了不連續的畫面,進而感受到卡頓。
注意,這種現象稱爲"幀率下降",而不是"丟幀"。由於幀並無丟,只是輸出的速度變慢了。丟幀是幀率大於顯示器的刷新率,那麼這些多出來的幀率其實並無體現到顯示器上,跟丟了沒有區別。
React面臨着「想讓頁面及時響應用戶交互」與以下兩個事實之間的矛盾:
想讓頁面及時響應用戶交互,就須要及時獲取主線程的執行權,可是渲染頁面又的確須要消耗主線程必定工做時間。
💡 可能有人會問,那麼React先執行「響應用戶交互」的任務不就行了嗎?這樣作,的確頁面會及時響應交互,可是頁面的渲染就會所以卡住,就至關於給頁面渲染加了一個debounce,這樣的交互體驗不是React想要的。
基於這兩個事實,有兩種解決思路:
爲何沒有用第一種方案,也許能夠參考這個討論👉:用web worker多核並行diff虛擬dom的操做存在哪些問題?
解決了CPU計算密集型的問題,用戶體驗已經獲得了顯著的提高。可是React沒有止步於此,藉助暫停這種能力,React又提供了一系列新API:Suspense、SuspenseList、useTransition、useDeferredValue。"組合"使用這些API,咱們將能夠從一個全新的維度去優化用戶體驗——網速快性能高的設備的頁面渲染將更快,網速慢性能差的設備的頁面體驗將更好。
這種集成到框架內部的功能實現是史無前例的,這對其餘框架來講能夠稱得上是一種"降維打擊",可是對React自己也是一種挑戰,由於這些API出現以後,React的render函數將變得愈來愈難以理解。
關於這些新API的更多討論請看 👉如何評價React的新功能Time Slice 和Suspense?
Suspense以及其餘新的API是React併發模式的一種應用場景,只要理解了React併發模式的原理,這些API的原理也就天然懂了。
API的介紹和使用能夠經過React併發模式的相關文檔進行學習。
經過暫停正在執行的任務,一方面讓出主線程的控制權,優先處理高優先級任務,解決CPU計算密集型問題;另外一方面,讓Reconcile的結果暫留在內存中,而後在合適的時間後再顯示到屏幕上,爲解決用戶體驗問題提供了機會。
這裏的暫停,並非真正意義上的暫停執行代碼,而是經過把待處理的任務安排到下一次事件循環,從而釋放主線程控制權,達到暫停的目的。以後在下一個事件循環中,再次嘗試執行待處理任務,進而實現暫停的恢復。
React把這種行爲稱爲: interruptible-rendering
其實並不複雜,主要分爲兩部分:
先來講說調度任務:
所謂調度任務,就是控制任務的執行時機,一般狀況下,任務會一個接着一個的串行執行。可是若是Scheduler接收到了一個高優先級的任務,同時當前已經存在一個正在執行的低優先級任務,這個時候調度器就會"暫停"這個低優先級任務,即經過把這個低優先級任務安排到下一次事件循環再嘗試執行,從而讓出主線程去執行高優先級任務。
因爲Scheduler目前代碼狀態很不穩定,同時React也在推動把Scheduler集成到瀏覽器API中這項工做,Scheduler的代碼可能還會發生更多變化。另外,這塊兒代碼對於理解React並無多大幫助,反而會給讀者形成閱讀困難。基於以上考慮,本小結就不繼續探究Scheduler的代碼實現了,可是後面小結依然會給出關於Scheduler目前狀態的簡單介紹。
而後來看執行任務:
在頁面首次渲染以及後續更新的過程當中,會使用調度器調度performWork這個任務,而performWork工做就是:從當前Fiber節點開始,使用一個while循環遍歷整個FiberTree,由上而下完成每個Fiber節點的更新。
在遍歷FiberTree的過程當中,每一個Fiber節點的處理工做是一個最小單元(unitWork),也就是說"暫停"只能發生在:Fiber節點開始處理以前,或者處理完畢以後。
暫停會發生performWork這個過程的多個unitWork之間,這就會遇到一個問題:暫停以後,咱們如何從當時的工做中恢復,而不是從新再走一遍performWork呢?
React經過一個workInProgress的全局變量來解決這個問題。在每一次unitWork執行完畢後,它的結果(更新後的Fiber)會被賦值給workInProgress,也就是說workInProgress老是保存着最近一次的unitWork的結果。當咱們從暫停中恢復時,直接讀取這個全局變量,並從這個變量爲起點繼續完成performWork的工做便可。
workInProgress也是Fiber,React使用了Double Buffering的方式,維護了一個當前Fiber的副本,他們的區別以下:
基於這種機制,React實現了併發模式。官方文檔有一段話很好的描述了這種機制:
Conceptually, you can think of this as React preparing every update 「on a branch」. Just like you can abandon work in branches or switch between them, React in Concurrent Mode can interrupt an ongoing update to do something more important, and then come back to what it was doing earlier. This technique might also remind you of double buffering in video games.
從概念上講,你能夠將它視爲 React 「在分支上」準備每一次更新。就像你能夠放棄分支上的工做或者在它們之間切換同樣,React 在 Concurrent 模式中能夠中斷一項正在執行的更新去作一些更重要的事情,而後再回到以前正在作的工做。這項技術也許會使你想起電子遊戲中的雙重緩衝。
在這段話中,分支(branch)就是workInProgress。
requestAnimationFrame實現被移除了,取而代之的是MessageChannel實現。相比於rAF實現經過rAF之間的時間間隔去計算幀長,MessageChannel將幀長直接固定爲5ms。
也就是說,MessageChannel實現中,任務每次只會執行5ms,以後便會當即釋放主線程,把剩餘任務安排到下一次事件循環。(MessageChannel能夠直接理解成setTimeout,只不過它性能更好)
這樣作有以下好處:
基於上述暫停機制,React解決了CPU計算密集型的問題,所以使用React併發模式開發的web應用將會帶來更好的用戶體驗。可是React團隊沒有止步於此,他們又推出了幾個新的API:
"組合"使用這些API,咱們將能夠從一個全新的維度去優化用戶體驗——網速快性能高的設備的頁面渲染將更快,網速慢性能差的設備的頁面體驗將更好。
繼續閱讀以前,讀者必需要知道這幾個API的用法,不然將沒有意義。
Suspense的思想並不複雜,其實咱們徹底能夠本身實現Suspense組件,這裏是一個超簡化的React Suspense實現 例子:
這個實現使用了React的錯誤邊界的概念。Suspense的實現原理就是Suspense所包裹的子組件內部throw一個promise出來,而後被Suspense的componentDidCatch捕獲到,在其內部處理這個promise,從而實現Suspense的render函數的三元表達式條件渲染的功能。
然而做爲一個框架,React會考慮更多。好比,在上面的這個極簡例子中,在使用三元表達式進行條件渲染時,不可避免的會致使children被卸載,也就是說子組件的狀態會丟失。爲了解決這個問題,React在處理Suspense組件時會有一個特別的reconcile過程:
當渲染Suspense被掛起,也就是渲染其fallback組件時,React會同時生成兩個fiber,一個是fallbackFiber,一個是primaryFiber,它們分別用來維護fallback組件的信息和子組件的信息。這樣,即便子組件被卸載,組件的狀態信息依舊會維持在primaryFiber之中。
使用Suspense配合useTransition能達到這樣的效果:
在加載異步數據時,Suspense所包裹的子組件不會當即掛起,而是嘗試在當前狀態繼續停留一段時間,這個時間由timeoutMs指定。若是在timeoutMs以內,異步數據已經加載完成,那麼子組件就會直接更新成最新狀態;反之,若是超過了timeoutMs,異步數據尚未加載完成,那麼纔會去渲染Suspense的fallback組件。
這樣,在高網速高性能的設備上,一些沒必要要的loading狀態將完全消失,用戶體驗獲得進一步優化,這就是所謂的延遲渲染。
延遲渲染並非延遲reconcile,而是延遲reconcile的結果(workInProgress)渲染到屏幕上,舉兩個例子:
例子1:
假設子組件經過useTransition在第0ms開始拉取異步數據,同時假設timeoutMs爲500ms,異步數據拉取耗時300ms,那麼這整個過程會是這樣:
這樣這整個流程就走完了。
例子2:
假設子組件經過useTransition在第0ms開始拉取異步數據,同時假設timeoutMs爲500ms,異步數據拉取耗時600ms,那麼這整個過程會是這樣:
這樣這整個流程就走完了。
根據這兩個例子,印證了咱們的結論:延遲渲染並非延遲reconcile,而是延遲reconcile的結果(workInProgress)渲染到屏幕上。
若是理解了Suspense和useTransition的原理,這兩個API就很好理解了,所以在這裏就再也不贅述了。
明天就要返工了,你們必定要作好防禦措施,在『20200202』這個特別的日子,祝安康!