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

0 阅读

我第一次觉得 useEffectEvent 真有必要,不是因为看了官方示例,而是因为我手里有一段 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 的同步边界,只在“连接成功这个事件真正发生时”读取最新值。

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

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

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

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

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

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

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

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

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

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

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

什么时候我不会用它

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

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

比如:

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

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

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

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

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

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

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

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

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

写在最后

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

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