React Concurrent Mode三連:是什么/為什么/怎么做
最近發布的React v17.0沒有包含新特性。
究其原因,v17.0主要的工作在于源碼內部對Concurrent Mode的支持。所以v17版本也被稱為“墊腳石”版本。
本文會詳細介紹Concurrent Mode的來龍去脈,以及這套體系從底層架構到上層API的實現。
由于跨度比較長,細節難免缺失。對文中提到的細節的進一步補足,歡迎關注我的工粽號 —— 魔術師卡頌,給你一份完整的源碼學習方案。
是什么?
Concurrent Mode是什么?你可以從官網Concurrent 模式介紹[1]了解其基本概念。
一句話概括:
- Concurrent 模式是一組 React 的新功能,可幫助應用保持響應,并根據用戶的設備性能和網速進行適當的調整。
為了讓應用保持響應,我們需要先了解是什么在制約應用保持響應?
我們日常使用App,瀏覽網頁時,有兩類場景會制約保持響應:
- 當遇到大計算量的操作或者設備性能不足使頁面掉幀,導致卡頓。
- 發送網絡請求后,由于需要等待數據返回才能進一步操作導致不能快速響應。
這兩類場景可以概括為:
- CPU的瓶頸
- IO的瓶頸
CPU的瓶頸
當項目變得龐大、組件數量繁多時,就容易遇到CPU的瓶頸。
考慮如下Demo,我們向視圖中渲染3000個li:
- function App() {
- const len = 3000;
- return (
- <ul>
- {Array(len).fill(0).map((_, i) => <li>{i}</li>)}
- </ul>
- );
- }
- const rootEl = document.querySelector("#root");
- ReactDOM.render(<App/>, rootEl);
主流瀏覽器刷新頻率為60Hz,即每(1000ms / 60Hz)16.6ms瀏覽器刷新一次。
我們知道,JS可以操作DOM,GUI渲染線程與JS線程是互斥的。所以JS腳本執行和瀏覽器布局、繪制不能同時執行。
在每16.6ms時間內,需要完成如下工作:
- JS腳本執行 ----- 樣式布局 ----- 樣式繪制
當JS執行時間過長,超出了16.6ms,這次刷新就沒有時間執行樣式布局和樣式繪制了。
在Demo中,由于組件數量繁多(3000個),JS腳本執行時間過長,頁面掉幀,造成卡頓。
可以從打印的執行堆棧圖看到,JS執行時間為73.65ms,遠遠多于一幀的時間。
如何解決這個問題呢?
答案是:在瀏覽器每一幀的時間中,預留一些時間給JS線程,React利用這部分時間更新組件(可以看到,在源碼[2]中,預留的初始時間是5ms)。
當預留的時間不夠用時,React將線程控制權交還給瀏覽器使其有時間渲染UI,React則等待下一幀時間到來繼續被中斷的工作。
- 這種將長任務分拆到每一幀中,像螞蟻搬家一樣一次執行一小段任務的操作,被稱為時間切片(time slice)
所以,解決CPU瓶頸的關鍵是實現時間切片,而時間切片的關鍵是:將同步的更新變為可中斷的異步更新。
IO的瓶頸
網絡延遲是前端開發者無法解決的。如何在網絡延遲客觀存在的情況下,減少用戶對網絡延遲的感知?
React給出的答案是將人機交互研究的結果整合到真實的 UI 中[3]。
這里我們以業界人機交互最頂尖的蘋果舉例,在IOS系統中:
點擊“設置”面板中的“通用”,進入“通用”界面:
作為對比,再點擊“設置”面板中的“Siri與搜索”,進入“Siri與搜索”界面:
你能感受到兩者體驗上的區別么?
事實上,點擊“通用”后的交互是同步的,直接顯示后續界面。
而點擊“Siri與搜索”后的交互是異步的,需要等待請求返回后再顯示后續界面。
但從用戶感知來看,這兩者的區別微乎其微。
這里的竅門在于:點擊“Siri與搜索”后,先在當前頁面停留了一小段時間,這一小段時間被用來請求數據。
當“這一小段時間”足夠短時,用戶是無感知的。如果請求時間超過一個范圍,再顯示loading的效果。
試想如果我們一點擊“Siri與搜索”就顯示loading效果,即使數據請求時間很短,loading效果一閃而過。用戶也是可以感知到的。
為此,React實現了Suspense[4]、useDeferredValue[5]。
在源碼內部,為了支持這些特性,同樣需要將同步的更新變為可中斷的異步更新。
Concurrent Mode自底向上
底層基礎決定了上層API的實現,接下來讓我們了解下,Concurrent Mode自底向上都包含哪些組成部分,才能實現上文提到的功能。
底層架構 —— Fiber架構
從上文我們了解到,為了解決CPU、IO瓶頸,最關鍵的一點是:實現異步可中斷的更新。
基于這個前提,React花費2年時間重構完成了Fiber架構。
Fiber機構的意義在于,他將單個組件作為工作單元,使以組件為粒度的“異步可中斷的更新”成為可能。
架構的驅動力 —— Scheduler
如果我們同步運行Fiber架構(通過ReactDOM.render),則Fiber架構與重構前并無區別。
但是當我們配合時間切片,就能根據宿主環境性能,為每個工作單元分配一個可運行時間,實現“異步可中斷的更新”。
于是,scheduler[6](調度器)產生了。
Scheduler能保證我們的長任務被拆分到每一幀不同的task中。
當我們為上文講到的渲染3000個li的Demo開啟Concurrent Mode:
- // 通過使用ReactDOM.unstable_createRoot開啟Concurrent Mode
- // ReactDOM.render(<App/>, rootEl);
- ReactDOM.unstable_createRoot(rootEl).render(<App/>);
可以看到,每段JS腳本執行時間大體在5ms左右。
這樣瀏覽器就有剩余時間執行樣式布局和樣式繪制,減少掉幀的可能性。
Fiber架構配合Scheduler實現了Concurrent Mode的底層剛需 —— “異步可中斷的更新”。
架構運行策略 —— lane模型
到目前為止,通過Scheduler,React可以控制更新在Fiber架構中運行/中斷/繼續運行。
基于當前的架構,當一次更新在運行過程中被中斷,過段時間再繼續運行,這就是“異步可中斷的更新”。
當一次更新在運行過程中被中斷,轉而重新開始一次新的更新,我們可以說:后一次更新打斷了前一次更新。
這就是優先級的概念:后一次更新的優先級更高,他打斷了正在進行的前一次更新。
多個優先級之間如何互相打斷?優先級能否升降?本次更新應該賦予什么優先級?
這就需要一個模型控制不同優先級之間的關系與行為,于是lane模型誕生了。
- lane模型通過將不同優先級賦值給一個位,通過31位的位運算來操作優先級
如下是不同優先級的定義:
- export const NoLanes: Lanes = /* */ 0b0000000000000000000000000000000;
- export const NoLane: Lane = /* */ 0b0000000000000000000000000000000;
- export const SyncLane: Lane = /* */ 0b0000000000000000000000000000001;
- export const SyncBatchedLane: Lane = /* */ 0b0000000000000000000000000000010;
- export const InputDiscreteHydrationLane: Lane = /* */ 0b0000000000000000000000000000100;
- const InputDiscreteLanes: Lanes = /* */ 0b0000000000000000000000000011000;
- // 省略...
上層實現
現在,我們可以說:
- 從源碼層面講,Concurrent Mode是一套可控的“多優先級更新架構”。
那么基于該架構之上可以實現哪些有意思的功能?我們舉幾個例子:
batchedUpdates
如果我們在一次事件回調中觸發多次更新,他們會被合并為一次更新進行處理。
如下代碼執行只會觸發一次更新:
- onClick() {
- this.setState({stateA: 1});
- this.setState({stateB: false});
- this.setState({stateA: 2});
- }
這種合并多個更新的優化方式被稱為batchedUpdates。
batchedUpdates在很早的版本就存在了,不過之前的實現局限很多(脫離當前上下文環境的更新不會被合并)。
在Concurrent Mode中,是以優先級為依據對更新進行合并的,使用范圍更廣。
Suspense
Suspense[7]可以在組件請求數據時展示一個pending狀態。請求成功后渲染數據。
本質上講Suspense內的組件子樹比組件樹的其他部分擁有更低的優先級。
useDeferredValue
useDeferredValue[8]返回一個延遲響應的值,該值可能“延后”的最長時間為timeoutMs。
例子:
- const deferredValue = useDeferredValue(value, { timeoutMs: 2000 });
在useDeferredValue內部會調用useState并觸發一次更新。
這次更新的優先級很低,所以當前如果有正在進行中的更新,不會受useDeferredValue產生的更新影響。所以useDeferredValue能夠返回延遲的值。
當超過timeoutMs后useDeferredValue產生的更新還沒進行(由于優先級太低一直被打斷),則會再觸發一次高優先級更新。
總結
除了以上介紹的實現,可以預見,當v17完美支持Concurrent Mode后,v18會迎來一大波基于Concurrent Mode的庫。