純函式只執行計算,不做其他事情。這讓您的程式碼更容易理解、除錯,並允許 React 自動正確地最佳化您的元件和 Hooks。
為什麼純粹性很重要?
讓 React 成為 *React* 的關鍵概念之一就是 *純粹性*。純粹的元件或 hook 具有以下特性:
- 冪等性 – 每次使用相同的輸入(元件輸入的 props、state、context;以及 hook 輸入的參數)執行它時,您總是得到相同的結果。
- 渲染時沒有副作用 – 具有副作用的程式碼應與渲染分開執行。例如,作為事件處理程序(使用者與 UI 互動並導致其更新的地方);或作為Effect(在渲染後執行)。
- 不變異非局部值:元件和 Hooks 永遠不應修改在渲染中非局部建立的值。
當渲染保持純粹時,React 可以理解如何確定哪些更新對使用者來說最重要,應該優先顯示。這是由於渲染的純粹性:由於元件在渲染時沒有副作用,React 可以暫停渲染不太重要的元件更新,只在需要時才返回更新它們。
具體來說,這表示可以多次執行渲染邏輯,讓 React 為您的使用者提供良好的使用者體驗。但是,如果您的元件具有未追蹤的副作用(例如在渲染期間修改全域變數的值),當 React 再次執行您的渲染程式碼時,您的副作用將以與您預期不符的方式被觸發。這通常會導致意想不到的錯誤,從而降低使用者體驗您的應用程式的效果。您可以在保持元件純粹頁面中看到一個例子。
React 如何執行您的程式碼?
React 是宣告式的:您告訴 React 要渲染*什麼*,React 將找出*如何*最佳地將其顯示給您的使用者。為此,React 有幾個階段可以執行您的程式碼。您不需要了解所有這些階段即可良好地使用 React。但在高階層面上,您應該了解*渲染*中執行的程式碼,以及在其外部執行的程式碼。
*渲染*是指計算 UI 的下一個版本的外觀。渲染後,Effects 會被 *刷新*(表示它們會一直執行,直到沒有剩餘的 Effects),如果 Effects 對佈局有影響,則可能會更新計算。React 取得此新計算結果,並將其與用於建立先前版本 UI 的計算結果進行比較,然後將所需的最小變更*提交*到DOM(您的使用者實際看到的內容)以使其與最新版本同步。
深入探討
一個快速判斷程式碼是否在渲染期間執行的啟發式方法是檢查它的位置:如果它像下面的範例一樣寫在頂層,那麼它很可能在渲染期間執行。
function Dropdown() {
const selectedItems = new Set(); // created during render
// ...
}
事件處理器和 Effects 不會在渲染中執行
function Dropdown() {
const selectedItems = new Set();
const onSelect = (item) => {
// this code is in an event handler, so it's only run when the user triggers this
selectedItems.add(item);
}
}
function Dropdown() {
const selectedItems = new Set();
useEffect(() => {
// this code is inside of an Effect, so it only runs after rendering
logForAnalytics(selectedItems);
}, [selectedItems]);
}
元件和 Hooks 必須具備冪等性
元件必須根據其輸入(屬性、狀態和上下文)始終返回相同的輸出。這稱為 *冪等性*。冪等性 是一個在函數式程式設計中普及的術語。它指的是每次使用相同的輸入執行一段程式碼時,總是得到相同的結果 的概念。
這意味著,為了遵守此規則,在 渲染期間 執行的 *所有* 程式碼也必須是冪等的。例如,以下這行程式碼不具備冪等性(因此,該元件也不具備冪等性):
function Clock() {
const time = new Date(); // 🔴 Bad: always returns a different result!
return <span>{time.toLocaleString()}</span>
}
new Date()
不具備冪等性,因為它總是返回當前日期,並且每次呼叫時都會更改其結果。當您渲染上述元件時,螢幕上顯示的時間將停留在元件渲染的時間。類似地,像 Math.random()
這樣的函數也不具備冪等性,因為即使輸入相同,它們每次呼叫時也會返回不同的結果。
這並不意味著您根本不應該使用 new Date()
等非冪等函數,您只需避免在 渲染期間 使用它們。在這種情況下,我們可以使用 Effect 將最新日期 *同步* 到此元件。
import { useState, useEffect } from 'react'; function useTime() { // 1. Keep track of the current date's state. `useState` receives an initializer function as its // initial state. It only runs once when the hook is called, so only the current date at the // time the hook is called is set first. const [time, setTime] = useState(() => new Date()); useEffect(() => { // 2. Update the current date every second using `setInterval`. const id = setInterval(() => { setTime(new Date()); // ✅ Good: non-idempotent code no longer runs in render }, 1000); // 3. Return a cleanup function so we don't leak the `setInterval` timer. return () => clearInterval(id); }, []); return time; } export default function Clock() { const time = useTime(); return <span>{time.toLocaleString()}</span>; }
透過將非冪等的 new Date()
呼叫包裝在 Effect 中,它將該計算移到 渲染之外。
如果您不需要將某些外部狀態與 React 同步,您也可以考慮使用 事件處理器,如果它只需要響應使用者互動進行更新。
副作用必須在渲染之外執行
副作用 不應該在 渲染中 執行,因為 React 可以多次渲染元件以創造最佳的使用者體驗。
雖然渲染必須保持純粹,但副作用在某些時候是必要的,才能讓您的應用程式執行任何有趣的操作,例如在螢幕上顯示內容!此規則的重點是副作用不應該在 渲染中 執行,因為 React 可以多次渲染元件。在大多數情況下,您將使用 事件處理器 來處理副作用。使用事件處理器明確地告訴 React 這段程式碼不需要在渲染期間執行,保持渲染的純粹性。如果您已嘗試所有選項,並且僅作為最後手段,您也可以使用 useEffect
處理副作用。
什麼時候可以進行變異?
局部變異
副作用的一個常見例子是變異,在 JavaScript 中是指更改非 原始值 的值。一般來說,雖然變異在 React 中並不常見,但 *局部* 變異是絕對可以的。
function FriendList({ friends }) {
const items = []; // ✅ Good: locally created
for (let i = 0; i < friends.length; i++) {
const friend = friends[i];
items.push(
<Friend key={friend.id} friend={friend} />
); // ✅ Good: local mutation is okay
}
return <section>{items}</section>;
}
不需要為了避免局部變異而扭曲您的程式碼。Array.map
也可以在這裡使用以簡潔起見,但在 渲染期間 建立局部陣列然後將項目推送到其中並沒有錯。
即使看起來我們正在變異 items
,但需要注意的關鍵是這段程式碼僅在 *局部* 進行變異,當元件再次渲染時,變異不會被「記住」。換句話說,items
只會在元件存在時存在。因為每次渲染 <FriendList />
時,items
總是會被 *重新建立*,所以元件將始終返回相同的結果。
另一方面,如果在元件外部建立 items
,它會保留其先前值並記住更改。
const items = []; // 🔴 Bad: created outside of the component
function FriendList({ friends }) {
for (let i = 0; i < friends.length; i++) {
const friend = friends[i];
items.push(
<Friend key={friend.id} friend={friend} />
); // 🔴 Bad: mutates a value created outside of render
}
return <section>{items}</section>;
}
當 <FriendList />
再次運行時,我們每次運行該元件時,都會繼續將 friends
附加到 items
,導致多個重複的結果。這個版本的 <FriendList />
在 渲染期間 具有可觀察的副作用,並且 **違反了規則**。
延遲初始化
延遲初始化也是可以的,儘管它不是完全「純粹」的。
function ExpenseForm() {
SuperCalculator.initializeIfNotReady(); // ✅ Good: if it doesn't affect other components
// Continue rendering...
}
變更 DOM
在 React 元件的渲染邏輯中,不允許直接影響使用者可見的副作用。換句話說,僅僅呼叫元件函式本身不應該在螢幕上產生變化。
function ProductDetailPage({ product }) {
document.title = product.title; // 🔴 Bad: Changes the DOM
}
在渲染之外更新 document.title
的一種方法是將元件與 document
同步。
只要多次呼叫元件是安全的,並且不影響其他元件的渲染,React 並不在乎它是否在嚴格的函數式編程意義上是 100% 純粹的。更重要的是,元件必須是冪等的。
屬性和狀態是不可變的
元件的屬性和狀態是不可變的快照。切勿直接修改它們。相反的,應該傳遞新的屬性,並使用 useState
的設定函式。
您可以將屬性和狀態值視為在渲染後更新的快照。因此,您不要直接修改屬性或狀態變數:而是傳遞新的屬性,或使用提供給您的設定函式來告知 React 需要在下次渲染元件時更新狀態。
不要修改屬性
屬性是不可變的,因為如果您修改它們,應用程式將產生不一致的輸出,這可能難以除錯,因為它可能根據情況而定是否有效。
function Post({ item }) {
item.url = new Url(item.url, base); // 🔴 Bad: never mutate props directly
return <Link url={item.url}>{item.title}</Link>;
}
function Post({ item }) {
const url = new Url(item.url, base); // ✅ Good: make a copy instead
return <Link url={url}>{item.title}</Link>;
}
不要修改狀態
useState
會傳回狀態變數和一個用於更新該狀態的設定器。
const [stateVariable, setter] = useState(0);
我們需要使用 useState
傳回的設定器函式來更新狀態變數,而不是就地更新。更改狀態變數上的值不會導致元件更新,從而使用戶的 UI 過時。使用設定器函式會通知 React 狀態已更改,並且我們需要將重新渲染排入佇列以更新 UI。
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
count = count + 1; // 🔴 Bad: never mutate state directly
}
return (
<button onClick={handleClick}>
You pressed me {count} times
</button>
);
}
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1); // ✅ Good: use the setter function returned by useState
}
return (
<button onClick={handleClick}>
You pressed me {count} times
</button>
);
}
Hooks 的傳回值和參數是不可變的
將值傳遞給 hook 後,您不應該修改它們。與 JSX 中的屬性一樣,傳遞給 hook 的值會變為不可變。
function useIconStyle(icon) {
const theme = useContext(ThemeContext);
if (icon.enabled) {
icon.className = computeStyle(icon, theme); // 🔴 Bad: never mutate hook arguments directly
}
return icon;
}
function useIconStyle(icon) {
const theme = useContext(ThemeContext);
const newIcon = { ...icon }; // ✅ Good: make a copy instead
if (icon.enabled) {
newIcon.className = computeStyle(icon, theme);
}
return newIcon;
}
React 中的一個重要原則是*局部推理*:通過單獨查看其程式碼來理解元件或 hook 的功能。呼叫 Hooks 時,應將其視為“黑盒子”。例如,自訂 hook 可能已使用其參數作為依賴項來記憶其中的值
function useIconStyle(icon) {
const theme = useContext(ThemeContext);
return useMemo(() => {
const newIcon = { ...icon };
if (icon.enabled) {
newIcon.className = computeStyle(icon, theme);
}
return newIcon;
}, [icon, theme]);
}
如果您要修改 Hooks 參數,自訂 hook 的記憶將變得不正確,因此避免這樣做很重要。
style = useIconStyle(icon); // `style` is memoized based on `icon`
icon.enabled = false; // Bad: 🔴 never mutate hook arguments directly
style = useIconStyle(icon); // previously memoized result is returned
style = useIconStyle(icon); // `style` is memoized based on `icon`
icon = { ...icon, enabled: false }; // Good: ✅ make a copy instead
style = useIconStyle(icon); // new value of `style` is calculated
同樣,不要修改 Hooks 的傳回值也很重要,因為它們可能已被記憶。
傳遞給 JSX 後,值是不可變的
在 JSX 中使用值後,不要修改它們。在建立 JSX 之前移動修改。
當您在運算式中使用 JSX 時,React 可能會在元件完成渲染之前急切地評估 JSX。這表示在將值傳遞給 JSX 後修改它們可能會導致 UI 過時,因為 React 不知道要更新元件的輸出。
function Page({ colour }) {
const styles = { colour, size: "large" };
const header = <Header styles={styles} />;
styles.size = "small"; // 🔴 Bad: styles was already used in the JSX above
const footer = <Footer styles={styles} />;
return (
<>
{header}
<Content />
{footer}
</>
);
}
function Page({ colour }) {
const headerStyles = { colour, size: "large" };
const header = <Header styles={headerStyles} />;
const footerStyles = { colour, size: "small" }; // ✅ Good: we created a new value
const footer = <Footer styles={footerStyles} />;
return (
<>
{header}
<Content />
{footer}
</>
);
}