事件处理程序仅在您再次执行相同交互时才会重新运行。 与事件处理程序不同,如果 Effects 读取的某些值(例如 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} />} </> ); }
响应式值和响应式逻辑
直观地说,你可以说事件处理程序总是“手动”触发的,例如通过点击按钮。另一方面,副作用是“自动”的:它们会根据需要运行和重新运行,以保持同步。
有一种更精确的方式来思考这个问题。
在组件体内部声明的 Props、状态和变量被称为 响应式值。在此示例中,serverUrl 不是响应式值,但 roomId 和 message 是。它们参与渲染数据流。
const serverUrl = 'https://:1234';
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
// ...
}像这样的响应式值可能会因重新渲染而改变。例如,用户可能会编辑 message 或在下拉菜单中选择不同的 roomId。事件处理程序和副作用以不同的方式响应这些变化。
- 事件处理程序内的逻辑*不是*响应式的。 除非用户再次执行相同的交互(例如单击),否则它不会再次运行。事件处理程序可以在不“响应”其更改的情况下读取响应式值。
- 副作用内的逻辑*是*响应式的。 如果你的副作用读取了一个响应式值,你必须将其指定为依赖项。 然后,如果重新渲染导致该值发生变化,React 将使用新值重新运行你的副作用逻辑。
让我们重新回顾前面的例子来说明这种差异。
事件处理程序内的逻辑不是响应式的
看看这行代码。这个逻辑应该是响应式的吗?
// ...
sendMessage(message);
// ...从用户的角度来看,对 message 的更改*并不*意味着他们想要发送消息。 这只意味着用户正在输入。换句话说,发送消息的逻辑不应该是响应式的。它不应该仅仅因为 响应式值 发生了变化而再次运行。这就是为什么它属于事件处理程序。
function handleSendClick() {
sendMessage(message);
}事件处理程序不是响应式的,因此 sendMessage(message) 将仅在用户单击“发送”按钮时运行。
副作用内的逻辑是响应式的
现在让我们回到这几行代码。
// ...
const connection = createConnection(serverUrl, roomId);
connection.connect();
// ...从用户的角度来看,对 roomId 的更改*确实*意味着他们想要连接到不同的房间。 换句话说,用于连接到房间的逻辑应该是响应式的。你*希望*这几行代码能够“跟上” 响应式值,并在该值不同时再次运行。这就是为什么它属于副作用。
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect()
};
}, [roomId]);副作用是响应式的,因此 createConnection(serverUrl, roomId) 和 connection.connect() 将针对 roomId 的每个不同值运行。你的副作用使聊天连接与当前选择的房间保持同步。
从副作用中提取非响应式逻辑
当你想要将响应式逻辑与非响应式逻辑混合在一起时,事情会变得更加棘手。
例如,假设你想要在用户连接到聊天室时显示通知。你从 props 中读取当前主题(深色或浅色),以便你可以以正确的颜色显示通知。
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('Connected!', theme);
});
connection.connect();
// ...但是,theme 是一个响应式值(它可以作为重新渲染的结果而改变),并且 副作用读取的每个响应式值都必须声明为其依赖项。 现在你必须将 theme 指定为副作用的依赖项。
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 也是一个依赖项,因此每次你在深色和浅色主题之间切换时,聊天*也会*重新连接。这可不太好!
换句话说,你*不希望*这行代码是响应式的,即使它在副作用内部(它是响应式的)。
// ...
showNotification('Connected!', theme);
// ...你需要一种方法将此非响应式逻辑与其周围的响应式副作用分开。
声明副作用事件
使用一个名为 useEffectEvent 的特殊 Hook 来将此非响应式逻辑从你的副作用中提取出来。
import { useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
});
// ...在这里,onConnected 被称为*副作用事件*。它是你的副作用逻辑的一部分,但它的行为更像事件处理程序。它内部的逻辑不是响应式的,它总是“看到”你的 props 和状态的最新值。
现在你可以从副作用内部调用 onConnected 副作用事件。
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
// ...这解决了问题。请注意,你必须从副作用的依赖项列表中*删除* onConnected。 副作用事件不是响应式的,必须从依赖项中省略。
验证新行为是否按预期工作。
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'} /> </> ); }
你可以将副作用事件视为与事件处理程序非常相似。主要区别在于事件处理程序响应用户交互而运行,而副作用事件由你从副作用中触发。副作用事件允许你“打破”副作用的响应性和不应该是响应式的代码之间的“链条”。
使用副作用事件读取最新的 props 和状态
副作用事件允许你修复许多你可能想要抑制依赖项 linter 的模式。
例如,假设您有一个记录页面访问量的 Effect
function Page() {
useEffect(() => {
logVisit();
}, []);
// ...
}之后,您向网站添加了多个路由。现在,您的 Page 组件会接收一个带有当前路径的 url 属性。您希望将 url 作为 logVisit 调用的一部分传递,但依赖项 linter 会发出警告
function Page({ url }) {
useEffect(() => {
logVisit(url);
}, []); // 🔴 React Hook useEffect has a missing dependency: 'url'
// ...
}想一想您希望代码做什么。您希望为不同的 URL 记录单独的访问量,因为每个 URL 都代表一个不同的页面。换句话说,此 logVisit 调用应该对 url 做出响应。这就是为什么在这种情况下,遵循依赖项 linter 并将 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,因此 linter 要求您将其添加为依赖项。但是,您不希望 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 属性,因此 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 的稳定部分后,我们建议永远不要抑制 linter。
抑制该规则的第一个缺点是,当您的 Effect 需要“响应”您引入代码的新响应式依赖项时,React 将不再警告您。在前面的示例中,您将 url 添加到依赖项中,因为React 提醒您这样做。如果您禁用 linter,则在将来对该 Effect 进行任何编辑时,您将不再收到此类提醒。这会导致错误。
下面是一个由抑制 linter 引起的令人困惑的错误示例。在此示例中,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, }} /> </> ); }
此代码的问题在于抑制了依赖项 linter。如果您删除抑制,您将看到此 Effect 应该依赖于 handleMove 函数。这很有道理:handleMove 在组件主体内部声明,这使其成为一个响应式值。每个响应式值都必须指定为依赖项,否则它可能会随着时间的推移而变得过时!
原始代码的作者对 React “撒谎”,说 Effect 不依赖于 ([]) 任何响应式值。这就是为什么 React 在 canMove 改变后(以及 handleMove)没有重新同步 Effect 的原因。因为 React 没有重新同步 Effect,所以作为监听器附加的 handleMove 是在初始渲染期间创建的 handleMove 函数。在初始渲染期间,canMove 为 true,这就是为什么初始渲染的 handleMove 将永远看到该值的原因。
如果你从不抑制 linter,你将永远不会看到过时值的问题。
使用 useEffectEvent,无需对 linter “撒谎”,并且代码会按预期工作
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 依赖项,了解抑制 linter 的其他正确替代方法。
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 变量。
但是,无论你点击加号按钮多少次,计数器仍然每秒递增一。这段代码有什么问题?为什么在 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> </> ); }