
useEffectEvent()
useEffectEvent()
useEffectEvent 是一个 React Hook,它可以让你将 Effect 中的事件处理独立出来。
js
const onEvent = useEffectEvent(callback)
参考 {/reference/}
useEffectEvent(callback) {/useeffectevent/}
在组件的顶层调用 useEffectEvent 来声明一个 Effect Event。
js {4,6}
import { useEffectEvent, useEffect } from 'react';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('已连接!', theme);
});
}
Effect Event 是 Effect 逻辑的一部分,但它们的表现更像事件处理器。它们总是“看到”渲染中的最新值(如 props 和 state),而不会重新同步你的 Effect,因此它们被排除在 Effect 依赖项之外。请参阅 将事件与 Effect 分离 了解更多信息。
参数 {/parameters/}
callback:一个包含 Effect Event 逻辑的函数。该函数可以接受任意数量的参数,并返回任意值。当你调用返回的 Effect Event 函数时,callback始终访问调用时渲染提交的最新值。
返回值 {/returns/}
useEffectEvent 返回一个 Effect Event 函数,其类型签名与你的 callback 相同。
你可以在 useEffect、useLayoutEffect、useInsertionEffect 或同一组件中的其他 Effect Event 内部调用此函数。
为了防止在错误的上下文中调用 Effect Event,强制执行以下限制:
useEffectEvent是一个 Hook,因此你只能在组件顶层或你自己的 Hook 中调用它。你不能在循环或条件语句中调用它。如果需要,请提取一个新组件并将 Effect Event 移入其中。- Effect Event 只能从 Effect 或其他 Effect Event 内部调用。不要在渲染期间调用它们,也不要将它们传递给其他组件或 Hook。
eslint-plugin-react-hooks的 linter 会强制执行此限制。 - 不要使用
useEffectEvent来避免在 Effect 的依赖数组中指定依赖项。这会隐藏 bug,并使你的代码更难理解。只将它用于真正由 Effect 触发的事件逻辑。 - Effect Event 函数没有稳定的标识。它们的标识会在每次渲染时刻意改变。
为什么 Effect Event 不稳定? {/why-are-effect-events-not-stable/}
与 useState 或 ref 中的 set 函数不同,Effect Event 函数没有稳定的标识。它们的标识会在每次渲染时刻意改变:
js
// 🔴 错误:将 Effect Event 包含在依赖项中
useEffect(() => {
onSomething();
}, [onSomething]); // ESLint 会对此发出警告
这是一个深思熟虑的设计选择。Effect Event 只会在同一组件的 Effect 内部被调用。由于你只能在本地调用它们,不能将它们传递给其他组件或包含在依赖数组中,因此稳定的标识没有意义,反而会掩盖 bug。
不稳定的标识充当运行时断言:如果你的代码错误地依赖于函数标识,你会看到 Effect 在每次渲染时重新运行,从而使 bug 变得明显。
这种设计强化了 Effect Event 在概念上属于特定 Effect 的理念,它们不是用于选择退出响应式行为的通用 API。
用法 {/usage/}
在 Effect 中使用事件 {/using-an-event-in-an-effect/}
在组件顶层调用 useEffectEvent 来创建一个 Effect Event:
js [[1, 1, "onConnected"]]
const onConnected = useEffectEvent(() => {
if (!muted) {
showNotification('已连接!');
}
});
useEffectEvent 接收一个 事件回调,并返回一个
js [[1, 3, "onConnected"]]
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', onConnected);
connection.connect();
return () => {
connection.disconnect();
}
}, [roomId]);
由于 onConnected 是一个 muted 和 theme 不在 Effect 依赖项中。
不要使用 Effect Event 来跳过依赖项 {/pitfall-skip-dependencies/}
你可能会想用 useEffectEvent 来避免列出你认为“不必要”的依赖项。然而,这会隐藏 bug,并使你的代码更难理解:
js
// 🔴 错误:使用 Effect Event 隐藏依赖项
const logVisit = useEffectEvent(() => {
log(pageUrl);
});
useEffect(() => {
logVisit()
}, []); // 缺少 pageUrl 意味着你会错失日志
如果某个值应该导致你的 Effect 重新运行,请将其保留为依赖项。只对真正不应该重新触发 Effect 的逻辑使用 Effect Event。
参见 将事件与 Effect 分离 了解更多信息。
在定时器中使用最新值 {/using-a-timer-with-latest-values/}
当你在 Effect 中使用 setInterval 或 setTimeout 时,你通常希望读取渲染中的最新值,而不希望在这些值变化时重启定时器。
这个计数器每秒按当前的 increment 值递增 count。onTick Effect Event 会读取最新的 count 和 increment,而不会导致 interval 重新启动:
js
import { useState, useEffect, useEffectEvent } from 'react';
export default function Timer() {
const [count, setCount] = useState(0);
const [increment, setIncrement] = useState(1);
const onTick = useEffectEvent(() => {
setCount(count + increment);
});
useEffect(() => {
const id = setInterval(() => {
onTick();
}, 1000);
return () => {
clearInterval(id);
};
}, []);
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>
</>
);
}
css
button { margin: 10px; }
尝试在定时器运行时更改递增值。计数器会立即使用新的递增值,但定时器会保持平稳运行,不会重新启动。
在事件监听器中使用最新值 {/using-an-event-listener-with-latest-values/}
当你在 Effect 中设置事件监听器时,通常需要在回调中读取渲染中的最新值。如果没有 useEffectEvent,你需要在依赖项中包含这些值,这会导致监听器在每次变化时被移除并重新添加。
这个示例展示了一个跟随光标的圆点,但仅在选中“允许移动”时。onMove Effect Event 始终读取最新的 canMove 值,而不会重新运行 Effect:
js
import { useState, useEffect, 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,
}} />
</>
);
}
css
body {
height: 200px;
}
切换复选框并移动光标。圆点会立即响应复选框状态,但事件监听器只在组件挂载时设置一次。
避免重新连接外部系统 {/showing-a-notification-without-reconnecting/}
useEffectEvent 的一个常见用例是:你想响应 Effect 做某件事,但该“某件事”依赖于你不希望响应其变化的值。
在这个示例中,聊天组件连接到房间并在连接时显示通知。用户可以通过复选框静音通知。但是,你不希望每次用户更改设置时都重新连接到聊天室:
json package.json hidden
{
"dependencies": {
"react": "latest",
"react-dom": "latest",
"react-scripts": "latest",
"toastify-js": "1.12.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}
js
import { useState, useEffect, useEffectEvent } from 'react';
import { createConnection } from './chat.js';
import { showNotification } from './notifications.js';
function ChatRoom({ roomId, muted }) {
const onConnected = useEffectEvent((roomId) => {
console.log('✅ Connected to ' + roomId + ' (muted: ' + muted + ')');
if (!muted) {
showNotification('已连接到 ' + roomId);
}
});
useEffect(() => {
const connection = createConnection(roomId);
console.log('⏳ Connecting to ' + roomId + '...');
connection.on('connected', () => {
onConnected(roomId);
});
connection.connect();
return () => {
console.log('❌ Disconnected from ' + roomId);
connection.disconnect();
}
}, [roomId]);
return <h1>欢迎来到 {roomId} 房间!</h1>;
}
export default function App() {
const [roomId, setRoomId] = useState('general');
const [muted, setMuted] = useState(false);
return (
<>
<label>
选择聊天室:{' '}
<select
value={roomId}
onChange={e => setRoomId(e.target.value)}
>
<option value="general">综合</option>
<option value="travel">旅行</option>
<option value="music">音乐</option>
</select>
</label>
<label>
<input
type="checkbox"
checked={muted}
onChange={e => setMuted(e.target.checked)}
/>
静音通知
</label>
<hr />
<ChatRoom
roomId={roomId}
muted={muted}
/>
</>
);
}
js src/chat.js
const serverUrl = 'https://localhost:1234';
export function createConnection(roomId) {
// 真实的实现会连接到服务器
let connectedCallback;
let timeout;
return {
connect() {
timeout = setTimeout(() => {
if (connectedCallback) {
connectedCallback();
}
}, 100);
},
on(event, callback) {
if (connectedCallback) {
throw Error('不能添加两次处理器。');
}
if (event !== 'connected') {
throw Error('仅支持 "connected" 事件。');
}
connectedCallback = callback;
},
disconnect() {
clearTimeout(timeout);
}
};
}
js src/notifications.js
import Toastify from 'toastify-js';
import 'toastify-js/src/toastify.css';
export function showNotification(message, theme) {
Toastify({
text: message,
duration: 2000,
gravity: 'top',
position: 'right',
style: {
background: theme === 'dark' ? 'black' : 'white',
color: theme === 'dark' ? 'white' : 'black',
},
}).showToast();
}
css
label { display: block; margin-top: 10px; }
尝试切换房间。聊天会重新连接并显示通知。现在静音通知。由于 muted 是在 Effect Event 内部而不是在 Effect 中读取的,因此聊天保持连接状态。
在自定义 Hook 中使用 Effect Event {/using-effect-events-in-custom-hooks/}
你可以在自己的自定义 Hook 中使用 useEffectEvent。这让你可以创建可复用的 Hook,封装 Effects,同时保持某些值不具有响应性:
js
import { useState, useEffect, useEffectEvent } from 'react';
function useInterval(callback, delay) {
const onTick = useEffectEvent(callback);
useEffect(() => {
if (delay === null) {
return;
}
const id = setInterval(() => {
onTick();
}, delay);
return () => clearInterval(id);
}, [delay]);
}
function Counter({ incrementBy }) {
const [count, setCount] = useState(0);
useInterval(() => {
setCount(c => c + incrementBy);
}, 1000);
return (
<div>
<h2>计数:{count}</h2>
<p>每秒递增 {incrementBy}</p>
</div>
);
}
export default function App() {
const [incrementBy, setIncrementBy] = useState(1);
return (
<>
<label>
递增:{' '}
<select
value={incrementBy}
onChange={(e) => setIncrementBy(Number(e.target.value))}
>
<option value={1}>1</option>
<option value={5}>5</option>
<option value={10}>10</option>
</select>
</label>
<hr />
<Counter incrementBy={incrementBy} />
</>
);
}
css
label { display: block; margin-bottom: 8px; }
在这个示例中,useInterval 是一个设置 interval 的自定义 Hook。传递给它的 callback 被包装在 Effect Event 中,因此即使每次渲染都传入新的 callback,interval 也不会重置。
疑难解答 {/troubleshooting/}
我收到了一个错误:“包装在 useEffectEvent 中的函数不能在渲染期间调用” {/cant-call-during-rendering/}
此错误意味着你在组件的渲染阶段调用了 Effect Event 函数。Effect Event 只能在 Effect 或其他 Effect Event 内部调用。
js
function MyComponent({ data }) {
const onLog = useEffectEvent(() => {
console.log(data);
});
// 🔴 错误:在渲染期间调用
onLog();
// ✅ 正确:在 Effect 中调用
useEffect(() => {
onLog();
}, []);
return <div>{data}</div>;
}
如果你需要在渲染期间运行逻辑,请不要将其包装在 useEffectEvent 中。直接调用逻辑,或将其移入 Effect。
我收到了一个 lint 错误:“从 useEffectEvent 返回的函数不得包含在依赖数组中” {/effect-event-in-deps/}
如果你看到类似“从 useEffectEvent 返回的函数不得包含在依赖数组中”的警告,请从依赖项中移除 Effect Event:
js
const onSomething = useEffectEvent(() => {
// ...
});
// 🔴 错误:Effect Event 在依赖项中
useEffect(() => {
onSomething();
}, [onSomething]);
// ✅ 正确:依赖项中没有 Effect Event
useEffect(() => {
onSomething();
}, []);
Effect Event 被设计为从 Effect 中调用,而无需列为依赖项。linter 强制执行此规则,因为函数标识是刻意不稳定的。将其包含在依赖项中会导致你的 Effect 在每次渲染时重新运行。
我收到了一个 lint 错误:“... 是使用 useEffectEvent 创建的函数,只能从 Effect 中调用” {/effect-event-called-outside-effect/}
如果你看到类似“... 是使用 React Hook useEffectEvent 创建的函数,只能从 Effect 和 Effect Event 中调用”的警告,说明你在错误的位置调用了该函数:
js
const onSomething = useEffectEvent(() => {
console.log(value);
});
// 🔴 错误:从事件处理器中调用
function handleClick() {
onSomething();
}
// 🔴 错误:传递给子组件
return <Child onSomething={onSomething} />;
// ✅ 正确:从 Effect 中调用
useEffect(() => {
onSomething();
}, []);
Effect Event 被特别设计用于定义它们的组件本地的 Effect。如果你需要用于事件处理器或传递给子组件的回调,请改用普通函数或 useCallback。
帮助我们改进文档
发现翻译问题或内容错误?请告诉我们。
