Hello world
新聞中心
在 2021 年 6 月 8 號(hào),React 公布了 v18 版本的發(fā)布計(jì)劃,并發(fā)布了 alpha 版本。經(jīng)過(guò)將近一年的發(fā)布前準(zhǔn)備,在 2022 年 3 月 29 日,React 18 正式版終于和大家見(jiàn)面了。

React 18 應(yīng)該是最近幾年的一個(gè)重磅版本,React 官方對(duì)它寄予了厚望。不然也不會(huì)將 React 17 作為一個(gè)過(guò)渡版本,也不會(huì)光發(fā)布準(zhǔn)備工作就做了一年。
在過(guò)去一年,我們已經(jīng)或多或少了解到一些 React 18 的新功能。這篇文章我會(huì)通過(guò)豐富的示例,向大家系統(tǒng)的介紹 React 18 帶來(lái)的改變。當(dāng)然本文融入了很多個(gè)人理解,如有不對(duì),煩請(qǐng)指正。
Concurrent Mode
Concurrent Mode(以下簡(jiǎn)稱 CM)翻譯叫并發(fā)模式,這個(gè)概念我已經(jīng)聽(tīng)了好多年了,并且一度非常擔(dān)憂
- React 官方憋了好多年的大招,會(huì)不會(huì)是一個(gè)破壞性不兼容的超級(jí)大版本?就像 VUE v3 和 v2。
- 現(xiàn)有的生態(tài)是不是都得跟著大版本升級(jí)?比如 ant design,ahooks 等。
隨著對(duì) CM 的了解,我發(fā)現(xiàn)它其實(shí)是人畜無(wú)害的。
CM 本身并不是一個(gè)功能,而是一個(gè)底層設(shè)計(jì),它使 React 能夠同時(shí)準(zhǔn)備多個(gè)版本的 UI。
在以前,React 在狀態(tài)變更后,會(huì)開(kāi)始準(zhǔn)備虛擬 DOM,然后渲染真實(shí) DOM,整個(gè)流程是串行的。一旦開(kāi)始觸發(fā)更新,只能等流程完全結(jié)束,期間是無(wú)法中斷的。
在 CM 模式下,React 在執(zhí)行過(guò)程中,每執(zhí)行一個(gè) Fiber,都會(huì)看看有沒(méi)有更高優(yōu)先級(jí)的更新,如果有,則當(dāng)前低優(yōu)先級(jí)的的更新會(huì)被暫停,待高優(yōu)先級(jí)任務(wù)執(zhí)行完之后,再繼續(xù)執(zhí)行或重新執(zhí)行。
CM 模式有點(diǎn)類似計(jì)算機(jī)的多任務(wù)處理,處理器在同時(shí)進(jìn)行的應(yīng)用程序之間快速切換,也許 React 應(yīng)該改名叫 ReactOS 了。
這里舉個(gè)例子:我們正在看電影,這時(shí)候門(mén)鈴響了,我們要去開(kāi)門(mén)拿快遞。在 React 18 以前,一旦我們開(kāi)始看電影,就不能被終止,必須等電影看完之后,才會(huì)去開(kāi)門(mén)。而在 React 18 CM 模式之后,我們就可以暫停電影,等開(kāi)門(mén)拿完快遞之后,再重新繼續(xù)看電影。
不過(guò)對(duì)于普通開(kāi)發(fā)者來(lái)說(shuō),我們一般是不會(huì)感知到 CM 的存在的,在升級(jí)到 React 18 之后,我們的項(xiàng)目不會(huì)有任何變化。
我們需要關(guān)注的是基于 CM 實(shí)現(xiàn)的上層功能,比如 Suspense、Transitions、streaming server rendering(流式服務(wù)端渲染), 等等。
React 18 的大部分功能都是基于 CM 架構(gòu)實(shí)現(xiàn)出來(lái)的,并且這這是一個(gè)開(kāi)始,未來(lái)會(huì)有更多基于 CM 實(shí)現(xiàn)的高級(jí)能力。
startTransition
我們?nèi)绻鲃?dòng)發(fā)揮 CM 的優(yōu)勢(shì),那就離不開(kāi) startTransition。
React 的狀態(tài)更新可以分為兩類:
- 緊急更新(Urgent updates):比如打字、點(diǎn)擊、拖動(dòng)等,需要立即響應(yīng)的行為,如果不立即響應(yīng)會(huì)給人很卡,或者出問(wèn)題了的感覺(jué)
- 過(guò)渡更新(Transition updates):將 UI 從一個(gè)視圖過(guò)渡到另一個(gè)視圖。不需要即時(shí)響應(yīng),有些延遲是可以接受的。
我以前會(huì)認(rèn)為,CM 模式會(huì)自動(dòng)幫我們區(qū)分不同優(yōu)先級(jí)的更新,一鍵無(wú)憂享受。很遺憾的是,CM 只是提供了可中斷的能力,默認(rèn)情況下,所有的更新都是緊急更新。
這是因?yàn)?React 并不能自動(dòng)識(shí)別哪些更新是優(yōu)先級(jí)更高的。
const [inputValue, setInputValue] = useState();
const onChange = (e)=>{
setInputValue(e.target.value);
// 更新搜索列表
setSearchQuery(e.target.value);
}
return (
)
比如以上示例,用戶的鍵盤(pán)輸入操作后,setInputValue會(huì)立即更新用戶的輸入到界面上,是緊急更新。而setSearchQuery是根據(jù)用戶輸入,查詢相應(yīng)的內(nèi)容,是非緊急的。
但是 React 確實(shí)沒(méi)有能力自動(dòng)識(shí)別。所以它提供了 startTransition讓我們手動(dòng)指定哪些更新是緊急的,哪些是非緊急的。
// 緊急的
setInputValue(e.target.value);
startTransition(() => {
setSearchQuery(input); // 非緊急的
});
如上代碼,我們通過(guò) startTransition來(lái)標(biāo)記一個(gè)非緊急更新,讓該狀態(tài)觸發(fā)的變更變成低優(yōu)先級(jí)的。
光用文字描述大家可能沒(méi)有體驗(yàn),接下來(lái)我們通過(guò)一個(gè)示例來(lái)認(rèn)識(shí)下可中斷渲染對(duì)性能的爆炸提升。
示例頁(yè)面:https://react-fractals-git-react-18-swizec.vercel.app/[1]。
如下圖,我們需要畫(huà)一個(gè)畢達(dá)哥拉斯樹(shù),通過(guò)一個(gè) Slider 來(lái)控制樹(shù)的傾斜。
那我們的代碼會(huì)很簡(jiǎn)單,如下所示,我們只需要一個(gè) treeLeanstate 來(lái)管理狀態(tài)。
const [treeLean, setTreeLean] = useState(0)
function changeTreeLean(event) {
const value = Number(event.target.value);
setTreeLean(value);
}
return (
<>
>
)
在每次 Slider 拖動(dòng)后,React 執(zhí)行流程大致如下:
- 更新 treeLean。
- 渲染 input,填充新的 value。
- 重新渲染樹(shù)組件 Pythagoras。
每一次用戶拖動(dòng) Slider,都會(huì)同步執(zhí)行上述三步。但當(dāng)樹(shù)的節(jié)點(diǎn)足夠多的時(shí)候,Pythagoras 渲染一次就非常慢,就會(huì)導(dǎo)致 Slider 的 value 回填變慢,用戶感覺(jué)到嚴(yán)重的卡頓。如下圖。
當(dāng)數(shù)的節(jié)點(diǎn)足夠大時(shí),已經(jīng)卡到爆炸了。在 React 18 以前,我們是沒(méi)有什么好的辦法來(lái)解決這個(gè)問(wèn)題的。但基于 React 18 CM 的可中斷渲染機(jī)制,我們可以將樹(shù)的更新渲染標(biāo)記為低優(yōu)先級(jí)的,就不會(huì)感覺(jué)到卡頓了。
const [treeLeanInput, setTreeLeanInput] = useState(0);
const [treeLean, setTreeLean] = useState(0);
function changeTreeLean(event) {
const value = Number(event.target.value);
setTreeLeanInput(value)
// 將 treeLean 的更新用 startTransition 包裹
React.startTransition(() => {
setTreeLean(value);
});
}
return (
<>
>
)
以上代碼,我們通過(guò) startTransition 標(biāo)記了非緊急更新,讓樹(shù)的更新變成低優(yōu)先級(jí)的,可以被隨時(shí)中止,保證了高優(yōu)先級(jí)的 Slider 的體驗(yàn)。
此時(shí)更新流程變?yōu)榱耍?/p>
input 更新:
- treeLeanInput 狀態(tài)變更。
- 準(zhǔn)備新的 DOM。
- 渲染 DOM。
樹(shù)更新(這一次更新是低優(yōu)先級(jí)的,隨時(shí)可以被中止):
- treeLean 狀態(tài)變更。
- 準(zhǔn)備新的 DOM。
- 渲染 DOM。
React 會(huì)在高優(yōu)先級(jí)更新渲染完成之后,才會(huì)啟動(dòng)低優(yōu)先級(jí)更新渲染,并且低優(yōu)先級(jí)渲染隨時(shí)可被其它高優(yōu)先級(jí)更新中斷。
當(dāng)然,在低優(yōu)先狀態(tài)等待更新過(guò)程中,如果能有一個(gè) Loading 狀態(tài),那就更好了。React 18 提供了 useTransition來(lái)跟蹤 transition 狀態(tài)。
const [treeLeanInput, setTreeLeanInput] = useState(0);
const [treeLean, setTreeLean] = useState(0);
// 實(shí)時(shí)監(jiān)聽(tīng) transition 狀態(tài)
const [isPending, startTransition] = useTransition();
function changeTreeLean(event) {
const value = Number(event.target.value);
setTreeLeanInput(value)
React.startTransition(() => {
setTreeLean(value);
});
}
return (
<>
>
)
自動(dòng)批處理 Automatic Batching
批處理是指 React 將多個(gè)狀態(tài)更新,聚合到一次 render 中執(zhí)行,以提升性能。比如:
function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
// React 只會(huì) re-render 一次,這就是批處理
}在 React 18 之前,React 只會(huì)在事件回調(diào)中使用批處理,而在 Promise、setTimeout、原生事件等場(chǎng)景下,是不能使用批處理的。
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// React 會(huì) render 兩次,每次 state 變化更新一次
}, 1000);而在 React 18 中,所有的狀態(tài)更新,都會(huì)自動(dòng)使用批處理,不關(guān)心場(chǎng)景。
function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
// React 只會(huì) re-render 一次,這就是批處理
}
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// React 只會(huì) re-render 一次,這就是批處理
}, 1000);如果你在某種場(chǎng)景下不想使用批處理,你可以通過(guò) flushSync來(lái)強(qiáng)制同步執(zhí)行(比如:你需要在狀態(tài)更新后,立刻讀取新 DOM 上的數(shù)據(jù)等。)
import { flushSync } from 'react-dom';
function handleClick() {
flushSync(() => {
setCounter(c => c + 1);
});
// React 更新一次 DOM
flushSync(() => {
setFlag(f => !f);
});
// React 更新一次 DOM
}React 18 的批處理在絕大部分場(chǎng)景下是沒(méi)有影響,但在 Class 組件中,如果你在兩次 setState 中間讀取了 state 值,會(huì)出現(xiàn)不兼容的情況,如下示例。
handleClick = () => {
setTimeout(() => {
this.setState(({ count }) => ({ count: count + 1 }));
// 在 React17 及之前,打印出來(lái)是 { count: 1, flag: false }
// 在 React18,打印出來(lái)是 { count: 0, flag: false }
console.log(this.state);
this.setState(({ flag }) => ({ flag: !flag }));
});
};當(dāng)然你可以通過(guò) flushSync來(lái)修正它。
handleClick = () => {
setTimeout(() => {
ReactDOM.flushSync(() => {
this.setState(({ count }) => ({ count: count + 1 }));
});
// 在 React18,打印出來(lái)是 { count: 1, flag: false }
console.log(this.state);
this.setState(({ flag }) => ({ flag: !flag }));
});
};流式 SSR
SSR 一次頁(yè)面渲染的流程大概為:
- 服務(wù)器 fetch 頁(yè)面所需數(shù)據(jù)。
- 數(shù)據(jù)準(zhǔn)備好之后,將組件渲染成 string 形式作為 response 返回。
- 客戶端加載資源。
- 客戶端合成(hydrate)最終的頁(yè)面內(nèi)容。
在傳統(tǒng)的 SSR 模式中,上述流程是串行執(zhí)行的,如果其中有一步比較慢,都會(huì)影響整體的渲染速度。
而在 React 18 中,基于全新的 Suspense,支持了流式 SSR,也就是允許服務(wù)端一點(diǎn)一點(diǎn)的返回頁(yè)面。
假設(shè)我們有一個(gè)頁(yè)面,包含了 NavBar、Sidebar、Post、Comments 等幾個(gè)部分,在傳統(tǒng)的 SSR 模式下,我們必須請(qǐng)求到 Post 數(shù)據(jù),請(qǐng)求到 Comments 數(shù)據(jù)后,才能返回完整的 HTML。
First comment
Second comment
但如果 Comments 數(shù)據(jù)請(qǐng)求很慢,會(huì)拖慢整個(gè)流程。
在 React 18 中,我們通過(guò) Suspense包裹,可以告訴 React,我們不需要等這個(gè)組件,可以先返回其它內(nèi)容,等這個(gè)組件準(zhǔn)備好之后,單獨(dú)返回。
}>
如上,我們通過(guò) Suspense包裹了 Comments 組件,那服務(wù)器首次返回的 HTML 是下面這樣的,
Hello world
當(dāng)
First comment
Second comment
更多關(guān)于流式 SSR 的講解可見(jiàn):https://github.com/reactwg/react-18/discussions/37[2]
Server Component
Server Component 叫服務(wù)端組件,目前還在開(kāi)發(fā)過(guò)程中,沒(méi)有正式發(fā)布,不過(guò)應(yīng)該很快就會(huì)和我們見(jiàn)面的。
Server Component 的本質(zhì)就是由服務(wù)端生成 React 組件,返回一個(gè) DSL 給客戶端,客戶端解析 DSL 并渲染該組件。
Server Component 帶來(lái)的優(yōu)勢(shì)有:
零客戶端體積,運(yùn)行在服務(wù)端的組件只會(huì)返回最終的 DSL 信息,而不包含其他任何依賴。
// NoteWithMarkdown.js
import marked from 'marked'; // 35.9K (11.2K gzipped)
import sanitizeHtml from 'sanitize-html'; // 206K (63.3K gzipped)
function NoteWithMarkdown({text}) {
const html = sanitizeHtml(marked(text));
return (/* render */);
}
假設(shè)我們有一個(gè) markdown 渲染組件,以前我們需要將依賴 marked和 sanitize-html打包到 JS 中。如果該組件在服務(wù)端運(yùn)行,則最終返回給客戶端的是轉(zhuǎn)換完成的文本。
組件擁有完整的服務(wù)端能力。
由于 Server Component 在服務(wù)端執(zhí)行,擁有了完整的 NodeJS 的能力,可以訪問(wèn)任何服務(wù)端 API。
// Note.server.js - Server Component
import fs from 'react-fs';
function Note({id}) {
const note = JSON.parse(fs.readFile(`${id}.json`));
return;
}
組件支持實(shí)時(shí)更新
由于 Server Component 在服務(wù)端執(zhí)行,理論上支持實(shí)時(shí)更新,類似動(dòng)態(tài) npm 包,這個(gè)還是有比較大的想象空間的。也許 React Component as a service 時(shí)代來(lái)了。
當(dāng)然說(shuō)了這么多好處,Server Component 肯定也是有一些局限性的:
不能有狀態(tài),也就是不能使用 state、effect 等,那么更適合用在純展示的組件,對(duì)性能要求較高的一些前臺(tái)業(yè)務(wù)
- 不能訪問(wèn)瀏覽器的 API。
- props 必須能被序列化。
- OffScreen。
OffScreen
目前也在開(kāi)發(fā)中,會(huì)在未來(lái)某個(gè)版本中發(fā)布。但我們非常有必要提前認(rèn)識(shí)下它,因?yàn)槟悻F(xiàn)在的代碼很可能已經(jīng)有問(wèn)題了。
OffScreen 支持只保存組件的狀態(tài),而刪除組件的 UI 部分??梢院芊奖愕膶?shí)現(xiàn)預(yù)渲染,或者 Keep Alive。比如我們?cè)趶?tabA 切換到 tabB,再返回 tabA 時(shí),React 會(huì)使用之前保存的狀態(tài)恢復(fù)組件。
為了支持這個(gè)能力,React 要求我們的組件對(duì)多次安裝和銷毀具有彈性。那什么樣的代碼不符合彈性要求呢?其實(shí)不符合要求的代碼很常見(jiàn)。
async function handleSubmit() {
setPending(true)
await post('/someapi') // component might unmount while we're waiting
setPending(false)
}在上面的代碼中,如果發(fā)送請(qǐng)求時(shí),組件卸載了,會(huì)拋出警告。
Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.
警告:不能在已經(jīng)卸載的組件中更改 state。這是一個(gè)無(wú)用的操作,它表明你的項(xiàng)目中存在內(nèi)存泄漏。要解決這個(gè)問(wèn)題,請(qǐng)?jiān)?useEffect 清理函數(shù)中取消所有訂閱和異步任務(wù)。
所以我們一般都會(huì)通過(guò)一個(gè) unmountRef來(lái)標(biāo)記當(dāng)前組件是否卸載,以避免所謂的「內(nèi)存泄漏」。
function SomeButton(){
const [pending, setPending] = useState(false)
const unmountRef = useUnmountedRef();
async function handleSubmit() {
setPending(true)
await post('/someapi')
if (!unmountRef.current) {
setPending(false)
}
}
return (
)
}我們來(lái)模擬執(zhí)行一次組件,看看組件的變化狀態(tài):
- 首次加載時(shí),組件的狀態(tài)為:pending = false。
- 點(diǎn)擊按鈕后,組件的狀態(tài)會(huì)變?yōu)椋簆ending = true。
- 假如我們?cè)谡?qǐng)求過(guò)程中卸載了組件,那此時(shí)的狀態(tài)會(huì)變?yōu)椋簆ending = true。
在 OffScreen 中,React 會(huì)保存住最后的狀態(tài),下次會(huì)用這些狀態(tài)重新渲染組件。慘了,此時(shí)我們發(fā)現(xiàn)重新渲染組件一直在 loading。
怎么解決?解決辦法很簡(jiǎn)單,就是回歸最初的代碼,刪掉 unmountRef的邏輯。至于「內(nèi)存泄漏」的警告,React 18 刪除了,因?yàn)檫@里不存在內(nèi)存泄漏(參考:https://mp.weixin.qq.com/s/fgT7Kxs_0feRx4TkBe6G5Q)。
async function handleSubmit() {
setPending(true)
await post('/someapi')
setPending(false)
}為了方便排查這類問(wèn)題,在 React 18 的 Strict Mode 中,新增了 double effect,在開(kāi)發(fā)模式下,每次組件初始化時(shí),會(huì)自動(dòng)執(zhí)行一次卸載,重載。
* React mounts the component.
* Layout effects are created.
* Effects are created.
* React simulates unmounting the component.
* Layout effects are destroyed.
* Effects are destroyed.
* React simulates mounting the component with the previous state.
* Layout effects are created.
* Effects are created.
這里還是要再提示下:開(kāi)發(fā)環(huán)境,在 React 18 的嚴(yán)格模式下,組件初始化的 useEffect 會(huì)執(zhí)行兩次,也就是可能 useEffect 里面的請(qǐng)求被執(zhí)行了兩次等。
新 Hooks
useDeferredValue
const deferredValue = useDeferredValue(value);
useDeferredValue 可以讓一個(gè) state 延遲生效,只有當(dāng)前沒(méi)有緊急更新時(shí),該值才會(huì)變?yōu)樽钚轮怠seDeferredValue 和 startTransition 一樣,都是標(biāo)記了一次非緊急更新。
之前 startTransition 的例子,就可以用 useDeferredValue來(lái)實(shí)現(xiàn)。
const [treeLeanInput, setTreeLeanInput] = useState(0);
const deferredValue = useDeferredValue(treeLeanInput);
function changeTreeLean(event) {
const value = Number(event.target.value);
setTreeLeanInput(value)
}
return (
<>
>
)
useId
const id = useId();
支持同一個(gè)組件在客戶端和服務(wù)端生成相同的唯一的 ID,避免 hydration 的不兼容。原理是每個(gè) id 代表該組件在組件樹(shù)中的層級(jí)結(jié)構(gòu)。
useSyncExternalStore
const state = useSyncExternalStore(subscribe, getSnapshot[, getServerSnapshot]);
useSyncExternalStore 能夠讓 React 組件在 Concurrent Mode 下安全地有效地讀取外接數(shù)據(jù)源。在 Concurrent Mode 下,React 一次渲染會(huì)分片執(zhí)行(以 fiber 為單位),中間可能穿插優(yōu)先級(jí)更高的更新。假如在高優(yōu)先級(jí)的更新中改變了公共數(shù)據(jù)(比如 redux 中的數(shù)據(jù)),那之前低優(yōu)先的渲染必須要重新開(kāi)始執(zhí)行,否則就會(huì)出現(xiàn)前后狀態(tài)不一致的情況。useSyncExternalStore 一般是三方狀態(tài)管理庫(kù)使用,一般我們不需要關(guān)注。
useInsertionEffect
useInsertionEffect(didUpdate);
這個(gè) Hooks 只建議 css-in-js庫(kù)來(lái)使用。這個(gè) Hooks 執(zhí)行時(shí)機(jī)在 DOM 生成之后,useLayoutEffect 生效之前,一般用于提前注入


咨詢
建站咨詢
