事件处理器只有在你再次执行相同的交互操作时才会重新运行。与事件处理器不同,如果 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://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} />} </> ); }
响应式值和响应式逻辑
直观地说,事件处理程序总是“手动”触发,例如点击按钮。另一方面,Effects是“自动的”:它们根据需要运行和重新运行,以保持同步。
有一种更精确的思考方式。
组件主体内部声明的 Props、状态和变量称为响应式值。在这个例子中,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 事件
使用一个名为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://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 事件视为与事件处理程序非常相似。主要区别在于事件处理程序响应用户交互运行,而 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> </> ); }