从Redux到Signal,状态管理为什么换了三代还是不满意

前端开发有一个有趣的规律:每隔两三年,社区就会宣布一个新的状态管理"最佳实践"。Redux曾是不可撼动的标准,然后MobX说响应式才是正道,Dva试图让Redux更人性化,Zustand说状态管理不该这么啰嗦,Jotai说应该是原子化的,现在Signal又告诉你要从原语层面重新思考这个问题。每一次更迭都伴随着"旧方案已死"的宣言,和一波迁移重构。

但有一个问题始终没人回答:如果每个新方案都比上一个好,为什么我们还是不满意?

经历过Redux到Dva再到Zustand的完整迁移,我发现每一次换库的时候,团队都觉得这次终于对了。每一次,半年后又会发现新的别扭。这种循环让人不得不怀疑——问题可能根本不在库里。

 

Redux的铁律和代价

 

Redux的核心思想并不复杂:单一数据源,纯函数更新,不可变数据。这三条铁律在2016年前后确实解决了一个真实痛点——复杂应用中状态变化的不可预测性。组件树里的状态到处飞,回调层层传递,谁能改、谁不能改、改了之后谁是新的,没人说得清。Redux用一套严格的规则堵住了这个口子。

但这个"严格"的代价太高了。写一个简单的功能,你需要action type常量、action creator函数、reducer处理函数、dispatch调用,然后组件里用connect或useSelector连接。一个功能点,横跨四五个文件。Dan Abramov说这不是bug而是feature——它让状态变化可追踪、可调试、可回溯。

逻辑上没有毛病。但它隐含了一个前提:你的所有状态都值得被这样管理。一个表单的临时输入值,一个下拉框的展开收起,一个Toast的显示隐藏——这些转瞬即逝的UI状态,真的需要走一遍dispatch到reducer再到selector的完整流程吗?

Redux的问题从来不是它不好用,而是它太严肃了。它把"需要严格管理的状态"和"随便放哪都行的状态"放进了同一个框架。当你手里有一把锤子,看什么都像钉子——表单值也放store,弹窗开关也放store,数组索引也放store。Store膨胀到几百行的时候,每个人嘴上说"这不是Redux的问题,是我们的问题",但没人能解释清楚:怎么判断一个状态该不该放进来?

 

Dva:用约定减轻痛苦,但没治本

 

Dva是我在蚂蚁体系里用得最久的方案。它的核心改进很简单:把state、effects、reducers写在同一个model文件里,而不是像原生Redux那样分散在actions、reducers、constants三个目录下。还引入了effects处理异步逻辑,subscriptions处理初始化。

这个改进方向是对的。Redux的action/reducer分离在大型项目里会导致严重的跳转地狱——看一个数据流要横跨四五个文件,认知负担极重。Dva的model把相关逻辑聚合在一起,至少可以一口气看完。

但Dva本质上是在Redux的范式里打补丁。单一store变成了命名空间,dispatch依然存在,不可变更新依然要手写`...spread`或者依赖immer。它减少了文件跳转,却没有减少概念负担。一个新人要学会Dva,实际上还是在学Redux那一套——只不过换了个包装。

更致命的是,Dva的model有一个隐藏倾向:把所有状态都往model里塞。API请求的loading和error要存,表单的临时值也放进来,分页的页码也算上,甚至选中行的ID也挂在上面。一个业务模块的model轻轻松松上三百行,其中一半是真正需要跨组件共享的数据,另一半只是"放在这里比较方便"。

当model变成垃圾桶,"让数据流可预测"这个初衷就变成了"把所有东西扔到一个地方然后假装我管了"。

 

原子化的钟摆

 

Zustand和Jotai代表的是另一个方向——别搞单一store了,状态应该按需拆分。Zustand让你创建多个独立的store,Jotai更进一步,把状态拆到atom级别,用多少定义多少。

这确实是Redux的反面:从"什么都放一起"到"什么都拆开来"。代码量下来了,样板代码少了,上手快了。从Dva迁移到Zustand的那段时间,团队普遍的反应是"终于不用写model了"。

但钟摆有另一端。状态碎片化之后,跨状态的依赖关系变成隐式的。一个计算属性依赖三个atom,一个副作用需要监听两个store的变化——这些依赖在Redux和Dva里至少是可见的(哪怕啰嗦),在Zustand里却可能散落在各个自定义hook中。当有人问"这个页面的数据流是什么",没有一个人能在脑海里画出完整的图。

还有一个更安静的问题:那些没有划入任何store或atom的状态去哪了?答案是组件内部。useState、useReducer、useRef——这些React原生方案确实够用,但它们和外部store之间的数据同步又成了新的认知负担。"这个数据在组件内还是store里"——几乎每做一个新功能都要回答一遍,全靠个人判断和团队约定。

约定这种东西,在没有强制力的项目中,保质期大概两周。

 

Signal:同一个问题的第四次回答

 

Signal是当下最热的状态管理范式。SolidJS起了头,Vue 3的ref和reactive本质上也是Signal,Angular全面拥抱Signals,甚至React社区也在认真讨论Signal RFC。它的思路是:与其在框架层面做diff,不如让数据本身告诉视图"我变了"。

从技术角度看,Signal确实更优雅。细粒度更新,没有虚拟DOM diff的开销,响应式链路清晰。Signal的拥趸会告诉你:这是状态管理的终极形态,之前所有方案都在绕弯路。

但仔细想想,Signal解决的核心问题仍然是同一个:怎样高效地把数据变化反映到视图上。这不是说Signal不好——它在更新效率上很可能确实优于前几代方案。但"状态更新效率高"和"状态管理这个事想清楚了"是两码事。Signal给了你更精细的底层原语,但没有回答上层的问题:哪些状态该用Signal管理?哪些是服务端的?哪些是组件本地的?哪个Signal应该是全局的,哪个应该限定在某个组件树内?

说到底,Signal是一把更锋利的锤子。但不是所有东西都是钉子。

 

被忽略的关键分类:服务端状态和客户端状态

 

我觉得大部分状态管理方案的不满,根因不是库选错了,而是从一开始就把状态分错了类。

打开你的Redux store或Zustand store看看,大概率80%以上的数据是从后端API拿来的。用户信息、列表数据、配置项、权限列表——这些本质上都是服务端状态的客户端缓存。剩下20%才是真正的客户端状态:表单输入、UI开关、弹窗显示、临时选中项。

这个比例很关键。因为服务端状态和客户端状态的生命周期、一致性要求、更新策略完全不同。服务端状态需要考虑缓存失效、重新获取、乐观更新、竞态条件、错误重试。客户端状态需要考虑组件生命周期、重置时机、是否需要持久化到localStorage。

把两种状态混在一个store里管理,相当于把数据库表和组件的私有变量塞进同一个数据结构。这不是架构,这是图方便。

React Query——现在是TanStack Query——的成功恰好证明了这个判断。它根本不是传统意义上的"状态管理库",它是"服务端状态管理库"。当你把API数据从Redux或Zustand store里抽出来交给TanStack Query,会发现store里剩下的那点客户端状态,大部分根本不需要全局管理——一个useState够了,最多一个useReducer。

我观察到好几个项目,引入TanStack Query之后,状态管理代码量缩减了70%以上。不是之前的方案不好,是之前往里面塞了太多不该由它管的东西。API的loading、error、data、分页、缓存失效——这些本就不该和弹窗开关、表单值混在一起。它们的数据来源不同、生命周期不同、消费者不同,硬放在一起,任何方案都会拧巴。

 

后端不存在"状态管理"这个焦虑

 

有一个有意思的对比:后端开发从来不像前端这样为"状态管理"焦虑。不是因为他们没有状态,而是他们从一开始就把状态分得很清楚——数据库是持久化层,Redis是缓存层,请求上下文是会话层,本地变量是计算层。每一层有每一层的工具和模式,没人想着用同一个框架统管所有数据。

前端的状态管理焦虑,某种程度上是因为我们试图在客户端重新发明一套数据库加缓存体系,但又不想承认自己在做这件事。Redux的store就是一个客户端数据库——单一数据源、不可变更新、可回溯。Selector就是查询语句。Middleware就是存储引擎。我们用JavaScript实现了一个笨拙的数据库,然后抱怨它不好用。

这不是嘲讽。前端的场景确实有特殊性——需要离线工作,需要乐观更新,需要在网络不稳定时保持可用性。这些都是后端数据库不关心的事。但承认"我在客户端做数据管理"和"我需要一个状态管理库来写React",是完全不同的出发点。前者会让你认真思考数据层该怎么设计——缓存策略是什么、失效条件怎么定、如何处理多个数据源的依赖。后者只会让你在library的API上反复纠结,从dispatch到hook到atom再到signal,换了一种又一种写法,问题还是那些问题。

 

那到底要不要状态管理库

 

我的看法可能有些极端:大部分应用不需要传统意义上的状态管理库。

服务端状态用TanStack Query或者SWR处理。它替你管缓存、重新获取、过期策略、乐观更新,甚至还有离线模式。不需要手动往store里存loading、error、data,不需要手写缓存失效逻辑,不需要在组件卸载时清理请求状态。这些事它都做了,而且做得比你自己写的更健壮。

真正需要跨组件共享的客户端状态,如果确实存在——主题、语言、登录态这类全局配置——一个轻量方案就够。Zustand可以,Jotai也行,甚至React Context配useState都不是不可以。在这种简单场景下,库的选择根本不重要,因为逻辑本身足够简单,任何方案都不会成为瓶颈。

组件内的UI状态,useState和useReducer就够了。表单用React Hook Form或者自己管理,都不需要引入全局状态管理。

剩下那一小块——既不是服务端数据,又确实需要跨组件共享,还有一定复杂度的状态——才是需要认真设计的地方。比如一个协同编辑场景中的本地草稿状态,或者一个复杂表单向导的跨步骤状态。但这种场景在大多数业务应用中真的不多。可能一个项目里也就一两个地方。

当你觉得"我需要一个状态管理库"的时候,先问三个问题:这个状态是服务端的还是客户端的?这个状态是局部的还是需要跨组件共享的?这个状态的生命周期是跟页面走还是跟会话走?三个问题回答完,大部分时候你会发现需要的不是一个库,而是一个清晰的分类。

 

终局不是更好的库,是更少的库

 

从Redux到Signal,三代状态管理方案跑下来,我不觉得哪一代比上一代有质的飞跃。每一代都在解决上一代制造的摩擦:Redux太啰嗦就上Dva做封装,Dva还是太重就换Zustand精简,Zustand状态碎片化就试Jotai原子化,原子化性能有瓶颈就追Signal细粒度。每一步看着都合理,但每一步都在原地打转。

根本原因:我们一直在优化"如何管理状态",却没有回头问"这些状态是否需要被管理"。

大部分放在全局store里的状态,要么是服务端缓存(该交给数据层处理),要么是临时UI状态(该留在组件内部)。真正需要"全局状态管理"来处理的那部分数据,在绝大多数业务应用中占比极小。为这5%的需求引入一个横跨整个应用的状态管理体系,让剩下95%的数据跟着一起承担它的复杂度——这笔账从来没人认真算过。

状态管理的终局,不是找到那个完美的库。是分清楚哪些状态需要管理、哪些不需要,然后用最简单的方案处理真正需要管理的那部分。其余的,让React自己处理,让TanStack Query处理缓存,让组件管好自己的局部状态。

这个判断可能让很多人不舒服。毕竟,花了那么多时间学Redux、迁移Dva、拥抱Zustand,讨论了好几年"状态管理是前端核心问题",结果说这个问题本身就不该存在?

但技术领域有个规律:最持久的改进不是做得更多,而是想清楚不做哪些。状态管理的方向,也许不是找更好的工具,而是少用工具。

Total votes: 0

添加新评论