大家好,我卡頌。
React
內部最難理解的地方就是排程演算法,不僅抽象、複雜,還重構了一次。
可以說,只有React
團隊自己才能完全理解這套演算法。
既然這樣,那本文嘗試從React
團隊成員的視角出發,來聊聊排程演算法。
歡迎加入人類高質量前端框架群,帶飛
什麼是排程演算法
React
在v16之前面對的主要效能問題是:當元件樹很龐大時,更新狀態可能造成頁面卡頓,根本原因在於:更新流程是同步、不可中斷的。
為了解決這個問題,React
提出Fiber
架構,意在將更新流程變為非同步、可中斷的。
最終實現的互動流程如下:
- 不同互動產生不同優先順序的更新(比如
onClick
回撥中的更新優先順序最高,useEffect
回撥中觸發的更新優先順序一般) - 排程演算法從眾多更新中選出一個
優先順序
作為本次render
的優先順序 - 以步驟2選擇的
優先順序
對元件樹進行render
在render
過程中,如果又觸發互動流程,步驟2又選出一個更高優先順序
,則之前的render
中斷,以新的優先順序
重新開始render
。
本文要聊的就是步驟2中的排程演算法。
expirationTime排程演算法
排程演算法需要解決的最基本的問題是:如何從眾多更新中選擇其中一個更新的優先順序
作為本次render
的優先順序?
最早的演算法叫做expirationTime演算法
。
具體來說,更新的優先順序與觸發互動的當前時間及優先順序對應的延遲時間相關:
// MAX_SIGNED_31_BIT_INT為最大31 bit Interger
update.expirationTime = MAX_SIGNED_31_BIT_INT - (currentTime + updatePriority);
例如,高優先順序更新u1、低優先順序更新u2的updatePriority
分別為0、200,則
MAX_SIGNED_31_BIT_INT - (currentTime + 0) > MAX_SIGNED_31_BIT_INT - (currentTime + 200)
// 即
u1.expirationTime > u2.expirationTime;
代表u1
優先順序更高。
expirationTime演算法
的原理簡單易懂:每次都選出所有更新中優先順序最高的。
如何表示“批次”
除此之外,還有個問題需要解決:如何表示批次?
批次是什麼?考慮如下例子:
// 定義狀態num
const [num, updateNum] = useState(0);
// ...某些修改num的地方
// 修改的方式1
updateNum(3);
// 修改的方式2
updateNum(num => num + 1);
兩種修改狀態的方式都會建立更新
,區別在於:
- 第一種方式,不需考慮更新前的狀態,直接將狀態
num
修改為3 - 第二種方式,需要基於更新前的狀態計算新狀態
由於第二種方式的存在,更新
之間可能有連續性。
所以排程演算法計算出一個優先順序
後,元件render
時實際參與計算當前狀態的值的是:
計算出的優先順序對應更新 + 與該優先順序相關的其他優先順序對應更新
這些相互關聯,有連續性的更新
被稱為一個批次(batch
)。
expirationTime演算法
計算批次的方式也簡單粗暴:優先順序大於某個值(priorityOfBatch
)的更新
都會劃為同一批次。
const isUpdateIncludedInBatch = priorityOfUpdate >= priorityOfBatch;
expirationTime演算法
保證了render
非同步可中斷、且永遠是最高優先順序的更新
先被處理。
這一時期該特性被稱為Async Mode
。
IO密集型場景
Async Mode
可以解決以下問題:
- 元件樹邏輯複雜導致更新時卡頓(因為元件
render
變為可中斷
) - 重要的互動更快響應(因為不同互動產生
更新
的優先順序
不同)
這些問題統稱為CPU密集型問題
。
在前端,還有一類問題也會影響體驗,那就是請求資料造成的等待。這類問題被稱為IO密集型問題
。
為了解決IO密集型問題
的,React
提出了Suspense
。考慮如下程式碼:
const App = () => {
const [count, setCount] = useState(0);
useEffect(() => {
const t = setInterval(() => {
setCount(count => count + 1);
}, 1000);
return () => clearInterval(t);
}, []);
return (
<>
<Suspense fallback={<div>loading...</div>}>
<Sub count={count} />
</Suspense>
<div>count is {count}</div>
</>
);
};
其中:
- 每過一秒會觸發一次更新,將狀態
count
更新為count => count + 1
- 在
Sub
中會發起非同步請求,請求返回前,包裹Sub
的Suspense
會渲染fallback
假設請求三秒後返回,理想情況下,請求發起前後UI
會依次顯示為:
// Sub內請求發起前
<div class=“sub”>I am sub, count is 0</div>
<div>count is 0</div>
// Sub內請求發起第1秒
<div>loading...</div>
<div>count is 1</div>
// Sub內請求發起第2秒
<div>loading...</div>
<div>count is 2</div>
// Sub內請求發起第3秒
<div>loading...</div>
<div>count is 3</div>
// Sub內請求成功後
<div class=“sub”>I am sub, request success, count is 4</div>
<div>count is 4</div>
從使用者的視角觀察,有兩個任務在併發執行:
- 請求
Sub
的任務(觀察第一個div
的變化) - 改變
count
的任務(觀察第二個div
的變化)
Suspense
帶來了多工併發執行的直觀感受。
因此,Async Mode
(非同步模式)也更名為Concurrent Mode
(併發模式)。
一個無法解決的bug
那麼Suspense對應更新
的優先順序是高還是低呢?
當請求成功後,合理的邏輯應該是儘快展示成功後的UI。所以Suspense對應更新
應該是高優先順序更新
。那麼,在示例中共有兩類更新:
Suspense
對應的高優IO更新,簡稱u0
- 每秒產生的低優
CPU
更新,簡稱u1
、u2
、u3
等
在expirationTime演算法
下:
// u0優先順序遠大於u1、u2、u3...
u0.expirationTime >> u1.expirationTime > u2.expirationTime > …
u0
優先順序最高,則u1
及之後的更新
都需要等待u0
執行完畢後再進行。
而u0
需要等待請求完畢才能執行。所以,請求發起前後UI
會依次顯示為:
// Sub內請求發起前
<div class=“sub”>I am sub, count is 0</div>
<div>count is 0</div>
// Sub內請求發起第1秒
<div>loading...</div>
<div>count is 0</div>
// Sub內請求發起第2秒
<div>loading...</div>
<div>count is 0</div>
// Sub內請求發起第3秒
<div>loading...</div>
<div>count is 0</div>
// Sub內請求成功後
<div class=“sub”>I am sub, request success, count is 4</div>
<div>count is 4</div>
從使用者的視角觀察,第二個div
被卡住了3秒後突然變為4。
所以,只考慮CPU密集型場景
的情況下,高優更新先執行的演算法並無問題。
但考慮IO密集型場景
的情況下,高優IO更新
會阻塞低優CPU更新
,這顯然是不對的。
所以expirationTime演算法
並不能很好支援併發更新。
expirationTime演算法線上Demo
出現bug的原因
expirationTime演算法
最大的問題在於:expirationTime
欄位耦合了優先順序與批次這兩個概念,限制了模型的表達能力。
這導致高優IO更新
不會與低優CPU更新
劃為同一批次。那麼低優CPU更新
就必須等待高優IO更新
處理完後再處理。
如果不同更新能根據實際情況靈活劃分批次,就不會產生這個bug
。
重構迫在眉睫,並且重構的目標很明確:將優先順序與批次拆分到兩個欄位中。
Lane排程演算法
新的排程演算法被稱為Lane
,他是如何定義優先順序與批次呢?
對於優先順序
,一個lane
就是一個32bit Interger
,最高位為符號位,所以最多可以有31個位參與運算。
不同優先順序對應不同lane
,越低的位代表越高的優先順序,比如:
// 對應SyncLane,為最高優先順序
0b0000000000000000000000000000001
// 對應InputContinuousLane
0b0000000000000000000000000000100
// 對應DefaultLane
0b0000000000000000000000000010000
// 對應IdleLane
0b0100000000000000000000000000000
// 對應OffscreenLane,為最低優先順序
0b1000000000000000000000000000000
批次則由lanes
定義,一個lanes
同樣也是一個32bit Interger
,代表一到多個lane的集合。
可以用位運算
很輕鬆的將多個lane
劃入同一個批次
:
// 要使用的批次
let lanesForBatch = 0;
const laneA = 0b0000000000000000000000001000000;
const laneB = 0b0000000000000000000000000000001;
// 將laneA納入批次中
lanesForBatch |= laneA;
// 將laneB納入批次中
lanesForBatch |= laneB;
上文提到的Suspense
的bug
是由於expirationTime演算法
不能靈活劃定批次
導致的。
lanes
就完全沒有這種顧慮,任何想劃定為同一批次的優先順序
(lane)都能用位運算
輕鬆搞定。
總結
排程演算法要解決兩個問題:
- 選取優先順序
- 選取批次
expirationTime演算法
中使用的expirationTime
欄位耦合了這兩個概念,導致不夠靈活。
Lane演算法
的出現解決了以上問題。