React 18 超全升級指南
React 18 RC.3 版已經發布,並且 API 已經穩定下來,現在主要是一些 BUG 修復,相信不久後便會發布正式版。React 團隊對新特性的探索相當謹慎,距離 16.8 版本已經有 3 年時間了,完全版的併發模式終於到來。今天我們從使用者的角度來探索下 React 17 升級到 18 會遇到的問題和一些新增的功能。
升級
使用 yarn 要安裝最新的 React 18 RC
yarn add react@rc react-dom@rc
變更
React 18 已經放棄對 IE 11 的支持,有兼容 IE 的需求則使用 React 17。
createRoot
React 18 提供了兩個根 API,我們稱之爲 Legacy Root API 和 New 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 作爲腳手架啓動項目。項目啓動後你會在控制檯中看到一個警告:
也就意味着你可以直接將項目升級到 React 18 版本而不會直接造成 break change。因爲它僅僅給予了一個警告,並且在整個 18 版本中都爲可用兼容狀態,並保持着 React 17 版本的特性。
爲什麼要這樣做呢?因爲僅僅爲項目升級的話比較乾脆利落,遇見一個地方改一個地方,無歷史包袱。但是 React 組件生態非常龐大,很多組件會用到 ReactDOM.render 直接渲染,比如常見 UI 庫中的 Modal.confirm 類似的 API,這時就需要一個版本的週期讓這些生態組件升級上來。
// React 17
import ReactDOM from'react-dom';
const container = document.getElementById('app');
// 裝載
ReactDOM.render(<App tab="home" />, container);
// 卸載
ReactDOM.unmountComponentAtNode(container);
// React 18
import { createRoot } from'react-dom/client';
const container = document.getElementById('app');
const root = createRoot(container);
// 裝載
root.render(<App tab="home" />);
// 卸載
root.unmount();
還不得不說 createRoot API 和 Vue3 的 createApp 形式一模一樣。
FAQ: 在 TypeScript, createRoot 中參數 container 可接收 HTMLElement ,但不能爲空。使用要麼斷言,要麼加判斷吧~
服務端渲染
hydrateRoot
如果的應用使用帶注水的服務端渲染,請升級 hydrate 到 hydrateRoot
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 只能在組件的生命週期函數或者合成事件函數中進行批處理。默認情況下,Promise、setTimeout 以及原生事件中是不會對其進行批處理的。如果需要保持批處理,則可以用 unstable_batchedUpdates 來實現,但它不是一個正式的 API。
React 18 之前:
function handleClick() {
setCount(1);
setFlag(true);
// 批處理:會合併爲一次 render
}
asyncfunction handleClick() {
await setCount(2);
setFlag(false);
// 同步模式:會執行兩次 render
// 並且在 setCount 後,在 setFlag 之前能通過 Ref 獲取到最新的 count 值
}
值得注意 React 18 上面的第二個例子依然有兩次 render,此外,異步後同時的更新都將自動批處理。這樣無疑是很好的提高了應用的整體性能。
flushSync
如果我想在 React 18 退出批處理該怎麼做呢?官方提供了一個 API flushSync。
flushSync<R>(fn: () => R): R 它接收一個函數作爲參數,並且允許有返回值。
function handleClick() {
flushSync(() => {
setCount(3);
});
// 會在 setCount 並 render 之後再執行 setFlag
setFlag(true);
}
注意:flushSync 會以函數爲作用域,函數內部的多個 setState 仍然爲批量更新,這樣可以精準控制哪些不需要的批量更新:
function handleClick() {
flushSync(() => {
setCount(3);
setFlag(true);
});
// setCount 和 setFlag 爲批量更新,結束後
setLoading(false);
// 此方法會觸發兩次 render
}
這種方式會比 React 17 及以前的方式更優雅的顆粒度控制 rerender。
flushSync 再某些場景中非常有用,比如在點擊一個表單中點擊保存按鈕,並觸發子表單關閉,並同步到全局 state,狀態更新後再調用保存方法:
子表單:
exportdefaultfunction ChildForm({ storeTo }) {
const [form] = Form.useForm();
// 當前組件卸載時將子表單的值同步到全局
// 若要觸發父組件同步 setState,必須使用 useLayoutEffect
useLayoutEffect(() => {
return() => {
storeTo(form.getFieldsValue());
};
}, []);
return (
<Form form={form}>
<Form.Item >
<Input />
</Form.Item>
</Form>
);
}
外部容器:
<div
onClick={() => {
// 觸發子表單卸載關閉
flushSync(() => setVisible(false));
// 子表單值更新到全局後,觸發保存方法,可以保證 onSave 獲取到最新填寫的表單值
onSave();
}}
>
保存
</div>
<div>{visible && <ChildForm storeTo={updateState} />}</div>
不過 unstable_batchedUpdates 在 React 18 中將繼續保留整個版本,因爲許多開源庫用了它。
已卸載組件更新狀態警告
我們在正常開發時難免會出現以下錯誤:
這個警告被廣泛誤解並且有些誤導。原本旨在針對如下場景:
useEffect(() => {
function handleChange() {
setState(store.getState());
}
store.subscribe(handleChange);
return() => store.unsubscribe(handleChange);
}, []);
如果您忘記了 unsubscribe 效果清理中的調用,則會發生內存泄漏。在實踐中,上述情況並不常見。這在我們的代碼中更爲常見:
asyncfunction handleSubmit() {
setLoading(true);
// 在我們等待時組件可能會卸載
await post('/some-api');
setLoading(false);
}
在這裏,警告也會觸發。但是,在這種情況下,警告具有誤導性。
這裏沒有實際的內存泄漏,Promise 會很快 resolve,之後它可以被垃圾回收。爲了抑制這個警告,我們可能會寫很多 isMounted 無用的判斷,會使代碼變得更加複雜。
在 React 18 中這個警告已經被移除掉了。
組件返回 null
在 React 17 中,如果組件在 render 中返回了 undefined,React 會在運行時拋出一個錯誤:
function Demo() {
return undefined;
}
這裏我們可以把 undefined 換成 null,程序將繼續運行。此行爲的目的是幫助用戶發現意外忘記 return 語句的常見問題。對於 React 18 的 Suspense fallback 會出現 undefined 而不報錯從而導致出現不一致。
現在類型系統和 Eslint 都非常健壯可以很好避免這類低級錯誤,因此 React 18 不再檢查因返回 undefined 而導致崩潰。
StrictMode
從 React 17 開始,React 會自動修改控制檯方法,例如 console.log() 在第二次調用生命週期函數時使日誌靜音。但是,在某些可以使用變通方法的情況下,它可能會導致不良行爲。
這這種行爲在 React 18 中已經移除,如果安裝了 React DevTools > 4.18.0,那麼第二次渲染期間的日誌現在將以柔和的顏色顯示在控制檯中。
新 API
useSyncExternalStore
useSyncExternalStore 經歷了一次修改,由 unstable_useMutableSource 改變而來,用於訂閱外部數據源。主要幫助有外部 store 需求的開發者解決撕裂問題。
一個監聽 innerWidth 變化的 hook 最簡單例子:
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);
}
function WindowInnerWidthExample() {
const width = useInnerWidth();
return<p>寬度: {width}</p>;
}
Demo 地址:https://codesandbox.io/s/usesyncexternalstore-demo-q47kyn
React 自身 state 已經原生的解決的併發特性下的撕裂 (tear) 問題。useSyncExternalStore 主要對於框架開發者,比如 redux,它在控制狀態時可能並非直接使用的 React 的 state,而是自己在外部維護了一個 store 對象,脫離了 React 的管理,也就無法依靠 React 自動解決撕裂問題。因此 React 對外提供了這樣一個 API。
目前 React-Redux 8.0 已經基於 useSyncExternalStore 實現。
useInsertionEffect
useInsertionEffect 的工作原理大致 useLayoutEffect 相同,只是此時無法訪問 DOM 節點的引用。
因此推薦的解決方案是使用這個 Hook 來插入樣式表(或者如果你需要刪除它們,可以引用它們):
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,同時避免水合不匹配。使用示例:
function Checkbox() {
const id = useId();
return (
<div>
<label htmlFor={id}>選擇框</label>
<input type="checkbox" id={id} />
</div>
);
}
Concurrent(併發) 模式
Concurrent 模式是一組 React 的新功能,可幫助應用保持響應,並根據用戶的設備性能和網速進行適當的調整,該模式通過使渲染可中斷來修復阻塞渲染限制。在 Concurrent 模式中,React 可以同時更新多個狀態。
通常,當我們更新 state 的時候,我們會期望這些變化立刻反映到屏幕上。我們期望應用能夠持續響應用戶的輸入,這是符合常理的。但是,有時我們會期望更新延遲響應在屏幕上。在 React 中實現這個功能在之前是很難做到的。Concurrent 模式提供了一系列的新工具使之成爲可能。
Transition
在 React 18 中,引入的一個新的 API startTransition,主要爲了能在大量的任務下也能保持 UI 響應。這個新的 API 可以通過將特定更新標記爲 “過渡” 來顯着改善用戶交互。
概覽:
import { startTransition } from'react';
// 緊急:顯示輸入的內容
setInputValue(input);
// 標記回調函數內的更新爲非緊急更新
startTransition(() => {
setSearchQuery(input);
});
簡單來說,就是被 startTransition 回調包裹的 setState 觸發的渲染 被標記爲不緊急渲染,這些渲染可能被其他緊急渲染所搶佔。
一般情況下,我們需要通知用戶後臺正在工作。爲此提供了一個帶有 isPending 轉換標誌的 useTransition,React 將在狀態轉換期間提供視覺反饋,並在轉換髮生時保持瀏覽器響應。
import { useTransition } from'react';
const [isPending, startTransition] = useTransition();
該 isPending 值在轉換掛起時爲 true,這時可以在頁面中放置一個加載器。
普通情況下:
使用 useTransition 表現:
Demo 地址:https://codesandbox.io/s/starttransition-demo-o59ld2
我們可以使用 startTransition 包裝任何要移至後臺的更新,通常,這些類型的更新分爲兩類:
-
渲染緩慢:這些更新需要時間,因爲
React需要執行大量工作才能轉換 UI 以顯示結果 -
網絡慢:這些更新需要時間,因爲
React正在等待來自網絡的一些數據。這個方式與Suspense緊密集成
網絡慢場景:一個列表頁,當我們點擊 “下一頁”,現存的列表立刻消失了,然後我們看到整個頁面只有一個加載提示。可以說這是一個 “不受歡迎” 的加載狀態。如果我們可以 “跳過” 這個過程,並且等到內容加載後再過渡到新的頁面,效果會更好。
這裏我們結合 Suspense 做加載邊界處理:
import React, { useState, useTransition, Suspense } from'react';
import { fetchMockData, MockItem } from'./utils';
import styles from'./DemoList.module.less';
const mockResource = fetchMockData(1);
exportdefaultfunction DemoList() {
const [resource, setResource] = useState(mockResource);
const [isPending, startTransition] = useTransition();
return (
<Suspense fallback="加載中">
<UserList resource={resource} />
<button
className={styles.button}
type="button"
onClick={() =>
startTransition(() => {
setResource(fetchMockData(2));
})
}
>
下一頁
</button>
{isPending && <div className={styles.loading}>加載中</div>}
</Suspense>
);
}
function UserList({ resource }: UserListProps) {
const mockList = resource.read();
return (
<div className={styles.list}>
{mockList.map((item) => (
<div key={item.id} className={styles.row}>
<div className={styles.col}>{item.id}</div>
<div className={styles.col}>{item.name}</div>
<div className={styles.col}>{item.age} 歲</div>
</div>
))}
</div>
);
}
結果展示:
Demo 地址:https://codesandbox.io/s/usetransition-request-demo-wgedzw
把 Transition 融合到應用的設計系統
useTransition 是非常常見的需求。幾乎所有可能導致組件掛起的點擊或交互操作都需要使用 useTransition,以避免意外隱藏用戶正在交互的內容。
這可能會導致組件存在大量重複代碼。通常建議把 useTransition 融合到應用的設計系統組件中。例如,我們可以把 useTransition 邏輯抽取到我們自己的 <Button> 組件:
function Button({ children, onClick }) {
const [startTransition, isPending] = useTransition();
function handleClick() {
startTransition(() => {
onClick();
});
}
return (
<button onClick={handleClick} disabled={isPending}>
{children} {isPending ? '加載中' : null}
</button>
);
}
FAQ:useTransition 有個可選參數,可以設定超時時間 timeoutMs,但目前的 TS 類型沒有開放。
useDeferredValue
返回一個延遲響應的值,這通常用於在具有基於用戶輸入立即渲染的內容,以及需要等待數據獲取的內容時,保持接口的可響應性。
import { useDeferredValue } from'react';
const deferredValue = useDeferredValue(value);
從介紹上來看 useDeferredValue 與 useTransition 是否感覺很相似呢?
-
相同:
useDeferredValue本質上和內部實現與useTransition一樣都是標記成了延遲更新任務。 -
不同:
useTransition是把更新任務變成了延遲更新任務,而useDeferredValue是產生一個新的值,這個值作爲延時狀態。
那它和 debounce 有什麼區別呢?
debounce 即 setTimeout 總是會有一個固定的延遲,而 useDeferredValue 的值只會在渲染耗費的時間下滯後,在性能好的機器上,延遲會變少,反之則變長。
結語
以上是本次 React 所升級的大致內容,主要圍繞着併發模式而展開。趕快提前準備起來發布正式版後升級吧~
本人強力之作:react-photo-view 已經完美兼容 React 18,歡迎大家嘗試:
https://react-photo-view.vercel.app
最後,歡迎加羣一起探討 React 18 升級心得。
前端星辰 分享前端精品技術文章、工具資源、行業趨勢,專注於前端組件設計、性能優化、底層原理,讓我們的代碼更優雅。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/byD6xm3O6MzX8kksjBmqpA