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 仍然决定连接什么时候重建。
而 theme、locale 这种值,不再参与 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 是同步条件。它决定定时器是否存在,所以应该进依赖数组。content 和 title 是定时器触发时要读取的最新值,但它们不应该导致定时器每次输入都重建。
这就是我觉得它比 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 里的同步条件和内部事件拆开。一旦这两层拆清楚,代码会比以前稳很多,也更像人在维护,而不是在和依赖数组僵持。