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

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


咨詢
建站咨詢
