React 18 超全升級指南

語言: CN / TW / HK

theme: cyanosis

React 18 RC.3 版已經發布,並且 API 已經穩定下來,現在主要是一些 BUG 修復,相信不久後便會發布正式版。React 團隊對新特性的探索相當謹慎,距離 16.8 版本已經有 3 年時間了,完全版的併發模式終於到來。今天我們從使用者的角度來探索下 React 17 升級到 18 會遇到的問題和一些新增的功能。

升級

使用 yarn 要安裝最新的 React 18 RC

bash yarn add [email protected] [email protected]

變更

React 18 已經放棄對 IE 11 的支援,有相容 IE 的需求需要繼續使用 React 17

createRoot

React 18 提供了兩個根 API,我們稱之為 Legacy Root APINew Root API

  • Legacy root API: 即 ReactDOM.render。這將建立一個以“遺留”模式執行的 root,其工作方式與 React 17 完全相同。使用此 API 會有一個警告,表明它已被棄用並切換到 New Root API
  • New Root API: 即 createRoot。 這將建立一個在 React 18 中執行的 root,它添加了 React 18 的所有改進並允許使用併發功能。

我們以 Vite + TS 作為腳手架啟動專案。專案啟動後你會在控制檯中看到一個警告:

1.png

也就意味著你可以直接將專案升級到 React 18 版本而不會直接造成 break change。因為它僅僅給予了一個警告,並且在整個 18 版本中都為可用相容狀態,並保持著 React 17 版本的特性。

為什麼要這樣做呢? 因為僅僅為專案升級的話比較乾脆利落,遇見一個地方改一個地方,無歷史包袱。但是 React 元件生態非常龐大,很多元件會用到 ReactDOM.render 直接渲染,比如常見 UI 庫中的 Modal.confirm 類似的 API,這時就需要一個版本的週期讓這些生態元件升級上來。

```js // React 17 import ReactDOM from 'react-dom'; const container = document.getElementById('app'); // 裝載 ReactDOM.render(, container); // 解除安裝 ReactDOM.unmountComponentAtNode(container);

// React 18 import { createRoot } from 'react-dom/client'; const container = document.getElementById('app'); const root = createRoot(container); // 裝載 root.render(); // 解除安裝 root.unmount(); ```

還不得不說 createRoot API 和 Vue3createApp 形式一模一樣。

FAQ: 在 TypeScriptcreateRoot 中引數 container 可接收 HTMLElement ,但不能為空。使用要麼斷言,要麼加判斷吧~

服務端渲染

hydrateRoot

如果的應用使用帶注水的服務端渲染,請升級 hydratehydrateRoot

js const root = hydrateRoot(container, <App tab="home" />); // 這裡無需執行 root.render

在此版本中,也改進了 react-dom/serverAPI 以完全支援伺服器上的 Suspense 和流式 SSR。作為這些更改的一部分,將棄用舊的 Node 流式 API,它不支援伺服器上的增量 Suspense 流式傳輸。

  • renderToNodeStream => renderToPipeableStream
  • 新增 renderToReadableStream 以支援 Deno
  • 繼續使用 renderToString (對 Suspense 支援有限)
  • 繼續使用 renderToStaticMarkup (對 Suspense 支援有限)

setState 同步/非同步

這是 React 此次版本中最大的破壞性更新,並且無法向下相容

React 中的批處理簡單來說就是將多個狀態更新合併為一次重新渲染,以獲得更好的效能,在 React 18 之前,React 只能在元件的生命週期函式或者合成事件函式中進行批處理。預設情況下,PromisesetTimeout 以及原生事件中是不會對其進行批處理的。如果需要保持批處理,則可以用 unstable_batchedUpdates 來實現,但它不是一個正式的 API。

React 18 之前:

```js function handleClick() { setCount(1); setFlag(true); // 批處理:會合併為一次 render }

async function handleClick() { await setCount(2); setFlag(false); // 同步模式:會執行兩次 render // 並且在 setCount 後,在 setFlag 之前能通過 Ref 獲取到最新的 count 值 } ```

React 18 上面的第二個例子只會有一次 render,因為所有的更新都將自動批處理。這樣無疑是很好的提高了應用的整體效能。

flushSync

如果我想在 React 18 退出批處理該怎麼做呢?官方提供了一個 API flushSync

flushSync<R>(fn: () => R): R 它接收一個函式作為引數,並且允許有返回值。

js function handleClick() { flushSync(() => { setCount(3); }); // 會在 setCount 並 render 之後再執行 setFlag setFlag(true); }

注意:flushSync 會以函式為作用域,函式內部的多個 setState 仍然為批量更新,這樣可以精準控制哪些不需要的批量更新:

js function handleClick() { flushSync(() => { setCount(3); setFlag(true); }); // setCount 和 setFlag 為批量更新,結束後 setLoading(false); // 此方法會觸發兩次 render }

這種方式會比 React 17 及以前的方式更優雅的顆粒度控制 rerender

flushSync 再某些場景中非常有用,比如在點選一個表單中點選儲存按鈕,並觸發子表單關閉,並同步到全域性 state,狀態更新後再呼叫儲存方法:

子表單:

```js export default function ChildForm({ storeTo }) { const [form] = Form.useForm();

// 當前元件解除安裝時將子表單的值同步到全域性 // 若要觸發父元件同步 setState,必須使用 useLayoutEffect useLayoutEffect(() => { return () => { storeTo(form.getFieldsValue()); }; }, []);

return (

); } ```

外部容器:

```js

{ // 觸發子表單解除安裝關閉 flushSync(() => setVisible(false)); // 子表單值更新到全域性後,觸發儲存方法,可以保證 onSave 獲取到最新填寫的表單值 onSave(); }} > 儲存

{visible && }

```

不過 unstable_batchedUpdatesReact 18 中將繼續保留整個版本,因為許多開源庫用了它。

已解除安裝元件更新狀態警告

我們在正常開發時難免會出現以下錯誤:

2.png

這個警告被廣泛誤解並且有些誤導。原本旨在針對如下場景:

js useEffect(() => { function handleChange() { setState(store.getState()); } store.subscribe(handleChange); return () => store.unsubscribe(handleChange); }, []);

如果您忘記了 unsubscribe 效果清理中的呼叫,則會發生記憶體洩漏。在實踐中,上述情況並不常見。這在我們的程式碼中更為常見:

js async function handleSubmit() { setLoading(true); // 在我們等待時元件可能會解除安裝 await post('/some-api'); setLoading(false); }

在這裡,警告也會觸發。但是,在這種情況下,警告具有誤導性

這裡沒有實際的記憶體洩漏,Promise 會很快 resolve,之後它可以被垃圾回收。為了抑制這個警告,我們可能會寫很多 isMounted 無用的判斷,會使程式碼變得更加複雜。

React 18 中這個警告已經被移除掉了。

元件返回 null

React 17 中,如果元件在 render 中返回了 undefinedReact 會在執行時丟擲一個錯誤:

js function Demo() { return undefined; }

3.png

這裡我們可以把 undefined 換成 null,程式將繼續執行。此行為的目的是幫助使用者發現意外忘記 return 語句的常見問題。對於 React 18Suspense fallback 會出現 undefined 而不報錯從而導致出現不一致。

現在型別系統和 Eslint 都非常健壯可以很好避免這類低階錯誤,因此 React 18 不再檢查因返回 undefined 而導致崩潰。

StrictMode

從 React 17 開始,React 會自動修改控制檯方法,例如 console.log() 在第二次呼叫生命週期函式時使日誌靜音。但是,在某些可以使用變通方法的情況下,它可能會導致不良行為。

這這種行為在 React 18 中已經移除,如果安裝了 React DevTools > 4.18.0,那麼第二次渲染期間的日誌現在將以柔和的顏色顯示在控制檯中。

4.png

新 API

useSyncExternalStore

useSyncExternalStore 經歷了一次修改,由 unstable_useMutableSource 改變而來,用於訂閱外部資料來源。主要幫助有外部 store 需求的開發者解決撕裂問題。

一個監聽 innerWidth 變化的 hook 最簡單例子:

```tsx import { useMemo, useSyncExternalStore } from 'react';

function useInnerWidth(): number { // 保持 subscribe 固定引用,避免 resize 監聽器重複執行 const [subscribe, getSnapshot] = useMemo(() => { return [ (notify: () => void) => { // 真實情況這裡會用到節流 window.addEventListener('resize', notify); return () => { window.removeEventListener('resize', notify); }; }, // 返回 resize 後需要的快照 () => window.innerWidth, ]; }, []); return useSyncExternalStore(subscribe, getSnapshot); } ```

```tsx function WindowInnerWidthExample() { const width = useInnerWidth();

return

寬度: {width}

; } ```

Demo 地址:https://codesandbox.io/s/usesyncexternalstore-demo-q47kyn

React 自身 state 已經原生的解決的併發特性下的撕裂(tear) 問題。useSyncExternalStore 主要對於框架開發者,比如 redux,它在控制狀態時可能並非直接使用的 Reactstate,而是自己在外部維護了一個 store 物件,脫離了 React 的管理,也就無法依靠 React 自動解決撕裂問題。因此 React 對外提供了這樣一個 API。

目前 React-Redux 8.0 已經基於 useSyncExternalStore 實現。

useInsertionEffect

useInsertionEffect 的工作原理大致 useLayoutEffect 相同,只是此時無法訪問 DOM 節點的引用。

因此推薦的解決方案是使用這個 Hook 來插入樣式表(或者如果你需要刪除它們,可以引用它們):

js 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} />; }

useId

useId 是一個 API,用於在客戶端和伺服器上生成唯一 ID,同時避免水合不匹配。使用示例:

js function Checkbox() { const id = useId(); return ( <div> <label htmlFor={id}>選擇框</label> <input type="checkbox" name="sex" id={id} /> </div> ); }

Concurrent(併發) 模式

Concurrent 模式是一組 React 的新功能,可幫助應用保持響應,並根據使用者的裝置效能和網速進行適當的調整,該模式通過使渲染可中斷來修復阻塞渲染限制。在 Concurrent 模式中,React 可以同時更新多個狀態。

通常,當我們更新 state 的時候,我們會期望這些變化立刻反映到螢幕上。我們期望應用能夠持續響應使用者的輸入,這是符合常理的。但是,有時我們會期望更新延遲響應在螢幕上。在 React 中實現這個功能在之前是很難做到的。Concurrent 模式提供了一系列的新工具使之成為可能。

Transition

React 18 中,引入的一個新的 API startTransition,主要為了能在大量的任務下也能保持 UI 響應。這個新的 API 可以通過將特定更新標記為“過渡”來顯著改善使用者互動。

概覽:

```js import { startTransition } from 'react';

// 緊急:顯示輸入的內容 setInputValue(input);

// 標記回撥函式內的更新為非緊急更新 startTransition(() => { setSearchQuery(input); }); ```

簡單來說,就是被 startTransition 回撥包裹的 setState 觸發的渲染 被標記為不緊急渲染,這些渲染可能被其他緊急渲染所搶佔。

一般情況下,我們需要通知使用者後臺正在工作。為此提供了一個帶有 isPending 轉換標誌的 useTransitionReact 將在狀態轉換期間提供視覺反饋,並在轉換髮生時保持瀏覽器響應。

```js import { useTransition } from 'react';

const [isPending, startTransition] = useTransition(); ```

isPending 值在轉換掛起時為 true,這時可以在頁面中放置一個載入器。

普通情況下:

5.gif

使用 useTransition 表現:

6.gif

Demo 地址:https://codesandbox.io/s/starttransition-demo-o59ld2

我們可以使用 startTransition 包裝任何要移至後臺的更新,通常,這些型別的更新分為兩類:

  1. 渲染緩慢:這些更新需要時間,因為 React 需要執行大量工作才能轉換 UI 以顯示結果
  2. 網路慢:這些更新需要時間,因為 React 正在等待來自網路的一些資料。這個方式與 Suspense 緊密整合

網路慢場景:一個列表頁,當我們點選 “下一頁”,現存的列表立刻消失了,然後我們看到整個頁面只有一個載入提示。可以說這是一個“不受歡迎”的載入狀態。如果我們可以“跳過”這個過程,並且等到內容載入後再過渡到新的頁面,效果會更好

這裡我們結合 Suspense 做載入邊界處理:

```js import React, { useState, useTransition, Suspense } from 'react'; import { fetchMockData, MockItem } from './utils'; import styles from './DemoList.module.less';

const mockResource = fetchMockData(1);

export default function DemoList() { const [resource, setResource] = useState(mockResource); const [isPending, startTransition] = useTransition();

return ( {isPending &&

載入中
} ); }

function UserList({ resource }: UserListProps) { const mockList = resource.read(); return (

{mockList.map((item) => (
{item.id}
{item.name}
{item.age} 歲
))}
); } ```

結果展示:

7.gif

Demo 地址:https://codesandbox.io/s/usetransition-request-demo-wgedzw

Transition 融合到應用的設計系統

useTransition 是非常常見的需求。幾乎所有可能導致元件掛起的點選或互動操作都需要使用 useTransition,以避免意外隱藏使用者正在互動的內容。

這可能會導致元件存在大量重複程式碼。通常建議把 useTransition 融合到應用的設計系統元件中。例如,我們可以把 useTransition 邏輯抽取到我們自己的 <Button> 元件:

```js function Button({ children, onClick }) { const [startTransition, isPending] = useTransition();

function handleClick() { startTransition(() => { onClick(); }); }

return ( ); } ```

FAQ: useTransition 有個可選引數,可以設定超時時間 timeoutMs,但目前的 TS 型別沒有開放。

useDeferredValue

返回一個延遲響應的值,這通常用於在具有基於使用者輸入立即渲染的內容,以及需要等待資料獲取的內容時,保持介面的可響應性。

```js import { useDeferredValue } from 'react';

const deferredValue = useDeferredValue(value); ```

從介紹上來看 useDeferredValueuseTransition 是否感覺很相似呢?

  • 相同:useDeferredValue 本質上和內部實現與 useTransition 一樣都是標記成了延遲更新任務。
  • 不同:useTransition 是把更新任務變成了延遲更新任務,而 useDeferredValue 是產生一個新的值,這個值作為延時狀態。

那它和 debounce 有什麼區別呢?

debouncesetTimeout 總是會有一個固定的延遲,而 useDeferredValue 的值只會在渲染耗費的時間下滯後,在效能好的機器上,延遲會變少,反之則變長。

結語

以上是本次 React 所升級的大致內容,主要圍繞著併發模式而展開。趕快提前準備起來發布正式版後升級吧~

本人強力之作:react-photo-view 已經完美相容 React 18,歡迎大家嘗試。

歡迎 加群 一起探討 React 18 升級心得。

今年輸出的文章