陌上人如玉
公子世无双

2.16 useEffectEvent 与「读最新值而不重建」

2.16 useEffectEvent 与「读最新值而不重建」

一句话:useEffectEvent 解决的是一个长期无解的矛盾——「Effect 里需要读到最新的 props/state,但又不想因此重建副作用」;它返回一个「引用永远稳定、内部永远读到最新值、且只能从 Effect 里调用」的函数。

一、这个矛盾到底是什么

// 场景:连接聊天室,并在「收到消息」时记录「当前主题」与「当前消息数」
function ChatRoom({ roomId, theme, messageCount }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on('connected', () => {
      // ⚠️ 这里读到了 theme / messageCount —— 它们是「本次渲染的快照」
      showNotification('已连接', theme);
      logVisit(roomId, messageCount);
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);        // 依赖只写了 roomId
  // ...
}
⭐ 这段代码有两个选择,而两个选择都是错的:

① 依赖只写 [roomId](当前写法)
   ❌ 闭包陷阱:回调里读到的 theme / messageCount 永远是「首次渲染的值」
      → 切换主题后再连上,通知显示的还是旧主题
      → 消息数永远是 0

② 依赖写全 [roomId, theme, messageCount]
   ❌ 副作用被反复重建:
      · 主题一变 → 断开连接 + 重新连接(用户看到「正在连接…」闪烁)
      · 消息数一变 → 又断开重连(消息一多就疯狂重连)
      → 这显然不可接受

⭐ 结论:这不是「写错依赖数组」的问题,而是「依赖数组这个机制本身」
   无法表达「我要最新值,但不要因为它重建」这个诉求。
⭐ 为什么「加依赖会导致重建」?—— 因为 Effect 的语义是「同步」:
   依赖变了 = 「需要重新与外部系统对齐」= 先清理旧的、再建立新的。
   所以「把 theme 写进依赖」的语义就是「主题变化时重新连接」,
   而这在业务上毫无意义。

⭐ 所以社区的诉求是:「给我一个『能读最新值、但不参与依赖比较』的东西」。
   React 官方的答案是 useEffectEvent(原提案名 useEvent)。

二、useEffectEvent 的语义

import { useEffectEvent } from 'react';       // ⚠️ React 19 中仍是实验特性(canary/experimental)

function ChatRoom({ roomId, theme, messageCount }) {
  // ⭐ 把「需要读最新值」的逻辑抽成 Effect Event
  const onConnected = useEffectEvent(() => {
    showNotification('已连接', theme);          // 永远读到最新 theme
    logVisit(roomId, messageCount);            // 永远读到最新 messageCount
  });

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on('connected', onConnected);   // ⭐ onConnected 不写进依赖(它引用稳定)
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);                                 // 依赖干净:只有 roomId
  // ...
}
⭐⭐ 三条核心规则(必须背下来):

① 【只能从 Effect 里调用】
   ✅ useEffect / useLayoutEffect / useInsertionEffect 内
   ❌ 渲染期间、事件处理函数、其它普通函数里

② 【永远不能写进依赖数组】
   它「不是响应式的」(not reactive)——
   官方明确说:Effect Events 必须永远从 Effect 的依赖里省略。

③ 【永远不要传给其它组件或 Hook】
   它不是普通的回调 props——它是「Effect 内部的私有工具」。
   传给子组件会破坏「渲染是 props 的纯函数」这个不变量。
⭐ 与「普通函数」的本质区别(一句话抓住):

   普通函数:**闭包 = 创建它那次渲染的快照**(所以会 stale)
   Effect Event:**闭包 = 调用时的最新值**(所以不会 stale)

   它在「语义上」等价于「用 useRef 保存了最新实现的那个函数」,
   但它是「官方承认的、lint 认识的、语义正确的」版本。

三、它和 useRef 模式是同一件事

// ✅ 现在就能用的等价实现(社区标准写法,叫 useEvent)
import { useCallback, useRef, useInsertionEffect } from 'react';

export function useEvent(
  handler: (...args: Args) => R
): (...args: Args) => R {
  const handlerRef = useRef(handler);

  // ⭐⭐ 关键细节:用 useInsertionEffect 而不是 useEffect 来更新 ref
  useInsertionEffect(() => {
    handlerRef.current = handler;
  });

  return useCallback((...args: Args) => handlerRef.current(...args), []);
}
⭐⭐ 为什么必须用 useInsertionEffect 更新 ref?(这是最容易被忽略的细节)

   Effect 的执行顺序:useInsertionEffect(DOM 变更前)→ useLayoutEffect(绘制前)→ useEffect(绘制后)

   场景:如果在 useLayoutEffect 里调用 Effect Event
     ① 若 ref 用 useEffect 更新(绘制后):
        调用时 ref.current 还是「上一轮渲染的 handler」→ ⚠️ 读到旧值
     ② 若 ref 用 useInsertionEffect 更新(更早):
        调用时 ref.current 已经是「本轮 render 的 handler」→ ✅ 读到最新值

   → React 官方的 useEffectEvent 内部也是用 insertion effect 更新 ref 的。
   → 这就是「为什么它能让 layout effect 里也拿到最新值」的原因。
// ✅ 完整可运行版:带类型 + 稳定性保证 + 使用示例
import { useCallback, useRef, useInsertionEffect } from 'react';

/** 返回一个「引用稳定 + 永远读到最新值」的函数(Effect Event 的等价实现) */
export function useEvent(
  handler: (...args: Args) => R
): (...args: Args) => R {
  const handlerRef = useRef(handler);

  useInsertionEffect(() => {
    handlerRef.current = handler;
  });

  // ⭐ 依赖为空 → 引用永久稳定;调用时读 ref → 永远是最新实现
  return useCallback((...args: Args) => handlerRef.current(...args), []);
}

四、完整实战:一个「三合一」的场景验证

下面的例子把三个典型场景放在一起,用来验证 useEvent 是否真的解决了两难的依赖问题。

// components/RoomPanel.tsx —— 150+ 行完整实现
import { useCallback, useEffect, useRef, useState, useInsertionEffect } from 'react';

/** ============ 1. useEvent(Effect Event 的等价实现) ============ */
function useEvent(handler: (...args: Args) => R): (...args: Args) => R {
  const handlerRef = useRef(handler);
  useInsertionEffect(() => {
    handlerRef.current = handler;
  });
  return useCallback((...args: Args) => handlerRef.current(...args), []);
}

/** ============ 2. 可动态调整的定时器(读最新回调,不重建) ============ */
function useInterval(callback: () => void, delay: number | null) {
  const onTick = useEvent(callback);           // ⭐ 回调不进依赖

  useEffect(() => {
    if (delay === null) return;
    const id = setInterval(() => onTick(), delay);
    return () => clearInterval(id);
  }, [delay, onTick]);                          // onTick 稳定 → 实际只随 delay 重建
}

/** ============ 3. 事件监听(读最新 state,不重复绑定) ============ */
function useKeyboardShortcut(key: string, onPress: (e: KeyboardEvent) => void) {
  const onKeyDown = useEvent(onPress);

  useEffect(() => {
    const listener = (e: KeyboardEvent) => {
      if (e.key === key && !e.metaKey && !e.ctrlKey) onKeyDown(e);
    };
    window.addEventListener('keydown', listener);
    return () => window.removeEventListener('keydown', listener);
  }, [key, onKeyDown]);                         // ⭐ 只在 key 变化时重新绑定
}

/** ============ 4. WebSocket 订阅(读最新筛选条件,不重连) ============ */
interface RoomMessage {
  id: string;
  roomId: string;
  text: string;
  tag: string;
}

function useRoomSubscription(roomId: string, filterTags: string[], onMessage: (m: RoomMessage) => void) {
  const handledMessage = useEvent(onMessage);
  const [status, setStatus] = useState<'connecting' | 'open' | 'closed'>('connecting');

  useEffect(() => {
    setStatus('connecting');
    const socket = new WebSocket(`wss://example.com/rooms/${roomId}`);

    socket.onopen = () => setStatus('open');
    socket.onclose = () => setStatus('closed');
    socket.onmessage = (event) => {
      const msg = JSON.parse(event.data) as RoomMessage;
      // ⚠️ 这里用了 filterTags —— 如果写进依赖,筛选一变就会「断开重连」
      //    用 Effect Event 后:连接不变,筛选条件永远最新
      if (filterTags.length === 0 || filterTags.includes(msg.tag)) {
        handledMessage(msg);
      }
    };

    return () => socket.close();
  }, [roomId]);                                  // 依赖干净:只随房间切换而重连

  return status;
}

/** ============ 5. 把它们组装成一个面板 ============ */
export function RoomPanel({ roomId }: { roomId: string }) {
  const [messages, setMessages] = useState([]);
  const [filterTags, setFilterTags] = useState([]);
  const [seconds, setSeconds] = useState(0);
  const [showOnlyMine, setShowOnlyMine] = useState(false);

  // ① 消息订阅:连接只随 roomId 变化
  const status = useRoomSubscription(roomId, filterTags, (msg) => {
    setMessages((prev) => [...prev, msg]);
  });

  // ② 计时器:每秒 +1(回调里读的是最新实现,定时器不会被重建)
  useInterval(() => setSeconds((s) => s + 1), status === 'open' ? 1000 : null);

  // ③ 快捷键:Esc 清空筛选(回调读最新 filterTags)
  useKeyboardShortcut('Escape', () => {
    if (filterTags.length > 0) setFilterTags([]);
    else setMessages([]);
  });

  // ④ 渲染时用「最新状态」计算(不影响上面两个 Effect 的生命周期)
  const visible = showOnlyMine ? messages.filter((m) => m.text.includes('我')) : messages;

  return (
    
连接状态:{status} 已连接 {seconds} 秒
{['公告', '闲聊', '求助'].map((tag) => ( ))}
    {visible.map((m) => (
  • {m.tag} {m.text}
  • ))}
); }
⭐⭐ 在这个例子里,如果没有 useEvent,会出现什么?

① 消息订阅(filterTags)
   写进依赖 → 每次点标签筛选,WebSocket 就「断开 + 重连」(服务端连接数暴涨、消息丢失)
   不写依赖 → 筛选永远是「首次渲染的空数组」→ 筛选功能完全失效

② 计时器(setSeconds 里的 s)
   这里用的是函数式更新,所以没问题;
   但如果写成 setSeconds(seconds + 1) 且依赖为空 → 秒数永远是 1

③ 快捷键(filterTags / messages)
   写进依赖 → 每次消息来都重新绑定键盘监听(高频重绑)
   不写依赖 → Esc 永远按首次的 filterTags 判断

→ useEvent 让「三个 Effect 的依赖」都退化成「业务上真正的重建条件」
  (roomId、delay、key),而不是「回调里用到的每一个值」。

五、与 useCallback + 依赖的关系

// ❌ 用 useCallback + 全依赖:引用会变 → 副作用重建
const onMessage = useCallback((m) => {
  if (filterTags.includes(m.tag)) append(m);
}, [filterTags]);                     // filterTags 变 → onMessage 变 → 订阅重建

useEffect(() => {
  socket.onmessage = onMessage;
}, [onMessage]);                      // ⚠️ 随 filterTags 重建
// ✅ 用 Effect Event:引用不变 → 副作用不重建
const onMessage = useEvent((m) => {
  if (filterTags.includes(m.tag)) append(m);   // 永远读最新 filterTags
});

useEffect(() => {
  socket.onmessage = onMessage;
}, [onMessage]);                      // ⭐ 引用稳定 → 只随「真正的依赖」重建
维度 useCallback(fn, [deps]) useEvent(fn)
引用稳定性 依赖变化时会变 永远不变
闭包内容 创建它那次渲染的快照 调用时的最新值
是否需要写依赖 ✅ 必须写全 ❌ 永远不写
调用位置限制 无 只能从 Effect 里调用
能否传给子组件 ✅(这是它的常见用途) ❌(语义不允许)
典型用途 稳定 props 引用(配合 memo) Effect 内部读最新值
⭐ 一句话区分:
   useCallback 解决「**给子组件的 props 引用稳定**」
   useEvent    解决「**给 Effect 的回调读到最新值**」
   两者目的不同,不能互相替代。

六、为什么「不能传给子组件」

// ❌ 反例:把 Effect Event 当回调 props 传下去
function Parent() {
  const onSelect = useEvent((id: string) => {
    setSelected(id);
    log('selected', selectedCount);        // ⚠️ 读的是「最新值」
  });

  return ;
}

function Child({ onSelect }: { onSelect: (id: string) => void }) {
  // ⚠️ 这里在「渲染」里使用了它(比如算出一个依赖它的值)
  const handle = useMemo(() => () => onSelect('x'), [onSelect]);
  // ...
}
⭐⭐ 为什么不允许?

因为 Effect Event 的语义是「**读取最新值的私有通道**」,
它「脱离了 React 的响应式追踪」。

如果它被传给子组件并在「渲染期」被使用,就会出现:
   一次渲染的输出,取决于「调用它那一刻的 ref 内容」,
   而不是「这次渲染的 props/state」。
   → 破坏「UI = f(props, state)」这个不变量
   → 出现「同样的 props 渲染出不同结果」的现象(在并发渲染下更危险)

⭐ 所以规则是:
   Effect Event **属于「副作用的世界」,不属于「渲染的世界」**。
   渲染世界的东西用 useCallback(并可传给子组件);
   副作用世界的东西用 useEvent(且只留在 Effect 里)。

七、三条硬性约束与失败路径

// ① ❌ 在渲染期间调用
function Bad1({ roomId }: { roomId: string }) {
  const onVisit = useEvent(() => logVisit(roomId));

  onVisit();                                  // ❌ 渲染期间调用 Effect Event
  return 
; // ⚠️ 语义错误:这等于「在渲染里做副作用」,而且它不受依赖控制 }
// ② ❌ 在事件处理里调用
function Bad2() {
  const onSave = useEvent(() => save(latestDraft));

  return ;
  // ❌ Effect Event 不是给事件用的——
  //    事件处理里应该直接用当前的 state/props(它们本来就是最新的)
}
// ③ ❌ 写进依赖数组
useEffect(() => {
  const id = setInterval(onTick, 1000);
  return () => clearInterval(id);
}, [onTick]);        // ⚠️ 虽然「因为引用稳定所以无害」,但语义上是错的
// ✅ 官方要求:Effect Event 永远从依赖里省略
useEffect(() => {
  const id = setInterval(onTick, 1000);
  return () => clearInterval(id);
}, []);
⭐ 三条约束的记忆方法(三个世界):

   渲染世界  → props / state / useMemo / useCallback / 子组件
   事件世界  → onClick / onChange 里的处理函数(读当次渲染的值即可)
   副作用世界 → Effect 内部 / useEffectEvent(读最新值)

   Effect Event 是「副作用世界」的居民,
   它【不能进入渲染世界】(不能传给子组件、不能在渲染中调用),
   也【不需要进入事件世界】(事件里本来就是最新值)。

八、ESLint 与依赖数组的关系

// 有了 useEffectEvent 后,exhaustive-deps 的行为变化

useEffect(() => {
  const connection = createConnection(roomId);
  connection.on('connected', () => {
    showNotification('已连接', theme);       // ⚠️ theme 未在依赖里 → lint 警告
  });
  return () => connection.disconnect();
}, [roomId]);
// ✅ 用 Effect Event 后,lint 不再要求把 theme 写进依赖
const onConnected = useEffectEvent(() => {
  showNotification('已连接', theme);         // ⭐ lint 认识它:内部读的值不算 Effect 的依赖
});

useEffect(() => {
  const connection = createConnection(roomId);
  connection.on('connected', onConnected);
  return () => connection.disconnect();
}, [roomId]);                                  // ✅ 无警告
⭐ 这是它相比「自研 useEvent」的一个实际优势:
   自研版本(用 useRef + useInsertionEffect)虽然「运行时行为一致」,
   但 lint 不认识它——如果你在 Effect 里直接写内联函数读 theme,
   警告依然存在(你必须用「把函数定义在 Effect 内部」或「disable 注释」来消警告)。

⚠️ 但注意:自研 useEvent 版本可以这样写(把逻辑放进 useEvent,Effect 里只用稳定引用),
   此时 lint 只看到「onConnected 是稳定引用」→ 也不再警告。
   → 所以「运行时等价」,差别在「语义表达力」与「官方支持」。

九、迁移路径与现状

⭐ 现状(截至 React 19):
   · `useEffectEvent` 仍处于【实验阶段】(experimental / canary 通道)
   · 它的前身叫 `useEvent`(在 2022 年被改名,因为社区对语义有争议)
   · 稳定版本发布前,生产环境应使用「useRef 保存最新回调」的模式

⭐ 三种可用方案的取舍:

| 方案 | 运行时行为 | lint 友好 | 生产可用 | 语义表达 |
| --- | --- | --- | --- | --- |
| 自研 useEvent(useRef + useInsertionEffect) | ✅ 与官方一致 | ⚠️ 部分 | ✅ | 中 |
| Effect Event(实验) | ✅ 官方实现 | ✅ | ❌ 需 canary | ✅ 最强 |
| 直接把函数定义在 Effect 内部 | ✅ | ✅ | ✅ | ⚠️ 只在「不需要复用」时可用 |
| eslint-disable + 空依赖 | ⚠️ 易错 | ❌ | ✅ | 最差 |

⭐ 迁移建议:
   ① 现在:统一用自研 useEvent(封装在一个文件里,将来只改一处)
   ② 等 useEffectEvent 稳定:把实现切换成官方版本(对外 API 不变)
   ③ 同时清理历史遗留的 `eslint-disable-next-line react-hooks/exhaustive-deps`
// ✅ 推荐的落地结构:一个「Effect Event 适配层」
// hooks/use-event.ts
import { useCallback, useRef, useInsertionEffect } from 'react';

/**
 * Effect Event 的适配层。
 * 等 React 的 useEffectEvent 稳定后,只需把这里换成:
 *   import { useEffectEvent } from 'react';
 *   export const useEvent = useEffectEvent;
 * 业务代码不需要改动。
 */
export function useEvent(handler: (...args: Args) => R): (...args: Args) => R {
  const handlerRef = useRef(handler);
  useInsertionEffect(() => {
    handlerRef.current = handler;
  });
  return useCallback((...args: Args) => handlerRef.current(...args), []);
}

十、什么时候不该用它

⭐ 三条「先别用」的判断:

① 如果「重建副作用」在业务上就是对的 → 直接写依赖
   例:请求要随 keyword 变化 → useEffect(() => { fetch(keyword) }, [keyword])
        (这里 keyword 变化时「重新请求」正是你想要的行为)

② 如果「不需要最新值」→ 不需要它
   例:只在挂载时上报一次 → useEffect(() => { track('mount') }, [])
        (回调里不读任何会变的值,自然没有 stale 问题)

③ 如果能「把函数定义在 Effect 内部」→ 优先这么做(更简单、没有额外抽象)
   useEffect(() => {
     function handle() { console.log(roomId); }   // ⭐ 依赖变成 roomId(清晰)
     window.addEventListener('keydown', handle);
     return () => window.removeEventListener('keydown', handle);
   }, [roomId]);

⭐ 它真正的适用场景只有一类:
   「回调需要读到最新值,而这个值【不应该】成为重建副作用的理由」。
   —— 典型即「订阅/连接/定时器的回调里读 UI 状态」。

十一、本篇特有的坑

// ① 用 useEffect(而不是 useInsertionEffect)更新 ref → layout effect 里读到旧值
function useEventWrong(handler: (...a: A) => R) {
  const ref = useRef(handler);
  useEffect(() => { ref.current = handler; });        // ⚠️ 太晚
  return useCallback((...a: A) => ref.current(...a), []);
}
// 现象:在 useLayoutEffect 里调用它时,拿到的是「上一轮渲染」的 handler
// ✅ 改成 useInsertionEffect

// ② 把 Effect Event 写进依赖数组(语义错误,虽无害)
}, [onTick]);         // ⚠️ 官方要求省略

// ③ 在渲染期间调用 Effect Event
onVisit();            // ❌

// ④ 把 Effect Event 传给子组件
     // ❌

// ⑤ 用 Effect Event「读最新值」去做「本该重新请求」的事
const onSubmit = useEffectEvent(() => fetch(`/api?q=${keyword}`));  // ⚠️ 用错场景
useEffect(() => { onSubmit(); }, []);                 // keyword 变了却不请求
// ✅ 应该把 keyword 写进依赖

// ⑥ 在 Effect Event 里「做同步的状态更新」导致无限循环
const onTick = useEffectEvent(() => setCount(count + 1));   // ⚠️ 每次 tick 都基于最新 count
// ⚠️ 虽然不会 stale,但如果是「在 Effect 里直接调用它」就会形成循环
// ✅ 用函数式更新 setCount(c => c + 1)

// ⑦ 自研版本用「useState 存 handler」→ 每次渲染都多一次 re-render
const [handler, setHandler] = useState(() => fn);     // ❌ 用 ref
useEffect(() => { setHandler(() => fn); });           //    会触发额外渲染

// ⑧ 自研版本「每次渲染都重建返回函数」(忘了 useCallback)
return (...args) => handlerRef.current(...args);      // ❌ 引用不稳定 → 副作用仍会重建

// ⑨ 依赖数组里同时写 onTick 和「它读的值」→ 等于没用 Effect Event
}, [onTick, filterTags]);                             // ⚠️ 白做

// ⑩ 用 Effect Event「绕过」lint 警告,但实际上是自己写错了依赖
//    例:请求类 Effect 里用 Effect Event 读参数 → 掩盖了「应该重新请求」的语义错误

// ⑪ 在「非 Effect 的自定义 Hook」里返回 Effect Event 给调用方使用
//    ⚠️ 如果不加限制,调用方可能在渲染里用它(违反语义)

// ⑫ 认为「Effect Event 能解决所有 stale closure」
//    ⚠️ 它只解决「Effect 内部」的 stale;
//       事件处理里的 stale 要靠「函数式更新」或「依赖修正」

十二、面试延伸

  1. 「useEffectEvent 解决什么问题?」
    它解决「Effect 里需要读最新值,但不想因此重建副作用」这个矛盾。典型场景是「订阅 / 连接 / 定时器的回调里读 UI 状态」:把 theme 写进依赖会导致「主题一变就重连」(副作用被无谓重建),不写依赖又会「读到旧值」(闭包陷阱)。useEffectEvent 返回的函数「引用永远稳定 + 内部永远读到最新值 + 只能从 Effect 里调用」,从而让 Effect 的依赖数组退化成「业务上真正的重建条件」。

  2. 「它和 useCallback 有什么区别?」
    两者解决不同问题:① useCallback 用于「给子组件的 props 提供稳定引用」(配合 React.memo)——它的闭包内容依然是「创建它那次渲染的快照」,所以必须写全依赖,依赖一变引用就变;② useEffectEvent 用于「Effect 内部读最新值」——它的引用永远不变,闭包内容永远是调用时的最新值,且永远不写进依赖。此外 useEffectEvent 只能从 Effect 里调用(不能传子组件、不能在渲染中用)。

  3. 「为什么要用 useInsertionEffect 更新 ref,而不是 useEffect?」
    因为 Effect 的执行顺序是 useInsertionEffect(DOM 变更前)→ useLayoutEffect(绘制前)→ useEffect(绘制后)。如果 ref 用 useEffect 更新,那么在 useLayoutEffect 里调用这个 Effect Event 时,ref 里还是「上一轮渲染的 handler」(读到旧值);用 useInsertionEffect 更新则能保证「在任何 Effect 里调用都能拿到本轮最新值」。这也是 React 官方 useEffectEvent 的内部实现方式。

  4. 「为什么它不能传给子组件?」
    因为它的语义是「读取最新值的私有通道」,它脱离了 React 的响应式追踪。如果传给子组件并在「渲染期」使用,一次渲染的输出就会取决于「调用它那一刻的 ref 内容」,而不是「这次渲染的 props/state」——破坏了「UI = f(props, state)」这个不变量(并发渲染下更危险:可能渲染出与 props 不一致的结果)。所以规则是「Effect Event 属于副作用的世界,不属于渲染的世界」。

  5. 「它现在能用在生产环境吗?」
    截至 React 19,useEffectEvent 仍处于实验阶段(canary/experimental),生产环境不应用它。替代方案是自研 useEvent:用 useRef 保存最新 handler(在 useInsertionEffect 里更新),再用 useCallback(() => ref.current(...args), []) 返回稳定引用。它的运行时行为与官方版本一致(连「用 insertion effect 更新 ref」这个细节都一致),差别只在「lint 是否认识」和「语义表达力」。

  6. 「加了它还需要 exhaustive-deps 吗?」
    需要,但警告会变少:有了 Effect Event 后,那些「回调里读的、但不应作为依赖的值」可以放进 Effect Event 内部,lint 不会再要求把它们写进依赖数组。注意两点:① 自研的 useEvent lint 不认识(它只知道 useEffectEvent),但你只要「在 Effect 里使用稳定引用」就不会触发警告;② 不要用它来掩盖「应该重新请求」的语义错误——例如「请求类 Effect 用 Effect Event 读 keyword」,那会让「参数变化时不重新请求」,是真正的 bug。

  7. 「什么时候不该用它?」
    三种情况:① 重建副作用在业务上就是对的——如「keyword 变化要重新请求」,直接写依赖即可;② 回调里不需要最新值——如「挂载时上报一次」,没有 stale 问题;③ 能把函数定义在 Effect 内部——那就直接定义(依赖会自然变成函数用到的值,更简单、没有额外抽象)。它真正的适用场景只有一类:「回调需要读最新值,而这个值不应该成为重建副作用的理由」。

  8. 「它和 useRef 保存最新值是什么关系?」
    本质是同一件事,但 useEffectEvent 更「语义化」:useRef 方案(useEffect(() => { ref.current = fn }))是「手动的、lint 不认识的、容易被用错的」;useEffectEvent 是「官方承认的、lint 认识的、有明确调用边界的」(只能 Effect 里用、不写依赖、不传子组件)。关系是「同一种机制的『原始版』与『规范版』」——useEffectEvent 内部就是用 insertion effect 更新 ref 实现的。

  9. 「如果我有十个 Effect Event,会有性能问题吗?」
    没有明显开销:每个 Effect Event 只是「一个 ref + 一个稳定的 useCallback」,渲染期的工作量是「一次 ref 赋值」(在 insertion effect 里)。真正的成本是「抽象层级」——如果在一个组件里放十个 Effect Event,说明这个组件的副作用很多,应该考虑「拆分成多个自定义 Hook」(每个 Hook 封装一类副作用 + 它的 Effect Event)。所以「Effect Event 的数量」更多是「设计信号」,而不是「性能问题」。

  10. 「useEffect 里的闭包问题,有了它就不用管了吗?」
    不是。Effect Event 只解决「Effect 内部回调读最新值」这一类;闭包陷阱还有另外两个来源:① 事件处理函数里的 stale——但事件处理函数的生命周期就是「本次渲染」,通常不需要处理(除非它被传给了 setTimeout / 第三方库缓存);② useCallback / useMemo 里的 stale——那要靠「写全依赖」解决。所以「Effect Event 是补充手段,不是替代品」;主路径依然是「写全依赖」。

一句话速记

useEffectEvent 解决「Effect 里要读最新值但不想重建副作用」——它返回一个「引用永久稳定 + 内部永远是最新值 + 只能从 Effect 里调用」的函数;三条铁律:「只从 Effect 里调用、永不写进依赖、永不传给子组件」(因为它属于「副作用世界」,进入渲染世界会破坏 UI = f(props, state));它与 useCallback 目的不同(后者是「给子组件的稳定 props」,必须写全依赖);可用自研 useEvent 等价替代——关键是「用 useInsertionEffect(不是 useEffect)更新 ref」,否则在 useLayoutEffect 里会读到上一轮的 handler;React 19 中仍是实验特性(建议做一层「适配层」,等官方稳定后只改一处);别用它掩盖「本该重新请求」的语义错误——能写依赖就写依赖,能把函数放进 Effect 就放进去。

赞(0) 打赏
未经允许不得转载:陌上寒 » 2.16 useEffectEvent 与「读最新值而不重建」

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫