![]()
React 19还没捂热,社区里一组实验代码的Star数已经悄悄破了4000。不是新框架,是有人把Signals塞进了React——而且没动一行React源码。
这事有意思的地方在于:React团队自己也在搞Signals(就是那个叫use的Hook),但进度慢得像在挤牙膏。社区等不及了,直接上手造了个能跑的生产级方案。今天这篇就是作者系列的第四篇,讲清楚一个关键问题——数据副作用和UI副作用,到底该谁管?
7行代码,拆出两条完全不同的生命周期
先看这段被转发最多的代码:
// data/heartbeat.ts import { signal } from "../core/signal"; import { createEffect, onCleanup } from "../core/effect"; export const intervalMs = signal(1000); export const heartbeat = signal(null); createEffect(() => { const ms = intervalMs.get(); const id = setInterval(() => { heartbeat.set(new Date()); }, ms); onCleanup(() => clearInterval(id)); });
7行核心逻辑,干了一件React里很别扭的事:让一个定时器跟着数据走,而不是跟着组件走。
作者管这叫"数据层的心跳"——intervalMs是个信号,改它的时候,旧的定时器自动清理,新的自动启动。整个过程没有组件参与,页面切走了它还在跑,页面切回来数据还是热的。
对比React原生的写法,差别立刻显现。以前你要么把定时器塞useEffect里跟着组件生死,要么上Redux-Saga、React Query这种重型方案。现在7行代码搞定,而且类型安全。
光标闪烁:为什么必须用React的useEffect?
作者紧接着抛了另一个例子,刻意和上面的形成对照:
// ui/Blinker.tsx export function Blinker({ enabled = true }) { const [on, setOn] = useState(false); useEffect(() => { if (!enabled) return; const id = setInterval(() => setOn(v => !v), 500); return () => clearInterval(id); }, [enabled]); return |; }
同样是定时器,这次老老实实用了React的useEffect。为什么?
![]()
因为光标闪烁是纯视觉行为,它依赖React的渲染周期——enabled prop变了要立刻停,组件卸载要立刻清。这些时机必须对齐React的commit阶段,而不是数据的任意变更。
作者的原话很直接:「这是纯粹的UI/视觉行为,它的清理时机应该跟随React的提交周期。」
两个例子摆在一起,分界线就清楚了:createEffect管数据流的生命周期,useEffect管DOM的生命周期。以前这两件事被混在一个Hook里,现在物理隔离。
Dashboard组件:两条河怎么汇到一处
真正用起来的时候,开发者面对的其实是混合场景。看作者的App.tsx:
export function Dashboard() { const lastBeat = useSignalValue(heartbeat); const ms = useSignalValue(intervalMs); return (
Last heartbeat: {lastBeat?.toLocaleTimeString() ?? "—"}
Polling every {ms} ms
这里用了个叫useSignalValue的桥接Hook——信号的值被转换成React能消费的state,但信号的订阅关系还在数据层自己手里。
结果是:改intervalMs的时候,createEffect那边自动重跑定时器,Dashboard组件只收到最新的ms值,不需要关心定时器的创建和销毁。而Blinker组件里的光标,该闪还是闪,该停还是停,两条线互不干扰。
作者特意强调了行为差异:Timer polling(createEffect)独立于任何组件,页面导航时继续运行;UI blinking(useEffect)随组件挂载/卸载创建和清理。
这个设计在解决什么真问题?
熟悉React历史的人知道,useEffect的批评声音从来没停过。Dan Abramov自己写过一篇《useEffect完整指南》,底下最高赞评论是"我还是不懂"。
核心矛盾在于:useEffect被迫同时干两件事——同步外部系统(数据),和同步浏览器API(DOM)。这两件事的时序要求完全不同,但API长得一模一样,依赖数组的语义还随场景变化。
![]()
Signals方案把第一层抽走了。数据相关的副作用跟着信号走,有独立的创建-更新-销毁生命周期;UI相关的副作用留在React里,跟着渲染周期走。两边都用onCleanup,但执行的时机由各自的运行时保证。
这不是什么理论洁癖。作者举的实际场景是:一个轮询心跳,一个光标闪烁。在生产环境里,这可能是WebSocket重连策略和加载动画的关系,是后台同步状态和Toast提示的关系——以前写在一起必然互相干扰,现在可以分开测试、分开优化。
社区对这个方案的反应很分裂。一部分人觉得终于不用在useEffect里写一堆防御性代码了,另一部分人担心又多了一层概念负担。但Star数的增长是真实的,4000多个开发者用实际行动投了票。
React官方的Signals实现还在RFC阶段,具体语法变了好几稿。社区方案的优势是现在就可用,而且API设计明显借鉴了Solid.js的成熟经验——createEffect、onCleanup、signal.get()/set(),几乎照搬。
风险也有。这个方案依赖React的订阅机制做桥接,如果官方最终定的API差异太大,迁移成本不会小。但作者似乎不太在意,系列文章已经写到第四篇,每一篇都在补全边缘场景的处理。
一个值得注意的细节:作者的代码里没有任何"魔法"。signal、createEffect都是普通函数,没有编译时转换,没有Babel插件。这意味着你可以逐行调试,可以在浏览器控制台里手动调heartbeat.set()看效果。
这种可观测性在现在的前端生态里反而成了稀缺品。太多方案藏在编译器后面,开发者遇到问题只能猜。
回到开头那个问题:React团队知道社区在这么干吗?
知道。React核心成员Andrew Clark去年在Twitter上回复过类似方案,说"我们也在探索这个方向,但想确保和并发特性兼容"。翻译一下:官方认可问题存在,但解法要保守。
保守有保守的道理。React的并发渲染(Concurrent Rendering)让时机问题变得极其复杂,一个信号更新如果在渲染中途触发,会不会导致死循环?会不会破坏时间切片?
社区方案目前的答案是:createEffect在微任务队列里调度,故意不和React的渲染帧抢资源。这个 trade-off 牺牲了最低延迟,换来了安全性。够不够用,取决于你的场景。
作者没说的是:这个方案已经在某个生产环境里跑了多久、撑住了多少流量。但代码的完整度和测试覆盖率暗示这不是玩具项目——有完整的TypeScript定义,有React适配层的边界情况处理,甚至还有和Next.js App Router的兼容说明。
如果你现在就想试,作者提供了现成的模板。但更值得观察的是这个模式的演化:Signals会不会成为React的标配?官方和社区方案最终是合并还是分叉?以及,有多少开发者愿意为了"更干净的数据流"承担额外的学习成本?
最后一个问题留给正在读的你:在你的项目里,有多少useEffect其实是在管数据而不是管DOM?数清楚这个数字,可能比选哪个方案更重要。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.