React useEffectEvent 实战:我是怎么把一个越改越乱的 effect 拆开的
我第一次觉得 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 仍然决定连接什么时候重建。
而 theme、locale 这种值,不再参与 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 里的同步条件和内部事件拆开。一旦这条边界拆清楚,代码会比以前稳很多,也更像人在维护,而不是在和依赖数组僵持。