TL;DR: 在 React 中订阅 WebSocket、EventEmitter 或 DOM 事件时,把不断变化的 handler 放进 Effect 依赖会造成反复 subscribe/unsubscribe。可以用一个固定的代理函数调用 handlerRef.current,把“订阅生命周期”和“读取最新状态”分开。React 19.2 的 useEffectEvent 为 Effect 内的非响应式逻辑提供了官方表达方式,但它不是通用的稳定回调 API。
本文假设你理解
useEffect的依赖数组、闭包和函数引用。
问题:监听器需要同一个函数引用
下面这段代码注册了一个内联监听器,却无法在清理阶段拿回相同的函数:
function ChatPanel() {
const [messages, setMessages] = useState<Message[]>([]);
useEffect(() => {
socket.on('message:new', (message) => {
setMessages((previous) => [...previous, message]);
});
return () => {
socket.off('message:new' /* 这里需要注册时的同一个函数 */);
};
}, []);
}
socket.off 和 removeEventListener 都要求传入注册时的同一个函数对象。重新写一个内容相同的箭头函数也没有用,因为引用不同。
把 handler 提出来能解决清理问题:
function useSocketEvent(event: string, onMessage: (message: Message) => void) {
useEffect(() => {
socket.on(event, onMessage);
return () => socket.off(event, onMessage);
}, [event, onMessage]);
}
但如果父组件每次渲染都创建新的 onMessage,Effect 就会先取消订阅再重新订阅。高频更新时,这会带来不必要的 I/O,还可能在切换窗口中漏掉事件。
心理模型:固定的代理人
需要稳定的不是业务 handler,而是交给外部系统的代理函数:
WebSocket
↓
stableHandler(订阅期间引用不变)
↓
handlerRef.current(指向最新 handler)
WebSocket 始终认识同一个代理人;代理人每次被调用时,再去找当前最新的业务逻辑。
手写方案:用两个 Effect 分离职责
import { useEffect, useRef } from 'react';
export function useStableHandler<T>(
subscribe: (handler: (payload: T) => void) => () => void,
handler: (payload: T) => void
): void {
const handlerRef = useRef(handler);
// 只同步最新业务逻辑,不执行外部 I/O。
useEffect(() => {
handlerRef.current = handler;
}, [handler]);
// 只管理订阅生命周期。
useEffect(() => {
const stableHandler = (payload: T) => {
handlerRef.current(payload);
};
return subscribe(stableHandler);
}, [subscribe]);
}
两个 Effect 的职责不同:
| Effect | 职责 | 依赖 | 成本 |
|---|---|---|---|
| 同步 Effect | 更新 handlerRef.current |
handler |
一次内存赋值 |
| 订阅 Effect | subscribe/unsubscribe | subscribe |
外部系统 I/O |
handler 可以在每次渲染时变化;只要 subscribe 稳定,外部订阅就不会重建。
为什么不在渲染期间更新 ref
常见写法会直接在组件函数体中赋值:
const handlerRef = useRef(handler);
handlerRef.current = handler;
它通常看起来可以工作,但 React 文档明确建议:除初始化外,不要在渲染期间读写 ref.current。渲染应当保持纯净,Strict Mode 还会在开发环境中额外调用组件函数,以暴露意外的副作用。
把赋值放进 Effect,意味着只有已经提交的渲染才会更新 ref。代价是 commit 完成到被动 Effect 执行之间存在一个很短的窗口;如果外部事件恰好在这个窗口触发,代理可能看到上一次提交的 handler。对必须在浏览器绘制前完成同步的场景,可以评估 useLayoutEffect,但大多数网络和 DOM 订阅并不需要为此升级为布局 Effect。
最容易踩的坑:subscribe 也必须稳定
这个 Hook 把稳定性的责任移到了 subscribe。如果调用处传入内联函数,订阅 Effect 仍会每次重跑:
// ❌ subscribe 每次渲染都是新函数
useStableHandler(
(eventHandler) => {
socket.on('message:new', eventHandler);
return () => socket.off('message:new', eventHandler);
},
(message) => setMessages((previous) => [...previous, message])
);
用 useCallback 稳定订阅函数:
const subscribe = useCallback(
(eventHandler: (message: Message) => void) => {
socket.on('message:new', eventHandler);
return () => socket.off('message:new', eventHandler);
},
[socket]
);
useStableHandler(subscribe, (message) => {
setMessages((previous) => [...previous, message]);
});
业务 handler 不需要 useCallback;这正是代理模式的价值。
React 19.2:useEffectEvent
React 19.2 提供了 useEffectEvent,用于把 Effect 中“不应触发重新同步、但需要读取最新值”的逻辑提取为 Effect Event:
import { useEffect, useEffectEvent } from 'react';
export function useStableHandler<T>(
subscribe: (handler: (payload: T) => void) => () => void,
handler: (payload: T) => void
): void {
const onEvent = useEffectEvent(handler);
useEffect(() => {
return subscribe(onEvent);
}, [subscribe]);
}
这里有三个容易混淆的规则:
- Effect Event 调用时能读取最新已提交的 props 和 state。
- 它不应放进 Effect 的依赖数组;当前 React Hooks lint 规则会对此给出提示。
- 它的函数 identity 并不稳定,React 甚至会有意在每次渲染时改变它。因此不要把
useEffectEvent当作通用的useCallback替代品。
Effect Event 只能从 Effect、Layout Effect、Insertion Effect或同一组件内的其他 Effect Event 调用。不要在渲染期间调用,也不要传给子组件。上面的代码符合这个边界:外部订阅在 Effect 中建立,Effect Event 只作为这个 Effect 的事件逻辑使用。
实际用例
Socket.IO 消息监听
function ChatPanel({ conversationId }: { conversationId: string }) {
const [messages, setMessages] = useState<Message[]>([]);
const subscribe = useCallback((eventHandler: (message: Message) => void) => {
socket.on('message:new', eventHandler);
return () => socket.off('message:new', eventHandler);
}, []);
useStableHandler(subscribe, (message) => {
if (message.conversationId === conversationId) {
setMessages((previous) => [...previous, message]);
}
});
return <MessageList messages={messages} />;
}
conversationId 更新时,handler 会看到新值,但 Socket.IO 监听器不需要重建。
原生 resize 监听
function useWindowWidth() {
const [width, setWidth] = useState(() => window.innerWidth);
const subscribe = useCallback((eventHandler: () => void) => {
window.addEventListener('resize', eventHandler);
return () => window.removeEventListener('resize', eventHandler);
}, []);
useStableHandler(subscribe, () => {
setWidth(window.innerWidth);
});
return width;
}
如何选择
| 方案 | 适合情况 | 注意点 |
|---|---|---|
| 直接把 handler 放进依赖 | 重订阅成本低,且依赖变化本就应重建连接 | 实现最直接 |
useRef 代理 |
需要兼容较早 React,或封装通用订阅 Hook | subscribe 必须稳定 |
useEffectEvent |
React 19.2+,逻辑确实属于 Effect Event | 不是依赖逃生舱,也不是稳定回调 |
不要为了消除依赖而使用 Effect Event。如果某个值变化后理应重新连接外部系统,它仍然应该留在依赖数组中。