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