零代碼深刻淺出React併發模式,帶你理解React Fiber架構

React Fiber架構有必定的複雜度,若是硬着頭皮去啃源碼,咱們會深陷於龐大的代碼量和實現細節之中,每每學不到什麼東西。html

React併發模式是ReactFiber架構的重要應用,本文不貼任何React源碼,純粹使用文字幫助你們從併發模式的角度去理解React Fiber架構。react

☕️這不是一篇關於React併發模式API使用的文章。爲節約篇幅,下文不會詳細介紹API的用法,只會講解原理。所以,本文假設讀者已經瞭解過React併發模式相關概念。git

🍺爲了方便讀者理解,本文中的一些關鍵名詞,在最後一小節給出了簡單的解釋github

React倉庫狀態 📦

目前React代碼庫(v16.12.0)已經全面使用Fiber架構重構,並同時存在三種模式:web

  • Legacy Mode(咱們正在用的), 使用ReactDOM.render(...)
  • Blocking Mode, 使用ReactDOM.createBlockingRoot(...).render(...)
  • Concurrent Mode, 使用ReactDOM.createRoot(...).render(...)

可是源碼編譯後只會暴露出Legacy Mode的接口,由於併發模式如今還不是很穩定。chrome

它們的特色以下:npm

  • Legacy Mode:同步地進行Reconcile Fiber,Reconcile任務不能被打斷,會執行到底
  • Blocking Mode:同步地進行Reconcile Fiber,Reconcile任務不能被打斷,會執行到底
  • Concurrent Mode:「併發地」進行Reconcile Fiber,Reconcile任務能夠被打斷

更多細節請看👉:詳細區別promise

注意,Concurrent Mode所謂的併發,只是一種假象,跟多線程併發徹底不同。多線程併發是多個Task跑在多個線程中,這是真正意義上的併發。而Concurrent Mode的併發是指多個Task跑在同一個主線程(JS主線程)中,只不過每一個Task均可以不斷在「運行」和「暫停」兩種狀態之間切換,從而給用戶形成了一種Task併發執行的假象。這其實跟CPU的執行原理同樣,這就叫時間分片(Time Slicing)。瀏覽器

所以,如今咱們經過npm i react所安裝的包使用ReactDOM.render(...)所建立的React應用的狀態是這樣的:多線程

  • Fiber:React的Reconcile過程的最小處理單元
  • Sync:React的Reconcile過程不能被打斷,是同步的
  • unbatchedUpdates:在非React的事件中(好比setTimeout),setState沒法被批處理
  • Suspense:僅能用於加載異步組件

總的來講就是:雖然已經使用Fiber重構了,可是其實仍是老樣子😔

若是想體驗併發模式請看👉:Adopting Concurrent Mode

卡頓的本質 ⌛️

在分析以前,咱們先來探究一下卡頓的本質。

顯示器刷新率(Refresh Rate)與瀏覽器幀率(Frame Rate or FPS)

刷新率是硬件層面的概念,它是恆定不變的。大部分顯示器的刷新速率都是60Hz,也就是每隔16ms讀取GPU Buffer中數據,顯示到屏幕上,對外表現爲一次屏幕刷新,這個過程叫v-sync。

幀率是軟件層面的概念,它是存在波動的。每一個軟件都會有一套本身的幀率控制策略,拿瀏覽器來講,它有多方面考慮:

  • 爲了保證性能的同時讓用戶不感受得卡頓,它會盡可能每隔16ms輸出圖像信息給GPU
  • 爲了減小電池損耗,在未插電源的時候下降幀率
  • 爲了減小內存佔用,在檢測到頁面上沒有用戶交互的時候下降幀率
  • 等等...

刷新率跟頁面卡頓沒有一毛錢關係,頁面的卡頓只跟幀率有關係。

什麼是卡頓

衆所周知,光線打到人類的視網膜能停留的時間大概是24ms,在考慮一些其餘因素,若是光每隔16ms打到視網膜上,人類看到的"連續畫面"就算比較舒服的了。

注意,若是是一個靜態畫面,就算每隔1天輸入一幀咱們也不會感受有什麼不一樣。但事實上,咱們所使用的大多數人機交互設備都是輸出動態畫面的,好比動畫、滾動、交互等。

若是一個連續畫面,沒有按照16ms/幀的速率輸出,那麼咱們就會感受到畫面不連續,也就是所謂的卡頓。

爲何會卡頓

這張圖叫作 The pixel pipeline,它描述了chrome瀏覽器像素的生成過程。

咱們能夠看到,首先要執行JavaScript生成DOM節點,以後纔會進行後續的Style/Layout/Pain/Composite過程,最終把畫面渲染出來。爲了方便分析問題,咱們把這些階段分爲兩個部分:

  • JavaScript:事件循環部分
  • Style/Layout/Pain/Composite:UI渲染部分

每一幀的頁面渲染都由這兩部分組成,也就是說這兩部分須要保證在16ms以內完成任務並輸出一幀畫面,用戶纔不會感受到卡頓。事實上,瀏覽器還有別的工做要作,因此可能最多隻有10ms左右的時間。

所以,最終能不能在10ms的時間內完成一幀畫面,取決於兩點:

  • 事件循環耗費了多久
  • UI渲染耗費了多久

本文將主要分析事件循環的問題,UI渲染的問題請看 👉:瀏覽器層合成與頁面渲染優化

若是這二者其中一個或多個耗費的時間過長,就會致使這一幀畫面花費了超過16ms才得以輸出,最終致使瀏覽器沒達到60FPS的幀率,體現到使用者的眼中就變成了不連續的畫面,進而感受到卡頓。

注意,這種現象稱爲"幀率下降",而不是"丟幀"。由於幀並無丟,只是輸出的速度變慢了。丟幀是幀率大於顯示器的刷新率,那麼這些多出來的幀率其實並無體現到顯示器上,跟丟了沒有區別。

React想要解決的兩類問題 🚨

解決CPU計算密集型的問題

React面臨着「想讓頁面及時響應用戶交互」與以下兩個事實之間的矛盾:

  • 「JS是單線程的」的事實
  • 「渲染頁面的確須要消耗CPU必定工做時間」的事實

想讓頁面及時響應用戶交互,就須要及時獲取主線程的執行權,可是渲染頁面又的確須要消耗主線程必定工做時間。

💡 可能有人會問,那麼React先執行「響應用戶交互」的任務不就行了嗎?這樣作,的確頁面會及時響應交互,可是頁面的渲染就會所以卡住,就至關於給頁面渲染加了一個debounce,這樣的交互體驗不是React想要的。

基於這兩個事實,有兩種解決思路:

  • 把頁面渲染的任務放到別的線程去跑,好比WebWorker
  • 讓頁面渲染的任務能夠在恰當的時候暫停,讓出主線程

爲何沒有用第一種方案,也許能夠參考這個討論👉:用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:負責調度任務,任務優先級可能各有不一樣
  • 執行任務
    • performWork:負責執行處理Fiber的任務,由Scheduler進行調度
    • Fiber:負責維護組件信息
    • workInProgress(WIP):負責維護當前正在處理的Fiber的中間結果

先來講說調度任務:

所謂調度任務,就是控制任務的執行時機,一般狀況下,任務會一個接着一個的串行執行。可是若是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的副本,他們的區別以下:

  • workInProgress所表示的FiberTree中,可能同時存在更新完畢的Fiber節點和未更新的Fiber節點
  • workInProgress所表示的FiberTree沒有體現到屏幕上,僅僅是停駐於內存中的一個變量而已

基於這種機制,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。

Scheduler最新狀態

requestAnimationFrame實現被移除了,取而代之的是MessageChannel實現。相比於rAF實現經過rAF之間的時間間隔去計算幀長,MessageChannel將幀長直接固定爲5ms。

也就是說,MessageChannel實現中,任務每次只會執行5ms,以後便會當即釋放主線程,把剩餘任務安排到下一次事件循環。(MessageChannel能夠直接理解成setTimeout,只不過它性能更好)

這樣作有以下好處:

  • 幀長穩定,rAF實現基於rAF回調的執行時間來計算幀長,是很是不穩定的,由於瀏覽器的幀數會由於各類因素產生波動,致使幀長存在很大偏差。
  • 更好地支持高刷新率設備,由於固定幀長5ms,其實就是假定瀏覽器幀率爲5ms/1幀,也就是1000ms/200幀,也就是最高能夠支持每秒200幀的幀率。

優化用戶體驗 🚀

基於上述暫停機制,React解決了CPU計算密集型的問題,所以使用React併發模式開發的web應用將會帶來更好的用戶體驗。可是React團隊沒有止步於此,他們又推出了幾個新的API:

  • Suspense
  • SuspenseList
  • useTransition
  • useDeferredValue

"組合"使用這些API,咱們將能夠從一個全新的維度去優化用戶體驗——網速快性能高的設備的頁面渲染將更快,網速慢性能差的設備的頁面體驗將更好。

繼續閱讀以前,讀者必需要知道這幾個API的用法,不然將沒有意義。

Suspense原理

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之中。

useTransition延遲渲染的原理

使用Suspense配合useTransition能達到這樣的效果:

在加載異步數據時,Suspense所包裹的子組件不會當即掛起,而是嘗試在當前狀態繼續停留一段時間,這個時間由timeoutMs指定。若是在timeoutMs以內,異步數據已經加載完成,那麼子組件就會直接更新成最新狀態;反之,若是超過了timeoutMs,異步數據尚未加載完成,那麼纔會去渲染Suspense的fallback組件。

這樣,在高網速高性能的設備上,一些沒必要要的loading狀態將完全消失,用戶體驗獲得進一步優化,這就是所謂的延遲渲染。

延遲渲染並非延遲reconcile,而是延遲reconcile的結果(workInProgress)渲染到屏幕上,舉兩個例子:


例子1:

假設子組件經過useTransition在第0ms開始拉取異步數據,同時假設timeoutMs爲500ms,異步數據拉取耗時300ms,那麼這整個過程會是這樣:

  1. 在第0ms一開始,React就會進行第一次reconcile,由於這個時候異步數據未加載完成,所以reconcile的結果所表示的組件實際上是fallback。而後reconcile的結果會存儲在內存中的workInProgress變量。假設reconcile耗時50ms,也就是說在50ms這個時間點,render階段已經完畢,接下來要作的事就要把workInProgress信息同步到dom上,也就是commit階段。 可是因爲咱們設置了timeoutMs,React不會當即去commit,而是去等待500ms以後再去commit。
  2. 因而時間來到了300ms,此時異步數據拉取完成,React再次進行reconcile,由於這個時候異步數據已經加載完成,所以reconcile的結果所表示的組件是真正的子組件。而後reconcile的結果又會複製給workInProgress這個變量,所以上一次的reconcile結果被覆蓋了。假設reconcile耗時100ms,也就是說在400ms這個時間點,render階段已經完畢,而後會直接進入到commit階段,當即把workInProgress渲染到頁面。
  3. 由於在400ms時已經commit了,那麼在500ms時就不會作任何事情了。

這樣這整個流程就走完了。


例子2:

假設子組件經過useTransition在第0ms開始拉取異步數據,同時假設timeoutMs爲500ms,異步數據拉取耗時600ms,那麼這整個過程會是這樣:

  1. 第一步同上,徹底同樣。
  2. 時間來到500ms,此時異步數據依舊沒有拉取完成,所以第一步的commit延遲時間已經到了,因此React會當即把fiber渲染到頁面,頁面因而會顯示fallback組件。
  3. 時間來到600ms,此時異步數據拉取完成,React再次進行reconcile,獲得下一個workInProgress,以後當即把workInProgress渲染到頁面。

這樣這整個流程就走完了。


根據這兩個例子,印證了咱們的結論:延遲渲染並非延遲reconcile,而是延遲reconcile的結果(workInProgress)渲染到屏幕上。

SuspenseList和useDeferredValue

若是理解了Suspense和useTransition的原理,這兩個API就很好理解了,所以在這裏就再也不贅述了。

名詞的簡單解釋

  • Fiber:Reconcile的最小單元,包含組件信息、組件狀態信息、組件關係信息、反作用列表等內容,能夠理解爲給每一個組件包了一層。
  • FiberTree:經過Fiber的組件關係信息:return(父),child(子),sibling(兄弟),構建出來的一顆樹。每個Fiber均可以表示一顆樹,所以FiberTree本質就是Fiber,它們是等價的。FiberTree中的任意Fiber節點均可以經過上述三個屬性描述出整個FiberTree。
  • Reconcile
    1. 經過遍歷FiberTree,完成各個Fiber的更新的過程就叫Reconcile。
    2. 這個過程分爲render和commit兩個階段,render階段的輸入是Fiber,輸出是更新後的Fiber,是純粹React層面的工做。commit階段輸入是更新後的Fiber,輸出是反作用執行、DOM更新;
    3. 只有render階段能夠被打斷。
  • workInProgress:上述中,在render階段輸出的更新後的Fiber就叫workInProgress,它本質上也是一個Fiber,不一樣的是,workInProgress僅僅存在於內存之中,尚未體現到屏幕上。

參考

最後

明天就要返工了,你們必定要作好防禦措施,在『20200202』這個特別的日子,祝安康!

相關文章
相關標籤/搜索