跳到正文
曾亮懿 ZengLiangYi Blog

React 事件订阅的稳定引用问题:从 useEffect 到 useEffectEvent

解决 WebSocket、EventEmitter 和 DOM 监听器因回调变化而反复订阅的问题,并厘清 useRef 代理与 React 19.2 useEffectEvent 的边界。

曾亮懿

曾亮懿

阅读约 4 分钟
目录 展开 收起

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]);
}

这里有三个容易混淆的规则:

  1. Effect Event 调用时能读取最新已提交的 props 和 state。
  2. 它不应放进 Effect 的依赖数组;当前 React Hooks lint 规则会对此给出提示。
  3. 它的函数 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。如果某个值变化后理应重新连接外部系统,它仍然应该留在依赖数组中。

相关代码与资料

分享:

从文章到代码

到我的 GitHub 看看 ChatCrystal、开发者工具和日常学习记录,欢迎交流实现细节。