React18正式版源碼級剖析
本文適合對React18.0.0源碼感興趣的小伙伴閱讀。
歡迎關(guān)注前端早茶,與廣東靚仔攜手共同進(jìn)階~
一、前言
本文是廣東靚仔的好友bubucuo-高老師寫的,高老師最近在準(zhǔn)備React18的視頻,有興趣的小伙伴可以去學(xué)習(xí)學(xué)習(xí)。
React18最重要的改變必須是Concurrent,就像哪吒降生一樣,打磨了很長時間了,終于正式見人了。

Concurrent Or Concurrency,中文我們通常翻譯為并發(fā),也有少部分翻譯成并行。React已經(jīng)著手開發(fā)Concurrent幾年了,但是一直只存在于實驗版本。到了React18,Concurrent終于正式投入使用了。
Concurrent并不是API之類的特性,而是一種能讓你的React項目同時具有多個版本UI的幕后機制,相當(dāng)愛迪生背后的特斯拉。
Concurrent很重要,雖然它不是API之類的新特性,但是如果你想解鎖React18的大部分新特性,諸如transition、Suspense等,背后就要依賴Concurrent這位大佬。

是的,如果你不想追求high level,就別學(xué)了。
二、Concurrent
什么是Concurrent
Concurrent最主要的特點就是渲染是可中斷的。沒錯,以前是不可中斷的,也就是說,以前React中的update是同步渲染,在這種情況下,一旦update開啟,在任務(wù)完成前,都不可中斷。
注意:這里說的同步,和setState所謂的同步異步不是一碼事,而且setState所謂的異步本質(zhì)上是個批量處理。
Concurrent模式特點
在Concurrent模式下,update開始了也可以中斷,晚點再繼續(xù)嘛,當(dāng)然中間也可能被遺棄掉。
關(guān)于可中斷
先說可中斷這件事情的重要性。對于React來說,任務(wù)可能很多,如果不區(qū)分優(yōu)先級,那就是先來后到的順序。雖然聽起來很合理,但是現(xiàn)實是普通車輛就應(yīng)該給救護(hù)車讓路,因為事有輕重緩急嘛。那么在React中呢,如果高優(yōu)先級任務(wù)來了,但是低優(yōu)先級任務(wù)還沒有處理完畢,就會造成高優(yōu)先級任務(wù)等待的局面。比如說,某個低優(yōu)先級任務(wù)還在緩慢中,input框忽然被用戶觸發(fā),但是由于主線程被占著,沒有人搭理用戶,結(jié)果是用戶哐哐輸入,但是input沒有任何反應(yīng)。用戶一怒之下就走了,那你那個低優(yōu)先級的任務(wù)還更新個什么呢,用戶都沒了。
由此可見,對于復(fù)雜項目來說,任務(wù)可中斷這件事情很重要。那么問題來了,React是如何做到的呢,其實基礎(chǔ)還是fiber,fiber本身鏈表結(jié)構(gòu),就是指針嘛,想指向別的地方加個屬性值就行了。
關(guān)于被遺棄
在Concurrent模式下,有些update可能會被遺棄掉。先舉個??:比如說,我看電視的時候,切換遙控器,從1頻道切換到2頻道,再切換到3頻道,最后在4頻道停下來。假如這些頻道都是UI,那么2、3頻道的渲染其實我并不關(guān)心,我只關(guān)心4頻道的結(jié)果,如果你非要花時間把2和3頻道的UI也渲染出來,最終導(dǎo)致4頻道很久之后才渲染出來,那我肯定不開心。正確的做法應(yīng)該是盡快渲染4頻道就行了,至于2和3頻道,不管渲染了多少了,遺棄了就行了,反正也不需要了。
最后回到項目的實際場景,比如我想在淘寶搜索“老人與海”,那么我在輸入框輸入“老人與海”的過程中,“老人”會有對應(yīng)的模糊查詢結(jié)果,但是不一定是我想要的結(jié)果,所以這個時候的模糊查詢框的update就是低優(yōu)先級,“老人”對應(yīng)UI的update相對input的update,優(yōu)先級就會低一些。在現(xiàn)在React18中,這個模糊查詢相關(guān)的UI可以被當(dāng)做transition。關(guān)于transition,等下我會有細(xì)講。

關(guān)于狀態(tài)復(fù)用
Concurrent模式下,還支持狀態(tài)的復(fù)用。某些情況下,比如用戶走了,又回來,那么上一次的頁面狀態(tài)應(yīng)當(dāng)被保存下來,而不是完全從頭再來。當(dāng)然實際情況下不能緩存所有的頁面,不然內(nèi)存不得爆炸,所以還得做成可選的。目前,React正在用Offscreen組件來實現(xiàn)這個功能。嗯,也就是這關(guān)于這個狀態(tài)復(fù)用,其實還沒完成呢。不過源碼中已經(jīng)在做了:

另外,使用OffScreen,除了可以復(fù)用原先的狀態(tài),我們也可以使用它來當(dāng)做新UI的緩存準(zhǔn)備,就是雖然新UI還沒登場,但是可以先在后臺準(zhǔn)備著嘛,這樣一旦輪到它,就可以立馬快速地渲染出來。
Concurrent總結(jié)
總結(jié)一下,Concurrent并不是API之類的新特性,但是呢,它很重要,因為它是React18大部分新特性的實現(xiàn)基礎(chǔ),包括Suspense、transitions、流式服務(wù)端渲染等。三、React的新特性
前文說了那么多Concurrent并不是新特性,而是React18新特性的實現(xiàn)基礎(chǔ)。那么新特性都有哪些呢,下面來看吧:
react-dom/client中的createRoot
創(chuàng)建一個初次渲染或者更新,以前我們用的是ReactDOM.render,現(xiàn)在改用react-dom/client中的createRoot,這個函數(shù)的返回值是卸載函數(shù)。
ssr中的ReactDOM.hydrate也換成了新的hydrateRoot。
以上兩個API目前依然支持,只是已經(jīng)移入legacy模式,開發(fā)環(huán)境下會報warning。
自動批量處理 Automatic Batching
如果你是React技術(shù)棧,那么你一定遇到過無數(shù)次這樣的面試題:

先回答上面那個問題,可同步可異步,同步的話把setState放在promises、setTimeout或者原生事件中等。所謂異步就是個批量處理,為什么要批量處理呢。舉個例子,老人以打漁為生,難道要每打到一條沙丁魚就下船去集市上賣掉嗎,那跑來跑去的成本太高了,賣魚的錢都不夠路費的。所以老人都是打到魚之后先放到船艙,一段時間之后再跑一次集市,批量賣掉那些魚。對于React來說,也是這樣,state攢夠了再一起更新嘛。
//?以前:?這里的兩次setState并沒有批量處理,React會render兩次
setTimeout(()?=>?{
??setCount(c?=>?c?+?1);
??setFlag(f?=>?!f);
},?1000);
//?React18:?自動批量處理,這里只會render一次
setTimeout(()?=>?{
??setCount(c?=>?c?+?1);
??setFlag(f?=>?!f);
},?1000);
所以如果你項目中還在用setTimeout之列的“黑科技”實現(xiàn)setState的同步的話,升級React18之前,記得改一下~???//?import?{?flushSync?}?from?"react-dom";
???changeCount?=?()?=>?{
????const?{?count?}?=?this.state;
????flushSync(()?=>?{
??????this.setState({
????????count:?count?+?1,
??????});
????});
????console.log("改變count",?this.state.count);?//sy-log
??};
??
??//?transition
React把update分成兩種:
Urgent updates?緊急更新,指直接交互,通常指的用戶交互。如點擊、輸入等。這種更新一旦不及時,用戶就會覺得哪里不對。
Transition updates?過渡更新,如UI從一個視圖向另一個視圖的更新。通常這種更新用戶并不著急看到。
startTransition
startTransition可以用在任何你想更新的時候。但是從實際來說,以下是兩種典型適用場景:
渲染慢:如果你有很多沒那么著急的內(nèi)容要渲染更新。
網(wǎng)絡(luò)慢:如果你的更新需要花較多時間從服務(wù)端獲取。這個時候也可以再結(jié)合
Suspense。
import?{useEffect,?useState,?Suspense}?from?"react";
import?Button?from?"../components/Button";
import?User?from?"../components/User";
import?Num?from?"../components/Num";
import?{fetchData}?from?"../utils";
const?initialResource?=?fetchData();
export?default?function?TransitionPage(props)?{
??const?[resource,?setResource]?=?useState(initialResource);
??//?useEffect(()?=>?{
??//???console.log("resource",?resource);?//sy-log
??//?},?[resource]);
??return?(
????<div>
??????<h3>TransitionPageh3>
??????<Suspense?fallback={<h1>loading?-?userh1>}>
????????<User?resource={resource}?/>
??????Suspense>
??????<Suspense?fallback={<h1>loading-numh1>}>
????????<Num?resource={resource}?/>
??????Suspense>
??????<Button
????????refresh={()?=>?{
??????????setResource(fetchData());
????????}}
??????/>
????div>
??);
}
Button
import?{
??//startTransition,
??useTransition,
}?from?"react";
export?default?function?Button({refresh})?{
??const?[isPending,?startTransition]?=?useTransition();
??return?(
????<div?className="border">
??????<h3>Buttonh3>
??????<button
????????onClick={()?=>?{
??????????startTransition(()?=>?{
????????????refresh();
??????????});
????????}}
????????disabled={isPending}>
????????點擊刷新數(shù)據(jù)
??????button>
??????{isPending???<div>loading...div>?:?null}
????div>
??);
}
與setTimeout異同
在startTransition出現(xiàn)之前,我們可以使用setTimeout來實現(xiàn)優(yōu)化。但是現(xiàn)在在處理上面的優(yōu)化的時候,有了startTransition基本上可以拋棄setTimeout了,原因主要有以三點:首先,與setTimeout不同的是,startTransition并不會延遲調(diào)度,而是會立即執(zhí)行,startTransition接收的函數(shù)是同步執(zhí)行的,只是這個update被加了一個“transitions"的標(biāo)記。而這個標(biāo)記,React內(nèi)部處理更新的時候是會作為參考信息的。這就意味著,相比于setTimeout, 把一個update交給startTransition能夠更早地被處理。而在于較快的設(shè)備上,這個過度是用戶感知不到的。useTransition
在使用startTransition更新狀態(tài)的時候,用戶可能想要知道transition的實時情況,這個時候可以使用React提供的hook api?useTransition。
import?{?useTransition?}?from?'react';
const?[isPending,?startTransition]?=?useTransition();
如果transition未完成,isPending值為true,否則為false。
useDeferredValue
使得我們可以延遲更新某個不那么重要的部分。
相當(dāng)于參數(shù)版的transitions。
舉例:如下圖,當(dāng)用戶在輸入框輸入“書”的時候,用戶應(yīng)該立馬看到輸入框的反應(yīng),而相比之下,下面的模糊查詢框如果延遲出現(xiàn)一會兒其實是完全可以接受的,因為用戶可能會繼續(xù)修改輸入框內(nèi)容,這個過程中模糊查詢結(jié)果還是會變化,但是這個變化對用戶來說相對沒那么重要,用戶最關(guān)心的是看到最后的匹配結(jié)果。

用法如下:
import?{useDeferredValue,?useState}?from?"react";
import?MySlowList?from?"../components/MySlowList";
export?default?function?UseDeferredValuePage(props)?{
??const?[text,?setText]?=?useState("hello");
??const?deferredText?=?useDeferredValue(text);
??const?handleChange?=?(e)?=>?{
????setText(e.target.value);
??};
??return?(
????<div>
??????<h3>UseDeferredValuePageh3>
??????{/*?保持將當(dāng)前文本傳遞給?input?*/}
??????<input?value={text}?onChange={handleChange}?/>
??????{/*?但在必要時可以將列表“延后”?*/}
??????<p>{deferredText}p>
??????<MySlowList?text={deferredText}?/>
????div>
??);
}
MySlowList
import?React,?{memo}?from?"react";
function?ListItem({children})?{
??let?now?=?performance.now();
??while?(performance.now()?-?now?3)?{}
??return?<div?className="ListItem">{children}div>;
}
export?default?memo(function?MySlowList({text})?{
??let?items?=?[];
??for?(let?i?=?0;?i?80;?i++)?{
????items.push(
??????<ListItem?key={i}>
????????Result?#{i}?for?"{text}"
??????ListItem>
????);
??}
??return?(
????<div?className="border">
??????<p>
????????<b>Results?for?"{text}":b>
??????p>
??????<ul?className="List">{items}ul>
????div>
??);
});
Suspense
可以“等待”目標(biāo)UI加載,并且可以直接指定一個加載的界面(像是個 spinner),讓它在用戶等待的時候顯示。 }>
??<Comments?/>
</Suspense>
其實Suspense也早就出現(xiàn)在React中了,只不過之前功能有限。在React18中,背靠Concurrent模式,Suspense終于爆發(fā)了自己的光彩。基本使用:避免等待太久
import?{useState,?Suspense}?from?"react";
import?User?from?"../components/User";
import?Num?from?"../components/Num";
import?{fetchData}?from?"../utils";
import?ErrorBoundaryPage?from?"./ErrorBoundaryPage";
const?initialResource?=?fetchData();
export?default?function?SuspensePage(props)?{
??const?[resource,?setResource]?=?useState(initialResource);
??return?(
????<div>
??????<h3>SuspensePageh3>
??????<ErrorBoundaryPage?fallback={<h1>網(wǎng)絡(luò)出錯了h1>}>
????????<Suspense?fallback={<h1>loading?-?userh1>}>
??????????<User?resource={resource}?/>
????????Suspense>
??????ErrorBoundaryPage>
??????<Suspense?fallback={<h1>loading-numh1>}>
????????<Num?resource={resource}?/>
??????Suspense>
??????<button?onClick={()?=>?setResource(fetchData())}>refreshbutton>
????div>
??);
}
錯誤處理
每當(dāng)使用 Promises,大概率我們會用?catch()?來做錯誤處理。但當(dāng)我們用 Suspense 時,我們不等待?Promises 就直接開始渲染,這時?catch()?就不適用了。這種情況下,錯誤處理該怎么進(jìn)行呢?在 Suspense 中,獲取數(shù)據(jù)時拋出的錯誤和組件渲染時的報錯處理方式一樣——你可以在需要的層級渲染一個錯誤邊界組件來“捕捉”層級下面的所有的報錯信息。export?default?class?ErrorBoundaryPage?extends?React.Component?{
??state?=?{hasError:?false,?error:?null};
??static?getDerivedStateFromError(error)?{
????return?{
??????hasError:?true,
??????error,
????};
??}
??render()?{
????if?(this.state.hasError)?{
??????return?this.props.fallback;
????}
????return?this.props.children;
??}
}
結(jié)合transitions
所謂提高用戶體驗,一個重要的準(zhǔn)則就是保證UI的連續(xù)性,如下面的例子,如果此時我想把tab從‘photos’切換到‘comments’,但是Comments又沒法立馬渲染出來,這個時候不可避免地,就會Photos頁面消失,顯現(xiàn)Spinner的loading頁面,等一會兒,Comments頁面才姍姍來遲。function?handleClick()?{
??setTab('comments');
}
}>
??{tab?===?'photos'???<Photos?/>?:?<Comments?/>}
</Suspense>
從UI連續(xù)性上來說,這個中間出現(xiàn)的Spinner就已經(jīng)破壞了連續(xù)性。而實際上,正常人的反應(yīng)其實是沒有那么快,短暫的延遲我們是感覺不到的。所以考慮到UI的連續(xù)性,上面的例子,交互可不可以修改一下,把上面頁面的切換當(dāng)做transitions,這樣即使tab切換,但是依然短暫停留在Photos,之后再改變到Comments:
function?handleClick()?{
??startTransition(()?=>?{
????setTab('comments');
??});
}
const?[isPending,?startTransition]?=?useTransition();
function?handleClick()?{
??startTransition(()?=>?{
????setTab('comments');
??});
}
}>
??<div?style={{?opacity:?isPending???0.8?:?1?}}>
????{tab?===?'photos'???<Photos?/>?:?<Comments?/>}
??div>
</Suspense>SuspenseList
用于控制Suspense組件的顯示順序。
revealOrder?Suspense加載順序
together?所有Suspense一起顯示,也就是最后一個加載完了才一起顯示全部
forwards?按照順序顯示Suspense
backwards?反序顯示Suspense
tail是否顯示fallback,只在revealOrder為forwards或者backwards時候有效
hidden不顯示
collapsed輪到自己再顯示
import?{useState,?Suspense,?SuspenseList}?from?"react";
import?User?from?"../components/User";
import?Num?from?"../components/Num";
import?{fetchData}?from?"../utils";
import?ErrorBoundaryPage?from?"./ErrorBoundaryPage";
const?initialResource?=?fetchData();
export?default?function?SuspenseListPage(props)?{
??const?[resource,?setResource]?=?useState(initialResource);
??return?(
????<div>
??????<h3>SuspenseListPageh3>
??????<SuspenseList?tail="collapsed">
????????<ErrorBoundaryPage?fallback={<h1>網(wǎng)絡(luò)出錯了h1>}>
??????????<Suspense?fallback={<h1>loading?-?userh1>}>
????????????<User?resource={resource}?/>
??????????Suspense>
????????ErrorBoundaryPage>
????????<Suspense?fallback={<h1>loading-numh1>}>
??????????<Num?resource={resource}?/>
????????Suspense>
??????SuspenseList>
??????<button?onClick={()?=>?setResource(fetchData())}>refreshbutton>
????div>
??);
}四、新的Hooks

關(guān)于useTransition與useDeferredValue上面已經(jīng)介紹過了,接下來說下React18其它的新Hooks,其中useSyncExternalStore與useInsertionEffect屬于Library Hooks。也就是普通應(yīng)用開發(fā)者一般用不到,這倆主要用于那些需要深度融合React模型的庫開發(fā),比如Recoil等。
useId
用于產(chǎn)生一個在服務(wù)端與Web端都穩(wěn)定且唯一的ID,也支持加前綴,這個特性多用于支持ssr的環(huán)境下:
export?default?function?NewHookApi(props)?{
??const?id?=?useId();
??return?(
????<div>
??????<h3?id={id}>NewHookApih3>
????div>
??);
}
注意:useId產(chǎn)生的ID不支持css選擇器,如querySelectorAll。
useSyncExternalStore
const?state?=?useSyncExternalStore(subscribe,?getSnapshot[,?getServerSnapshot]);
此Hook用于外部數(shù)據(jù)的讀取與訂閱,可應(yīng)用Concurrent。
基本用法如下:
import?{?useStore?}?from?"../store";
import?{?useId,?useSyncExternalStore?}?from?"../whichReact";
export?default?function?NewHookApi(props)?{
??const?store?=?useStore();
??const?state?=?useSyncExternalStore(store.subscribe,?store.getSnapshot);
??return?(
????<div>
??????<h3>NewHookApih3>
??????<button?onClick={()?=>?store.dispatch({?type:?"ADD"?})}>{state}button>
????div>
??);
}
useStore是我另外定義的,
export?function?useStore()?{
??const?storeRef?=?useRef();
??if?(!storeRef.current)?{
????storeRef.current?=?createStore(countReducer);
??}
??return?storeRef.current;
}
function?countReducer(action,?state?=?0)?{
??switch?(action.type)?{
????case?"ADD":
??????return?state?+?1;
????case?"MINUS":
??????return?state?-?1;
????default:
??????return?state;
??}
}
這里的createStore用的redux思路:
export?function?createStore(reducer)?{
??let?currentState;
??let?listeners?=?[];
??function?getSnapshot()?{
????return?currentState;
??}
??function?dispatch(action)?{
????currentState?=?reducer(action,?currentState);
????listeners.map((listener)?=>?listener());
??}
??function?subscribe(listener)?{
????listeners.push(listener);
????return?()?=>?{
??????//???console.log("unmount",?listeners);
????};
??}
??dispatch({?type:?"TIANNA"?});
??return?{
????getSnapshot,
????dispatch,
????subscribe,
??};
}
對于還在用自定義store來做低代碼項目的我有點開心,可以用于升級我的項目了,原先定義的forceUpdate、unsubscribe之類的,可以去掉了~
useInsertionEffect
useInsertionEffect(didUpdate);
函數(shù)簽名同useEffect,但是它是在所有DOM變更前同步觸發(fā)。主要用于css-in-js庫,往DOM中動態(tài)注入?或者 SVG?。因為執(zhí)行時機,因此不可讀取refs。
function?useCSS(rule)?{
??useInsertionEffect(()?=>?{
????if?(!isInserted.has(rule))?{
??????isInserted.add(rule);
??????document.head.appendChild(getStyleForRule(rule));
????}
??});
??return?rule;
}
function?Component()?{
??let?className?=?useCSS(rule);
??return?<div?className={className}?/>;
}
具體內(nèi)容可以前往:
https://github.com/reactwg/react-18/discussions/110
文章轉(zhuǎn)載于:高老師:https://juejin.cn/post/7080854114141208612
五、最后
?在我們閱讀完官方文檔后,我們一定會進(jìn)行更深層次的學(xué)習(xí),比如看下框架底層是如何運行的,以及源碼的閱讀。? ? 這里廣東靚仔給下一些小建議:- 在看源碼前,我們先去官方文檔復(fù)習(xí)下框架設(shè)計理念、源碼分層設(shè)計
- 閱讀下框架官方開發(fā)人員寫的相關(guān)文章
- 借助框架的調(diào)用棧來進(jìn)行源碼的閱讀,通過這個執(zhí)行流程,我們就完整的對源碼進(jìn)行了一個初步的了解
- 接下來再對源碼執(zhí)行過程中涉及的所有函數(shù)邏輯梳理一遍
關(guān)注我,一起攜手進(jìn)階
歡迎關(guān)注前端早茶,與廣東靚仔攜手共同進(jìn)階~
