事件處理程式只會在您再次執行相同的互動時重新執行。與事件處理程式不同,如果副作用讀取的某些值(例如 prop 或狀態變數)與上次渲染期間的值不同,則副作用會重新同步。有時,您也希望兩種行為兼而有之:一個副作用會響應某些值而重新執行,但不會響應其他值。本頁面將教您如何做到這一點。
你將學到
- 如何在事件處理程式和副作用之間做出選擇
- 為什麼副作用是反應式的,而事件處理程式不是
- 當您希望副作用程式碼的一部分不具有反應性時該怎麼做
- 什麼是副作用事件,以及如何從副作用中提取它們
- 如何使用副作用事件從副作用中讀取最新的 props 和狀態
在事件處理程式和副作用之間選擇
首先,讓我們回顧一下事件處理程式和副作用之間的差異。
想像您正在實作一個聊天室元件。您的需求如下所示
- 您的元件應自動連線到所選聊天室。
- 當您點擊「發送」按鈕時,它應該向聊天室發送一條訊息。
假設您已經實作了它們的程式碼,但不確定要放在哪裡。您應該使用事件處理程式還是副作用?每次您需要回答這個問題時,請考慮 為什麼 需要執行程式碼。
事件處理程式會響應特定互動而執行
從使用者的角度來看,發送訊息應該因為點擊了特定的「發送」按鈕而發生。如果您在其他任何時間或出於任何其他原因發送他們的訊息,使用者會非常不高興。這就是為什麼發送訊息應該是一個事件處理程式。事件處理程式讓您可以處理特定的互動
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
// ...
function handleSendClick() {
sendMessage(message);
}
// ...
return (
<>
<input value={message} onChange={e => setMessage(e.target.value)} />
<button onClick={handleSendClick}>Send</button>
</>
);
}
使用事件處理程式,您可以確定 sendMessage(message)
只會在使用者按下按鈕時執行。
副作用會在需要同步時執行
回想一下,您還需要保持元件連線到聊天室。那段程式碼應該放在哪裡?
執行此程式碼的原因不是某個特定互動。使用者如何或為何導航到聊天室畫面並不重要。既然他們正在查看它並且可以與之互動,則元件需要保持與所選聊天伺服器的連線。即使聊天室元件是您應用程式的初始畫面,並且使用者根本沒有執行任何互動,您仍然需要連線。這就是為什麼它是一個副作用
function ChatRoom({ roomId }) {
// ...
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
// ...
}
使用此程式碼,您可以確定始終與目前選擇的聊天伺服器保持活動連線,無論使用者執行的特定互動為何。無論使用者是剛打開您的應用程式、選擇了不同的房間,還是導航到另一個畫面然後返回,您的副作用都能確保元件將保持與目前選擇的房間同步,並將在必要時重新連線。
import { useState, useEffect } from 'react'; import { createConnection, sendMessage } from './chat.js'; const serverUrl = 'https://127.0.0.1:1234'; function ChatRoom({ roomId }) { const [message, setMessage] = useState(''); useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.connect(); return () => connection.disconnect(); }, [roomId]); function handleSendClick() { sendMessage(message); } return ( <> <h1>Welcome to the {roomId} room!</h1> <input value={message} onChange={e => setMessage(e.target.value)} /> <button onClick={handleSendClick}>Send</button> </> ); } export default function App() { const [roomId, setRoomId] = useState('general'); const [show, setShow] = useState(false); return ( <> <label> Choose the chat room:{' '} <select value={roomId} onChange={e => setRoomId(e.target.value)} > <option value="general">general</option> <option value="travel">travel</option> <option value="music">music</option> </select> </label> <button onClick={() => setShow(!show)}> {show ? 'Close chat' : 'Open chat'} </button> {show && <hr />} {show && <ChatRoom roomId={roomId} />} </> ); }
反應值與反應邏輯
直覺上,您可以說事件處理程式始終是「手動」觸發的,例如點擊按鈕。另一方面,副作用是「自動的」:它們會根據保持同步的需要經常執行和重新執行。
有一種更精確的思考方式。
在組件主體內宣告的 Props、State 和變數稱為反應值。在此範例中,serverUrl
不是反應值,但 roomId
和 message
是。它們參與渲染資料流程。
const serverUrl = 'https://127.0.0.1:1234';
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
// ...
}
像這樣的反應值可能會因為重新渲染而改變。例如,使用者可能會編輯 message
或在下拉式選單中選擇不同的 roomId
。事件處理器和 Effects 對變化的反應不同。
- 事件處理器內的邏輯*不是*反應式的。 除非使用者再次執行相同的互動(例如點擊),否則它不會再次運行。事件處理器可以讀取反應值,而不會對其變化做出「反應」。
- Effects 內的邏輯*是*反應式的。 如果您的 Effect 讀取反應值,您必須將其指定為依賴項。 然後,如果重新渲染導致該值發生變化,React 將使用新值重新運行您的 Effect 邏輯。
讓我們回顧前面的例子來說明這種差異。
事件處理器內的邏輯不是反應式的
看看這行程式碼。這個邏輯應該是反應式的嗎?
// ...
sendMessage(message);
// ...
從使用者的角度來看,更改 message
*並不*意味著他們想要發送訊息。 這只意味著使用者正在輸入。換句話說,發送訊息的邏輯不應該是反應式的。它不應該僅僅因為反應值已更改而再次運行。這就是為什麼它屬於事件處理器。
function handleSendClick() {
sendMessage(message);
}
事件處理器不是反應式的,因此 sendMessage(message)
只會在使用者點擊「發送」按鈕時運行。
Effects 內的邏輯是反應式的
現在讓我們回到這幾行程式碼。
// ...
const connection = createConnection(serverUrl, roomId);
connection.connect();
// ...
從使用者的角度來看,更改 roomId
*的確*意味著他們想要連接到不同的房間。 換句話說,連接到房間的邏輯應該是反應式的。您*希望*這幾行程式碼能夠「跟上」反應值,並且如果該值不同則再次運行。這就是為什麼它屬於 Effect。
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect()
};
}, [roomId]);
Effects 是反應式的,因此 createConnection(serverUrl, roomId)
和 connection.connect()
將針對 roomId
的每個不同值運行。您的 Effect 使聊天連線與當前選擇的房間同步。
從 Effects 中提取非反應式邏輯
當您想要混合反應式邏輯和非反應式邏輯時,事情會變得更加棘手。
例如,假設您想要在使用者連接到聊天時顯示通知。您從 props 讀取當前主題(深色或淺色),以便您可以以正確的顏色顯示通知。
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('Connected!', theme);
});
connection.connect();
// ...
但是,theme
是一個反應值(它可以因重新渲染而改變),並且 Effect 讀取的每個反應值都必須宣告為其依賴項。 現在您必須將 theme
指定為 Effect 的依賴項。
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('Connected!', theme);
});
connection.connect();
return () => {
connection.disconnect()
};
}, [roomId, theme]); // ✅ All dependencies declared
// ...
試著操作這個例子,看看您是否可以發現這個使用者體驗的問題。
import { useState, useEffect } from 'react'; import { createConnection, sendMessage } from './chat.js'; import { showNotification } from './notifications.js'; const serverUrl = 'https://127.0.0.1:1234'; function ChatRoom({ roomId, theme }) { useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.on('connected', () => { showNotification('Connected!', theme); }); connection.connect(); return () => connection.disconnect(); }, [roomId, theme]); return <h1>Welcome to the {roomId} room!</h1> } export default function App() { const [roomId, setRoomId] = useState('general'); const [isDark, setIsDark] = useState(false); return ( <> <label> Choose the chat room:{' '} <select value={roomId} onChange={e => setRoomId(e.target.value)} > <option value="general">general</option> <option value="travel">travel</option> <option value="music">music</option> </select> </label> <label> <input type="checkbox" checked={isDark} onChange={e => setIsDark(e.target.checked)} /> Use dark theme </label> <hr /> <ChatRoom roomId={roomId} theme={isDark ? 'dark' : 'light'} /> </> ); }
當 roomId
改變時,聊天會如您所料重新連線。但由於 theme
也是一個依賴項,因此每次您在深色和淺色主題之間切換時,聊天*也會*重新連線。這不太好!
換句話說,即使這行程式碼在 Effect(反應式)內,您也*不希望*它是反應式的。
// ...
showNotification('Connected!', theme);
// ...
您需要一種方法將這種非反應式邏輯與周圍的反應式 Effect 分開。
宣告 Effect Event
使用一個名為 useEffectEvent
的特殊 Hook 將此非反應式邏輯從 Effect 中提取出來。
import { useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
});
// ...
這裡,onConnected
稱為 *Effect Event*。它是 Effect 邏輯的一部分,但它的行為更像事件處理器。它內部的邏輯不是反應式的,並且它總是「看到」您的 props 和 state 的最新值。
現在您可以從 Effect 內部調用 onConnected
Effect Event。
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
onConnected();
});
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ All dependencies declared
// ...
這解決了問題。請注意,您必須從 Effect 的依賴項列表中*移除* onConnected
。Effect Event 不是反應式的,必須從依賴項中省略。
驗證新的行為是否如您所料。
import { useState, useEffect } from 'react'; import { experimental_useEffectEvent as useEffectEvent } from 'react'; import { createConnection, sendMessage } from './chat.js'; import { showNotification } from './notifications.js'; const serverUrl = 'https://127.0.0.1:1234'; function ChatRoom({ roomId, theme }) { const onConnected = useEffectEvent(() => { showNotification('Connected!', theme); }); useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.on('connected', () => { onConnected(); }); connection.connect(); return () => connection.disconnect(); }, [roomId]); return <h1>Welcome to the {roomId} room!</h1> } export default function App() { const [roomId, setRoomId] = useState('general'); const [isDark, setIsDark] = useState(false); return ( <> <label> Choose the chat room:{' '} <select value={roomId} onChange={e => setRoomId(e.target.value)} > <option value="general">general</option> <option value="travel">travel</option> <option value="music">music</option> </select> </label> <label> <input type="checkbox" checked={isDark} onChange={e => setIsDark(e.target.checked)} /> Use dark theme </label> <hr /> <ChatRoom roomId={roomId} theme={isDark ? 'dark' : 'light'} /> </> ); }
您可以將 Effect Event 視為與事件處理器非常相似。主要區別在於事件處理器響應使用者互動而運行,而 Effect Event 則是由您從 Effects 觸發。Effect Event 讓您能夠在 Effects 的反應性和不應該是反應式的程式碼之間「斷開鏈」。
使用 Effect 事件讀取最新的 props 和 state
Effect 事件可以讓你解決許多可能 tempted 想抑制依賴性檢查器 (dependency linter) 的模式。
例如,假設你有一個 Effect 來記錄頁面訪問
function Page() {
useEffect(() => {
logVisit();
}, []);
// ...
}
稍後,你在你的網站上新增多個路由。現在你的 Page
元件會收到一個帶有目前路徑的 url
prop。你想將 url
作為 logVisit
呼叫的一部分傳遞,但依賴性檢查器會發出警告
function Page({ url }) {
useEffect(() => {
logVisit(url);
}, []); // 🔴 React Hook useEffect has a missing dependency: 'url'
// ...
}
想想你想要程式碼做什麼。你*想要*為不同的 URL 記錄不同的訪問,因為每個 URL 代表不同的頁面。換句話說,這個 logVisit
呼叫*應該*對 url
有反應。這就是為什麼,在這種情況下,遵循依賴性檢查器,並將 url
作為依賴項新增是有意義的
function Page({ url }) {
useEffect(() => {
logVisit(url);
}, [url]); // ✅ All dependencies declared
// ...
}
現在假設你想在每次頁面訪問時包含購物車中的商品數量
function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;
useEffect(() => {
logVisit(url, numberOfItems);
}, [url]); // 🔴 React Hook useEffect has a missing dependency: 'numberOfItems'
// ...
}
你在 Effect 中使用了 numberOfItems
,因此檢查器要求你將其作為依賴項新增。但是,你*不希望* logVisit
呼叫對 numberOfItems
有反應。如果使用者將商品放入購物車,並且 numberOfItems
發生變化,這*並不意味著*使用者再次訪問了該頁面。換句話說,*訪問頁面*在某種意義上是一個「事件」。它發生在一個精確的時間點。
將程式碼分成兩部分
function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;
const onVisit = useEffectEvent(visitedUrl => {
logVisit(visitedUrl, numberOfItems);
});
useEffect(() => {
onVisit(url);
}, [url]); // ✅ All dependencies declared
// ...
}
這裡,onVisit
是一個 Effect 事件。其中的程式碼沒有反應性。這就是為什麼你可以使用 numberOfItems
(或任何其他反應值!)而不必擔心它會導致周圍的程式碼重新執行。
另一方面,Effect 本身仍然具有反應性。Effect 內的程式碼使用 url
prop,因此 Effect 將在每次使用不同 url
重新渲染後重新執行。這反過來又會呼叫 onVisit
Effect 事件。
因此,你將為 url
的每次更改呼叫 logVisit
,並始終讀取最新的 numberOfItems
。但是,如果 numberOfItems
自身發生變化,這不會導致任何程式碼重新執行。
深入探討
在現有的程式碼庫中,你有時可能會看到像這樣抑制了檢查規則
function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;
useEffect(() => {
logVisit(url, numberOfItems);
// 🔴 Avoid suppressing the linter like this:
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
// ...
}
在 useEffectEvent
成為 React 的穩定部分之後,我們建議永遠不要抑制檢查器。
抑制規則的第一個缺點是,當你的 Effect 需要對你引入程式碼的新反應依賴項「做出反應」時,React 將不再警告你。在前面的例子中,你將 url
新增到依賴項中,*因為* React 提醒你這樣做。如果你停用檢查器,你將不再收到對該 Effect 的任何未來編輯的此類提醒。這會導致錯誤。
以下是一個因抑制程式碼檢查器而導致的混淆錯誤範例。在此範例中,handleMove
函式應該讀取目前的 canMove
狀態變數值,以決定點是否應該跟隨游標。然而,在 handleMove
內部,canMove
永遠是 true
。
你知道為什麼嗎?
import { useState, useEffect } from 'react'; export default function App() { const [position, setPosition] = useState({ x: 0, y: 0 }); const [canMove, setCanMove] = useState(true); function handleMove(e) { if (canMove) { setPosition({ x: e.clientX, y: e.clientY }); } } useEffect(() => { window.addEventListener('pointermove', handleMove); return () => window.removeEventListener('pointermove', handleMove); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( <> <label> <input type="checkbox" checked={canMove} onChange={e => setCanMove(e.target.checked)} /> The dot is allowed to move </label> <hr /> <div style={{ position: 'absolute', backgroundColor: 'pink', borderRadius: '50%', opacity: 0.6, transform: `translate(${position.x}px, ${position.y}px)`, pointerEvents: 'none', left: -20, top: -20, width: 40, height: 40, }} /> </> ); }
這段程式碼的問題在於抑制了依賴項程式碼檢查器。如果您移除抑制,您會看到這個 Effect 應該依賴於 handleMove
函式。這是合理的:handleMove
是在元件主體內部宣告的,這使得它成為一個反應值。每個反應值都必須被指定為一個依賴項,否則它可能會隨著時間推移而過時!
原始程式碼的作者透過聲明 Effect 不依賴 ([]
) 任何反應值來「欺騙」React。這就是為什麼在 canMove
改變(以及 handleMove
)之後,React 沒有重新同步 Effect 的原因。因為 React 沒有重新同步 Effect,所以作為監聽器附加的 handleMove
是在初始渲染期間建立的 handleMove
函式。在初始渲染期間,canMove
是 true
,這就是為什麼初始渲染的 handleMove
將永遠看到該值的原因。
如果您從不抑制程式碼檢查器,您將永遠不會看到過時值的問題。
使用 useEffectEvent
,就沒有必要「欺騙」程式碼檢查器,程式碼會按預期工作。
import { useState, useEffect } from 'react'; import { experimental_useEffectEvent as useEffectEvent } from 'react'; export default function App() { const [position, setPosition] = useState({ x: 0, y: 0 }); const [canMove, setCanMove] = useState(true); const onMove = useEffectEvent(e => { if (canMove) { setPosition({ x: e.clientX, y: e.clientY }); } }); useEffect(() => { window.addEventListener('pointermove', onMove); return () => window.removeEventListener('pointermove', onMove); }, []); return ( <> <label> <input type="checkbox" checked={canMove} onChange={e => setCanMove(e.target.checked)} /> The dot is allowed to move </label> <hr /> <div style={{ position: 'absolute', backgroundColor: 'pink', borderRadius: '50%', opacity: 0.6, transform: `translate(${position.x}px, ${position.y}px)`, pointerEvents: 'none', left: -20, top: -20, width: 40, height: 40, }} /> </> ); }
這並不意味著 useEffectEvent
*永遠* 是正確的解決方案。您應該只將其應用於您不希望具有反應性的程式碼行。在上面的沙盒中,您不希望 Effect 的程式碼與 canMove
產生反應。這就是為什麼提取 Effect 事件是有意義的。
閱讀移除 Effect 依賴項以了解抑制程式碼檢查器的其他正確替代方案。
Effect 事件的限制...
Effect 事件的使用方式非常有限。
- 只能從 Effects 內部呼叫它們。
- 永遠不要將它們傳遞給其他元件或 Hooks。
例如,不要像這樣宣告和傳遞 Effect 事件。
function Timer() {
const [count, setCount] = useState(0);
const onTick = useEffectEvent(() => {
setCount(count + 1);
});
useTimer(onTick, 1000); // 🔴 Avoid: Passing Effect Events
return <h1>{count}</h1>
}
function useTimer(callback, delay) {
useEffect(() => {
const id = setInterval(() => {
callback();
}, delay);
return () => {
clearInterval(id);
};
}, [delay, callback]); // Need to specify "callback" in dependencies
}
相反,請始終將 Effect 事件直接宣告在使用它們的 Effects 旁邊。
function Timer() {
const [count, setCount] = useState(0);
useTimer(() => {
setCount(count + 1);
}, 1000);
return <h1>{count}</h1>
}
function useTimer(callback, delay) {
const onTick = useEffectEvent(() => {
callback();
});
useEffect(() => {
const id = setInterval(() => {
onTick(); // ✅ Good: Only called locally inside an Effect
}, delay);
return () => {
clearInterval(id);
};
}, [delay]); // No need to specify "onTick" (an Effect Event) as a dependency
}
Effect 事件是 Effect 程式碼的非反應性「片段」。它們應該在使用它們的 Effect 旁邊。
摘要...
- 事件處理程式會因應特定的互動而執行。
- Effects 會在需要同步時執行。
- 事件處理程式內的邏輯不具反應性。
- Effects 內部的邏輯具有反應性。
- 您可以將非反應性邏輯從 Effects 移至 Effect 事件。
- 只能從 Effects 內部呼叫 Effect 事件。
- 不要將 Effect 事件傳遞給其他元件或 Hooks。
嘗試一些挑戰...
挑戰 1之 4: 修復未更新的變數...
這個 Timer
元件維護一個 count
狀態變數,該變數每秒遞增。遞增的值儲存在 increment
狀態變數中。您可以使用加號和減號按鈕來控制 increment
變數。
但是,無論您點擊加號按鈕多少次,計數器仍然每秒遞增 1。這段程式碼有什麼問題?為什麼在 Effect 的程式碼內部,increment
總是等於 1
?找出錯誤並修復它。
import { useState, useEffect } from 'react'; export default function Timer() { const [count, setCount] = useState(0); const [increment, setIncrement] = useState(1); useEffect(() => { const id = setInterval(() => { setCount(c => c + increment); }, 1000); return () => { clearInterval(id); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( <> <h1> Counter: {count} <button onClick={() => setCount(0)}>Reset</button> </h1> <hr /> <p> Every second, increment by: <button disabled={increment === 0} onClick={() => { setIncrement(i => i - 1); }}>–</button> <b>{increment}</b> <button onClick={() => { setIncrement(i => i + 1); }}>+</button> </p> </> ); }