事件处理器只有在你再次执行相同的交互操作时才会重新运行。与事件处理器不同,如果 Effect 读取的值(例如 prop 或状态变量)与上次渲染时不同,Effects 会重新同步。有时,你还需要同时使用这两种行为:一个 Effect 会响应某些值但不会响应其他值而重新运行。本页将教你如何做到这一点。
你将学习
- 如何在事件处理器和 Effect 之间进行选择
- 为什么 Effects 是响应式的,而事件处理器不是
- 当你想让 Effect 代码的一部分不具有响应性时该怎么做
- 什么是 Effect 事件,以及如何从你的 Effects 中提取它们
- 如何使用 Effect 事件从 Effects 中读取最新的 props 和状态
在事件处理器和 Effects 之间进行选择
首先,让我们回顾一下事件处理器和 Effects 之间的区别。
想象一下,你正在实现一个聊天室组件。你的需求如下:
- 你的组件应该自动连接到选定的聊天室。
- 当你点击“发送”按钮时,它应该向聊天室发送消息。
假设你已经实现了它们的代码,但你不确定把它放在哪里。你应该使用事件处理器还是 Effects?每次你需要回答这个问题时,请考虑为什么需要运行这段代码。
事件处理器响应特定的交互操作
从用户的角度来看,发送消息应该因为点击了特定的“发送”按钮而发生。如果在任何其他时间或任何其他原因发送他们的消息,用户会非常生气。这就是为什么发送消息应该是一个事件处理器。事件处理器允许你处理特定的交互操作。
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)将仅在用户按下按钮时运行。
Effects 在需要同步时运行
回想一下,你还需要保持组件连接到聊天室。这段代码放在哪里?
运行此代码的原因不是某些特定的交互操作。用户如何导航到聊天室屏幕并不重要。既然他们正在查看它并且可以与它交互,则组件需要保持连接到选定的聊天服务器。即使聊天室组件是应用程序的初始屏幕,并且用户根本没有执行任何交互操作,你仍然需要连接。这就是为什么它是 Effect。
function ChatRoom({ roomId }) {
// ...
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
// ...
}使用此代码,你可以确保始终与当前选定的聊天服务器保持活动连接,无论用户执行什么特定交互操作。无论用户只是打开了你的应用程序、选择了不同的房间,还是导航到另一个屏幕并返回,你的 Effect 都确保组件将保持与当前选定的房间同步,并且将在必要时重新连接。
import { useState, useEffect } from 'react'; import { createConnection, sendMessage } from './chat.js'; const serverUrl = 'https://: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} />} </> ); }
响应式值和响应式逻辑
直观地说,事件处理程序总是“手动”触发,例如点击按钮。另一方面,Effects是“自动的”:它们根据需要运行和重新运行,以保持同步。
有一种更精确的思考方式。
组件主体内部声明的 Props、状态和变量称为响应式值。在这个例子中,serverUrl不是响应式值,但roomId和message是。它们参与渲染数据流。
const serverUrl = 'https://: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://: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 事件
使用一个名为useEffectEvent的特殊 Hook 来从你的 Effect 中提取此非响应式逻辑。
import { useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
});
// ...在这里,onConnected 被称为Effect 事件。它是 Effect 逻辑的一部分,但它的行为更像事件处理程序。它内部的逻辑不是响应式的,并且它总是“看到”props 和 state 的最新值。
现在你可以从 Effect 内部调用onConnected Effect 事件。
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 事件不是响应式的,必须从依赖项中省略。
验证新行为是否符合预期。
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://: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 事件视为与事件处理程序非常相似。主要区别在于事件处理程序响应用户交互运行,而 Effect 事件是由您从 Effects 中触发的。Effect 事件允许您“打破”Effects 的响应性和不应具有响应性的代码之间的链。
使用 Effect 事件读取最新的 props 和状态
Effect 事件允许您修复许多您可能想抑制依赖性代码检查器警告的模式。
例如,假设您有一个 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 自行更改,这不会导致任何代码重新运行。
深入探讨
在现有的代码库中,您有时可能会看到 lint 规则被这样抑制。
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 的稳定部分后,我们建议永远不要抑制代码检查器。
抑制规则的第一个缺点是 React 将不再警告您何时您的 Effect 需要对您引入代码的新响应式依赖项“做出反应”。在之前的示例中,您将 url 添加到依赖项中,因为React 提醒您这样做。如果您禁用代码检查器,您将不再收到对该 Effect 的任何未来编辑的此类提醒。这会导致错误。
这是一个由抑制代码检查器引起的令人困惑的bug示例。在这个例子中,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 事件的使用方式非常有限。
- 只能从 Effect 内部调用它们。
- 绝不要将它们传递给其他组件或 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 旁边声明 Effect 事件。
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 放在一起。
总结
- 事件处理程序响应特定交互而运行。
- Effect 在需要同步时运行。
- 事件处理程序内部的逻辑是非响应式的。
- Effect 内部逻辑是响应式的。
- 您可以将 Effect 中的非响应式逻辑移动到 Effect 事件中。
- 只能从 Effect 内部调用 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> </> ); }