事件处理函数只有在你再次执行同样的交互时才会重新运行。与事件处理函数不同,如果某个 Effect 读取的值(比如 props 或 state 变量)与上一次渲染时不同,Effect 会重新同步。有时候,你也会希望同时具备这两种行为:一个会根据某些值重新运行、但不会因其他值而重新运行的 Effect。本页将教你如何做到这一点。
You will learn
- 如何在事件处理函数和 Effect 之间做选择
- 为什么 Effect 是响应式的,而事件处理函数不是
- 当你希望 Effect 中的一部分代码不具备响应性时该怎么做
- 什么是 Effect Event,以及如何从 Effect 中提取它们
- 如何使用 Effect Event 从 Effect 中读取最新的 props 和 state
在事件处理函数和 Effect 之间做选择
首先,让我们回顾一下事件处理函数和 Effect 的区别。
假设你正在实现一个聊天室组件。你的需求如下:
- 组件应当自动连接到所选的聊天室。
- 当你点击 “Send” 按钮时,它应该向聊天发送一条消息。
假设你已经为它们实现好了代码,但不确定该放在哪里。应该使用事件处理函数还是 Effect?每次你需要回答这个问题时,都要考虑 为什么 这段代码需要运行。
事件处理函数会响应特定交互而运行
从用户的角度看,发送消息应该是因为点击了特定的 “Send” 按钮才发生的。如果你在其他时间或出于其他原因发送他们的消息,用户会相当不满。这就是为什么发送消息应该是一个事件处理函数。事件处理函数让你处理特定交互:
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
// ...
function handleSendClick() {
sendMessage(message);
}
// ...
return (
<>
<input value={message} onChange={e => setMessage(e.target.value)} />
<button onClick={handleSendClick}>发送</button>
</>
);
}有了事件处理函数,你可以确定 sendMessage(message) 只会在用户按下按钮时运行。
Effect 会在需要同步时运行
还记得你也需要保持组件连接到聊天室。那段代码应该放在哪里?
运行这段代码的原因并不是某个特定交互。用户是如何、为什么进入聊天室界面的并不重要。现在他们正在查看它并可能与之交互,组件需要保持与所选聊天服务器的连接。即使聊天室组件是应用的初始界面,且用户根本没有进行任何交互,你仍然需要连接。这就是为什么它应该是一个 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://localhost: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>欢迎来到 {roomId} 房间!</h1> <input value={message} onChange={e => setMessage(e.target.value)} /> <button onClick={handleSendClick}>发送</button> </> ); } export default function App() { const [roomId, setRoomId] = useState('general'); const [show, setShow] = useState(false); return ( <> <label> 选择聊天室:{' '} <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 ? '关闭聊天' : '打开聊天'} </button> {show && <hr />} {show && <ChatRoom roomId={roomId} />} </> ); }
响应式值与响应式逻辑
直观地说,你可以认为事件处理函数总是被“手动”触发的,例如通过点击按钮。另一方面,Effect 是“自动”的:它们会按需运行和重新运行,以保持同步。
不过,我们可以更精确地理解这一点。
在组件函数体内声明的 props、state 和变量被称为响应式值。在这个例子中,serverUrl 不是响应式值,而 roomId 和 message 是。它们参与渲染数据流:
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
// ...
}像这样的响应式值可能会因为重新渲染而变化。例如,用户可能编辑 message,或者在下拉菜单中选择不同的 roomId。事件处理函数和 Effect 对变化的响应方式不同:
- 事件处理函数中的逻辑不是响应式的。 除非用户再次执行同样的交互(例如点击)否则它不会再次运行。事件处理函数可以读取响应式值,而不会对它们的变化“做出反应”。
- Effect 中的逻辑是响应式的。 如果你的 Effect 读取了一个响应式值,你必须将它指定为依赖项。 然后,如果一次重新渲染导致该值变化,React 会使用新值重新运行你的 Effect 逻辑。
让我们回到前面的例子来说明这种区别。
事件处理函数中的逻辑不是响应式的
看看这行代码。这里的逻辑应该是响应式的吗?
// ...
sendMessage(message);
// ...从用户的角度看,message 的变化并不意味着他们想要发送一条消息。 它只意味着用户正在输入。换句话说,发送消息的逻辑不应该是响应式的。它不应该仅仅因为响应式值变化了就再次运行。这就是它属于事件处理函数的原因:
function handleSendClick() {
sendMessage(message);
}事件处理函数不是响应式的,所以 sendMessage(message) 只会在用户点击 Send 按钮时运行。
Effect 中的逻辑是响应式的
现在让我们回到这些代码行:
// ...
const connection = createConnection(serverUrl, roomId);
connection.connect();
// ...从用户的角度看,roomId 的变化确实意味着他们想要连接到另一个房间。 换句话说,连接房间的逻辑应该是响应式的。你希望这些代码行能“跟上”响应式值,并在该值变化时重新运行。这就是它属于 Effect 的原因:
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect()
};
}, [roomId]);Effect 是响应式的,所以 createConnection(serverUrl, roomId) 和 connection.connect() 会在 roomId 的每个不同值上运行。你的 Effect 会让聊天连接与当前所选房间保持同步。
将非响应式逻辑从 Effect 中提取出来
当你想把响应式逻辑与非响应式逻辑混合在一起时,事情就会变得更复杂。
例如,假设你想在用户连接到聊天时显示一条通知。你从 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]); // ✅ 已声明所有依赖项
// ...试试这个示例,看看你能否找出这个用户体验中的问题:
import { useState, useEffect } from 'react'; import { createConnection, sendMessage } from './chat.js'; import { showNotification } from './notifications.js'; const serverUrl = 'https://localhost: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>欢迎来到 {roomId} 房间!</h1> } export default function App() { const [roomId, setRoomId] = useState('general'); const [isDark, setIsDark] = useState(false); return ( <> <label> 选择聊天室:{' '} <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)} /> 使用深色主题 </label> <hr /> <ChatRoom roomId={roomId} theme={isDark ? 'dark' : 'light'} /> </> ); }
当 roomId 变化时,聊天会按预期重新连接。但由于 theme 也是一个依赖项,所以每次你在深色和浅色主题之间切换时,聊天也会重新连接。这不太好!
换句话说,尽管这行代码位于一个 Effect 中(而 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]); // ✅ 已声明所有依赖项
// ...这解决了问题。注意,你必须把 theme 从 Effect 的依赖项列表中移除,因为它不再在 Effect 中使用了。你也不需要把 onConnected 添加进去,因为Effect Event 不是响应式的,必须从依赖项中省略。
验证一下新的行为是否符合预期:
import { useState, useEffect } from 'react'; import { useEffectEvent } from 'react'; import { createConnection, sendMessage } from './chat.js'; import { showNotification } from './notifications.js'; const serverUrl = 'https://localhost: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>欢迎来到 {roomId} 房间!</h1> } export default function App() { const [roomId, setRoomId] = useState('general'); const [isDark, setIsDark] = useState(false); return ( <> <label> 选择聊天室:{' '} <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)} /> 使用深色主题 </label> <hr /> <ChatRoom roomId={roomId} theme={isDark ? 'dark' : 'light'} /> </> ); }
你可以把 Effect Event 看作和事件处理函数非常相似。主要区别在于,事件处理函数是响应用户交互而运行,而 Effect Events 是由你从 Effect 中触发的。Effect Events 让你可以“打断”Effect 的响应式链条与那些不应该响应式的代码之间的联系。
使用 Effect Event 读取最新的 props 和 state
Effect Event 可以帮助你修复很多你可能想通过抑制依赖项检查器来处理的模式。
例如,假设你有一个用于记录页面访问的 Effect:
function Page() {
useEffect(() => {
logVisit();
}, []);
// ...
}后来,你的网站增加了多个路由。现在你的 Page 组件会接收一个表示当前路径的 url prop。你希望在 logVisit 调用中传入 url,但依赖项检查器会报错:
function Page({ url }) {
useEffect(() => {
logVisit(url);
}, []); // 🔴 React Hook useEffect 缺少依赖项:'url'
// ...
}想一想你希望这段代码做什么。你希望针对不同的 URL 记录分别的访问,因为每个 URL 都代表一个不同的页面。换句话说,这次 logVisit 调用应该对 url 保持响应式。这就是为什么在这种情况下,遵循依赖项检查器并把 url 加入依赖项是合理的:
function Page({ url }) {
useEffect(() => {
logVisit(url);
}, [url]); // ✅ 已声明所有依赖项
// ...
}现在假设你想在每次页面访问时都把购物车中的商品数量一起记录:
function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;
useEffect(() => {
logVisit(url, numberOfItems);
}, [url]); // 🔴 React Hook useEffect 缺少依赖项:'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]); // ✅ 已声明所有依赖项
// ...
}这里的 onVisit 是一个 Effect Event。它内部的代码不是响应式的。这就是为什么你可以使用 numberOfItems(或任何其他响应式值!),而不用担心它会导致外层代码在变化时重新执行。
另一方面,Effect 本身仍然是响应式的。Effect 内部的代码使用了 url prop,因此 Effect 会在每次重新渲染且 url 不同时重新运行。而这又会调用 onVisit 这个 Effect Event。
结果就是,你会在 url 每次变化时都调用 logVisit,并且始终读取到最新的 numberOfItems。不过,如果 numberOfItems 自己变化了,这不会导致任何代码重新运行。
Deep Dive
在现有代码库中,你有时可能会看到像这样抑制 lint 规则:
function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;
useEffect(() => {
logVisit(url, numberOfItems);
// 🔴 不要像这样抑制检查器:
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
// ...
}我们建议永远不要抑制检查器。
抑制规则的第一个缺点是:当你的 Effect 需要“响应”你后来添加到代码中的新响应式依赖时,React 将不再提醒你。在前面的例子里,你之所以把 url 加入依赖项,正是因为 React 提醒了你这么做。如果你禁用了检查器,以后对这个 Effect 的任何修改都不会再得到这样的提醒。这会导致 bug。
下面是一个因抑制检查器而导致的令人困惑的 bug 示例。在这个例子里,handleMove 函数本应读取当前 canMove state 变量的值,以决定这个点是否应该跟随光标。然而,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)} /> 允许这个点移动 </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, }} /> </> ); }
这段代码的问题在于抑制了依赖项检查器。如果你移除这个抑制,React 会告诉你这个 Effect 的代码依赖于 handleMove 函数。这是有道理的:handleMove 定义在组件函数体内,因此它是一个响应式值。每个响应式值都必须被指定为依赖项,否则它就可能随着时间推移变得陈旧!
原始代码的作者对 React “撒了谎”,声称这个 Effect 不依赖任何响应式值([])。这就是为什么 canMove 改变后,React 没有重新同步这个 Effect(以及其中的 handleMove)。因为 React 没有重新同步这个 Effect,作为监听器附加上的 handleMove 就是初始渲染时创建的那个 handleMove 函数。在初始渲染时,canMove 是 true,这就是为什么初始渲染中的 handleMove 永远看到这个值。
如果你从不抑制检查器,就永远不会看到陈旧值的问题。
使用 useEffectEvent 时,就没有必要对检查器“撒谎”,代码也会如你所期望的那样工作:
import { useState, useEffect } from 'react'; import { 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)} /> 允许这个点移动 </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 Event 是合理的。
请阅读移除 Effect 依赖项了解其他可以正确替代抑制检查器的方法。
Effect Event 的限制
Effect Event 的使用非常受限:
- 只能在 Effect 内部调用它们。
- 绝不要把它们传给其他组件或 Hook。
例如,不要像这样声明并传递一个 Effect Event:
function Timer() {
const [count, setCount] = useState(0);
const onTick = useEffectEvent(() => {
setCount(count + 1);
});
useTimer(onTick, 1000); // 🔴 避免:传递 Effect Event
return <h1>{count}</h1>
}
function useTimer(callback, delay) {
useEffect(() => {
const id = setInterval(() => {
callback();
}, delay);
return () => {
clearInterval(id);
};
}, [delay, callback]); // 需要在依赖项中指定 "callback"
}相反,始终把 Effect Event 直接声明在使用它的 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(); // ✅ 好:仅在 Effect 内部本地调用
}, delay);
return () => {
clearInterval(id);
};
}, [delay]); // 不需要把 "onTick"(一个 Effect Event)指定为依赖项
}Effect Event 是你的 Effect 代码中不具备响应性的“片段”。它们应该放在使用它们的 Effect 附近。
Recap
- 事件处理函数会响应特定交互而运行。
- Effect 会在需要同步时运行。
- 事件处理函数中的逻辑不是响应式的。
- Effect 中的逻辑是响应式的。
- 你可以把 Effect 中的非响应式逻辑移动到 Effect Event 中。
- 只能在 Effect 内部调用 Effect Event。
- 不要把 Effect Event 传给其他组件或 Hook。
Challenge 1 of 4: 修复一个不会更新的变量
这个 Timer 组件维护了一个 count state 变量,它每秒增加一次。它每次增加的值存储在 increment state 变量中。你可以通过加号和减号按钮控制 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> 计数器:{count} <button onClick={() => setCount(0)}>重置</button> </h1> <hr /> <p> 每秒增加: <button disabled={increment === 0} onClick={() => { setIncrement(i => i - 1); }}>–</button> <b>{increment}</b> <button onClick={() => { setIncrement(i => i + 1); }}>+</button> </p> </> ); }