React useEffectEvent 实战:我是怎么把一个越改越乱的 effect 拆开的

手里这段 effect,是我自己一点点改乱的。

它是一个带实时消息页面的连接逻辑:页面进入时建立连接,连接成功后弹通知,通知文案和样式又依赖当前主题和语言设置。这个功能最早很简单,后面边做边加,最后 effect 长成了一团。

最开始的版本只有连接逻辑:

1useEffect(() => {
2  const connection = createConnection(roomId);
3  connection.connect();
4  return () => connection.disconnect();
5}, [roomId]);

后来要加一个“连接成功提示”,于是 effect 里多了主题依赖;再后来国际化接进来,语言也进来了;再往后,页面切换静音状态时,回调里还要顺手读一遍用户设置。

代码还能跑,但问题越来越明显:只要主题切换、语言切换,连接就会跟着重建一次。功能上不是完全错,但体验和日志都开始变脏。开发时你会看到它反复 connect、disconnect,线上偶尔还会导致通知乱跳。

当时最难受的是:逻辑都“看起来有道理”

这类 effect 最麻烦的地方,不在于它明显报错,而在于每一项改动都能自圆其说。

主题确实在 effect 里用了,那依赖数组里放 theme 看上去没毛病。

语言也确实在回调里读了,那 locale 放进去似乎也合理。

可一旦这些值都放进去,effect 的同步边界就已经变了。原本它只该由 roomId 控制,现在任何跟通知相关的 UI 信息变化都会触发重连。

这种问题本质上不是依赖数组写错了,而是我把两类逻辑混在了一起:

  • 一类是“这个 effect 到底什么时候应该重新同步”
  • 另一类是“这个 effect 内部某个事件发生时,我希望读取到最新值”

这两件事不是一个层级,但以前我一直拿同一种写法在处理。

我一开始尝试过两个老办法,都不太舒服

第一个办法是继续把所有依赖都放齐,接受 effect 重跑。

这样 ESLint 最开心,代码也最“规矩”,但业务上不舒服。因为我不想让主题切换导致连接重建,这就是不符合语义。

第二个办法是把主题、语言这些值塞进 useRef

这确实能绕开闭包旧值问题,也能避免 effect 重跑。可写到后面代码会越来越像在自己维护一个影子状态系统:

  • 每次 render 同步 ref
  • 回调里读 ref.current
  • 还得提醒自己别漏字段

它不是不能用,但很难说优雅。尤其是多人协作时,别人看到 ref.current 往往得停一下,想想这段逻辑到底想规避什么。

useEffectEvent 解决的就是这层分工

我后来把那段代码改成了这种结构:

1const onConnected = useEffectEvent(() => {
2  showNotification('连接成功', theme);
3});
4
5useEffect(() => {
6  const connection = createConnection(roomId);
7
8  connection.on('connected', () => {
9    onConnected();
10  });
11
12  connection.connect();
13  return () => connection.disconnect();
14}, [roomId]);

改完之后,最大的变化不是代码更短,而是语义终于对了。

roomId 仍然决定连接什么时候重建。

themelocale 这种值,不再参与 effect 的同步边界,只在“连接成功这个事件真正发生时”读取最新值。

这一下把我之前一直拧巴的地方解开了。

这里说几个明确的行为事实(注意:useEffectEvent 目前还是 React 的实验性 API,只在 canary / experimental 版本里有,正式版尚未发布,名字和行为都可能变)。它的关键约定是:Effect Event 内部读到的 props/state 永远是最新的那次渲染的值,但这个函数本身不进 effect 的依赖数组,也不会触发 effect 重跑。这正好对应上面那条边界——theme 变了,通知文案会用新主题(因为读的是最新值),但连接不会因为 theme 重建。

而如果换成普通函数放进依赖数组,行为就完全不同:函数每次渲染都是新引用,依赖数组里有它,effect 每次渲染都判定“依赖变了”,于是 disconnect + connect 重订阅一遍——这正是我一开始那团乱码的来源。

“读到旧值 vs 最新值”的差别,用纯 JS 闭包就能看清,跟 React 无关:

1// 普通闭包:注册时把 theme 的当前值拍扁成快照,之后外部再变也读不到
2let theme = "light";
3function subscribeStale() {
4  const capturedTheme = theme; // 注册那一刻把 theme 的值锁进局部常量
5  return () => console.log("通知用的主题 =", capturedTheme);
6}
7const handler = subscribeStale(); // capturedTheme = "light"
8theme = "dark"; // 外部改了 theme,但 capturedTheme 是独立副本,纹丝不动
9handler(); // 通知用的主题 = light —— 旧值
10
11// 想读最新值,就别闭包住那个值,而是闭包住取值的入口
12const latest = { theme: "light" };
13function subscribeFresh() {
14  return () => console.log("通知用的主题 =", latest.theme);
15}
16const handler2 = subscribeFresh();
17latest.theme = "dark";
18handler2(); // 通知用的主题 = dark —— 最新值

useEffectEvent 在概念上就像第二种写法:effect 只注册一次(闭包住“取值入口”),而 Effect Event 保证每次调用时取的是最新一次渲染的 props/state。区别在于你不用自己维护那个 latest 对象——这正是它比手动 useRef 优雅的地方。

它最适合解决的是“effect 里的事件”

我现在对 useEffectEvent 的理解很简单:它不是一个省依赖的捷径,而是用来表达“effect 里发生的事件”。

这个事件有几个典型特点:

  • 不是用户点击按钮这种普通 UI 事件
  • 是 effect 内部注册、触发的回调
  • 回调里想读最新 props 或 state
  • 但这些值不该反过来决定 effect 是否重跑

聊天室连接成功通知、定时器自动保存、原生事件监听回调,这些都属于这个范围。

一次定时器问题让我更确信它有用

后来我在另一个编辑页里也碰到过类似问题。

页面每隔几秒自动保存草稿。最早写法是把 content 放进依赖数组,于是每次输入都会重新建一遍定时器。功能上没错,但逻辑读起来很奇怪。

如果把依赖去掉,又会拿到旧值。

改成 useEffectEvent 之后,这段代码终于回到了它该有的样子:effect 只负责创建和销毁定时器,定时器触发时再读最新内容。这个分层一旦清楚,后面任何人接手都容易理解。

大概可以写成这样:

1const onAutoSave = useEffectEvent(() => {
2  saveDraft({
3    content,
4    title,
5    updatedAt: Date.now()
6  });
7});
8
9useEffect(() => {
10  if (!enabled) return;
11
12  const timer = setInterval(() => {
13    onAutoSave();
14  }, 5000);
15
16  return () => clearInterval(timer);
17}, [enabled]);

这里 enabled 是同步条件。它决定定时器是否存在,所以应该进依赖数组。contenttitle 是定时器触发时要读取的最新值,但它们不应该导致定时器每次输入都重建。

这就是我觉得它比 useRef 更自然的地方:代码直接表达了“谁控制订阅,谁只是事件发生时读取”。

什么时候我不会用它

我现在也不会把 useEffectEvent 到处塞。

如果某个值真的会决定 effect 是否需要重新同步,那它就应该留在依赖数组里。这个判断不能偷懒。

比如:

  • roomId 变了,连接就该重建
  • url 变了,请求就该重新发
  • enabled 变了,订阅就该开关

这些都属于 effect 的同步条件,不该用 useEffectEvent 偷过去。

所以我现在会先问自己一句话:

“这个值是影响 effect 是否要重跑,还是只是希望某个回调触发时读到最新值?”

前者进依赖,后者才考虑 useEffectEvent

它不是用来逃避依赖数组的

这点我觉得必须反复提醒自己。

如果一个 effect 因为依赖太多而频繁重跑,第一反应不应该是“把依赖都塞进 useEffectEvent”。更应该先问:

  • 这个 effect 是不是做了太多事?
  • 订阅、请求、事件回调是不是混在一起?
  • 有没有一部分逻辑应该拆成普通事件处理?
  • 有没有一部分状态其实应该放回局部组件?

useEffectEvent 解决的是“effect 内部事件需要读最新值”,不是解决所有 effect 复杂度。

如果滥用它,代码会变成另一种难懂:依赖数组看起来很干净,但真正影响行为的状态藏在 Effect Event 里。那就只是把问题从依赖数组挪到了另一个地方。

排查 effect 时我现在会先分类

遇到一个越写越乱的 effect,我现在会先把里面的逻辑分三类:

1同步条件:决定 effect 什么时候建立或销毁
2事件回调:effect 建立后,外部事件触发时执行
3普通派生:可以在 render 阶段或事件处理里计算

只有第二类才是 useEffectEvent 的主要候选。

这个分类比直接看依赖数组更有效。很多 effect 难维护,不是因为依赖写错,而是三类逻辑本来就被写在了一起。

这类 API 真正让我有感的地方

以前写 React effect,我经常有一种感觉:逻辑明明都对,但总要在依赖数组、ref、eslint 规则之间来回妥协。

useEffectEvent 让我第一次觉得,React 是在正面承认这类问题确实存在,并给了一个更像“正经写法”的表达方式。

它不是为了炫新 API,而是把以前模模糊糊、只能靠经验处理的这层分工,正式写进了模型里。

如果一个 effect 越写越乱,通常不是因为你不会写 Hook,而是它里面混进了不属于同一层的逻辑。

我现在看 useEffectEvent,最重要的价值不是“更高级”,而是它逼着你把 effect 里的同步条件和内部事件拆开。一旦这两层拆清楚,代码会比以前稳很多,也更像人在维护,而不是在和依赖数组僵持。