React 并發模式到底是個啥?

在計算機里,并發「concurrent」一詞,最早是用來表示多個任務同時進行。但是由于早期的計算機能力有限,單核計算機同一時間,只能運行一個任務。因此,為了做到看上去多個應用是在同時運行的,單核計算機就快速的在不同的應用中來回切換,它執行完 A 應用的一個任務,就執行 B 應用的任務,只要切換得足夠快,對于用戶而言,A 應用與 B 應用就是在同時運行。
因此,對于單核 CPU 來說,多個任務同時執行這種情況并不存在。
后來的主流計算機已經可以做到多個任務同時執行了,但是并發一詞已經有了自己專屬的場景,于是我們把真正的多個任務同時執行又重新取了一個名字,并行「parallel」
而并發則保留了它原本在單核 CPU 上的的含義:多個任務切換執行。為了知道下一個任務到底應該是誰執行了,那么單核 CPU 上必定會設計一個調度模式,用來確定任務的優先級。因此,并發的另外一個角度的解讀,就是多個任務對同一執行資源的競爭。
一、React 的并發
在頁面使用 JS 操作 DOM 渲染頁面的過程中,也是同樣的道理,他不存在有兩個任務能同時執行的情況。不過,React 設計了一種機制,來模擬渲染資源的競爭。
首先,React 設計了一個調度器,Scheduler,來調度任務的優先級。
但是在爭取誰更先渲染這個事情,在瀏覽器的渲染原理里,他經不起推敲。為什么呢?因為瀏覽器的底層渲染機制有收集邏輯,他會合并所有的渲染指令
div.style.color = 'red'
div.style.backgroundColor = '#FFF'
...多個指令,會被合并成一個渲染任務。那也就意味著,對于瀏覽器而言,不存在渲染資源的競爭,因為不同的渲染指令都會被合并。既然這樣,那 React 的并發又是怎么回事呢?
還有更詭異的事情,React 的渲染指令,是通過 setState 來觸發,我們知道,多個 setState 指令,React 也會將他們合并批處理
setLoading(false)
setList([])
// 等價于
setState({
loading: false,
list: []
})既然如此,并發體現在什么地方呢?也不存在渲染資源的競爭啊?我們看不到任務的切換執行,也看不到不同任務對渲染資源的競爭。所以真相就是...
大多數情況下,React 確實并不存在任何并發現象。
而事實上,當我們已經明確了哪些 DOM 需要被操作,對于瀏覽器來說,他可以足夠快的渲染更新,因此,在一幀的時間里,就算合并非常多的 DOM 操作,瀏覽器也足以應對。夠用,就表示競爭毫無意義。
只有在渲染超大量的 DOM 和大量表單時,瀏覽器的渲染引擎表示有壓力
因此,資源競爭只會發生在,渲染能力不夠用的時候。
一次渲染包括兩個部分,一個部分是 JS 邏輯,我們需要在 JS 邏輯中明確具體的 DOM 操作是什么。第二個部分是渲染引擎執行渲染任務。很明顯,對于 React 而言,他無法改變渲染引擎的邏輯。那么也就意味著,React 的并發只會發生在第一個部分:JS 邏輯中。
因此,react 還設計了第二步驟,Reconciler。當我們通過 setState 觸發一個渲染任務時,react 需要在 Reconciler 中,利用 diff 算法找出來哪些 DOM 需要被更改。如果多個 setState 指令合并之后,我們發現 diff 過程超出了一幀的時間,這個時候就有可能會存在渲染資源的競爭。
Scheduler | Reconciler | Renderer |
收集 | diff | 操作 DOM |
優先級 | 可中斷 |
但是,如果只有一幀超出的時候,這一幀之后,瀏覽器再也沒有新的渲染任務,那么就算超出了也無所謂。也沒有必要去競爭渲染資源,只有一種可能,那就是短時間之內需要多次渲染。如果每一幀的時間都超標了,那么頁面就會卡頓。
因此,只有在短時間之內頁面需要多次渲染,才會存在資源競爭的情況。這個時候我們才會考慮并發的存在。
我們還需要進一步思考。剛才我們已經分析出,只有在短時間之內多次渲染,并且造成了頁面卡頓,我們才會考慮并發。說明此時我們想要使用并發來解決的問題就是讓頁面不卡頓。因此,在多次渲染的前提下,多個任務的競爭結果就一定是渲染任務總量減少了,才會不卡頓。所以我們要做的事情就是,找出優先級更低的任務,即使他掉幀,只要不影響頁面卡頓,我們都可以接受。
在 React 的底層設計中,setState 是一個任務,但是這個任務會影響哪些 UI 發生變化,它就可能會對應多個 Fiber,每一個 Fiber 的執行都是一個小任務,我們可以把一個任務看成一個函數。
一旦一個任務開始執行之后,React 不具備提前判斷這個任務執行結束需要多少時間。只有等他執行完了,我們才能夠算出來他一共執行了多久。因此,對于哪些 setState 是耗時較長的任務,React 無法判斷,只有通過開發者自己去判斷。我們需要在觸發 setState 時,就標記這個任務的優先級,否則 react 也判斷不了這個任務是否耗時比較長。因此,我們需要手動使用 startTransition 來標記耗時的 setState
function TabContainer() {
const [isPending, startTransition] = useTransition();
const [tab, setTab] = useState('about');
function selectTab(nextTab) {
startTransition(() => {
setTab(nextTab);
});
}
// ……
}另外一個問題就是,競爭是如何發生的。
通過時間切片中斷任務的執行,給優先級更高的任務一個插隊的機會。
例如上面例子,當我們使用 StartTransition 標記了 setTab 為一個耗時較長的任務時。setTab 會有許多小的 Fiber 節點任務組成,我們在 Reconciler 階段執行每一個小的 Fiber 節點任務之前,都會判斷此時是否應該打斷循環。
function workLoop(hasTimeRemaining, initialTime) {
var currentTime = initialTime;
advanceTimers(currentTime);
currentTask = peek(taskQueue);
while (currentTask !== null && !(enableSchedulerDebugging )) {
if (currentTask.expirationTime > currentTime && (!hasTimeRemaining || shouldYieldToHost())) {
// 當前任務尚未過期,但時間已經到了最后期限
break;
}這里的 frameInterval 的具體值為 5ms,就是一個時間分片。也就是說,在 子 Fiber 任務執行的遍歷過程中,每大于 5ms,就會被打斷一次。這樣才有給更高優先級任務執行的機會。
function shouldYieldToHost() {
var timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) { // 5ms
// 主線程只被阻塞了很短時間;
// smaller than a single frame. Don't yield yet.
return false;
}
// 主線程被阻塞的時間不可忽視
return true;
}這里需要注意的是,setTab 最終被中斷,是由于時間分片之內沒有足夠的時間給他執行每一個 Fiber 節點任務,而并非是由更高優先級的任務產生了導致它的中斷。優先級只會影響隊列的排序結果。
例如,假設 setTab 影響的 UI 中包含一個父級 Fiber 節點和 250 個子級Fiber 節點。如果我們對子 Fiber 節點增加一個 1ms 的阻塞,此時就至少有 50 個中斷間隔給優先級更高的任務執行。
function Item(props: { text: string }) {
let startTime = performance.now();
while (performance.now() - startTime < 1) {}
console.log('text')
return (
<div>{props.text}</div>
)
}因此,在真實的渲染邏輯中,如果我的設備足夠強悍,執行速度足夠快,就算是我標記了低優先級,也可能不會被中斷。
這里還需要注意的是,任務的最小單位是 Fiber,如果你的單個 Fiber 執行時間過長,react 也無法拆分這個任務。這種情況下,我們應該想辦法把執行壓力分散到子組件中去。
二、總結
到目前為止,React 的并發模式就只體現在任務優先級和任務可被中斷上。如果單獨考慮任務可被中斷,他實現的效果就跟防抖、節流比較類似,概念比較高大上,但說穿了其實也沒啥用。如果你不用 useTransition/useDefferedValue 的話,基本上你的任務也不會被中斷。
但是如果不考慮任務可被中斷呢,優先級隊列其實也沒啥太大的意義。所以 react 的并發模式,從我個人主觀的角度來看的話,宣傳意義大于實際意義。




























