前端状态管理的终极错觉:我们都在重新发明数据库

前端的现状有点荒诞:我们嘴上说自己在写 UI,实际上大部分时间在跟数据较劲。Redux、Zustand、React Query、SWR、Dva、Pinia——每出一个新方案,大家就觉得"这次终于对了"。但如果你把所有这些方案放在一起,去掉它们的外壳看本质,会发现一个尴尬的事实:它们解决的问题,数据库几十年前就解决了,而且解决得更好。

我们不是在做状态管理,我们是在重新发明数据库。只是发明得很差。

 

一个 Redux Store 就是一个简化版数据库

 

Redux 可能是最明显的例子。你写一个 Redux store 的时候,本质上在做什么?

定义一个全局的数据结构——这叫 Schema。写 reducer 处理 action——这叫事务(Transaction),action 本身就是 WAL 日志。写 selector 查询数据——这叫查询引擎。写 middleware 拦截 action——这叫触发器(Trigger)。用 normalize 归一化数据——这不就是数据库范式吗?

Dva 把这些东西更结构化了:model 里的 state 是表,effect 是存储过程,subscription 是监听器,reducer 是事务处理器。你把 Dva model 的概念换上数据库的术语,几乎一一对应。

这不是巧合。当你的前端应用复杂到一定程度,你面对的问题和数据管理的本质问题开始重合——一致性、并发、缓存失效、查询效率。数据库用几十年解决了这些问题,前端重新遇到了它们,然后用 JavaScript 重新造了一遍轮子。

区别在于,数据库的设计者知道自己在做数据库,而前端状态管理方案的设计者以为自己只是在"管理状态"。这个认知差距导致了一个根本性的问题:我们造的轮子,漏了最关键的那根辐条。

 

最关键的那根辐条叫一致性保证

 

数据库有 ACID——原子性、一致性、隔离性、持久性。这四条不是什么学术矫情,是数据管理系统赖以生存的基础。没有这些保证,你的"数据库"就是一个随时可能产生脏数据的数据结构。

前端状态管理的现状呢?

原子性?不存在。你在前端提交一个表单,背后可能触发三个接口调用:创建订单、扣减库存、记录日志。任何一个失败了,另外两个怎么办?Redux 的 reducer 是原子的——但那只是内存中的原子性,从发起接口调用到数据落地的整个链路,没有原子性保障。你可以在 reducer 里优雅地 rollback state,但后端的订单已经创建了。

一致性?前端连定义一致性的标准都没有。什么是"一致"?在你的 store 里,`user.balance` 和 `order.totalAmount` 的关系是什么?如果用户在A页面修改了余额,B页面什么时候能看到?更极端的情况——用户开了两个标签页,一个在支付,一个在退款,双方看到的余额是一致的吗?数据库用约束和事务隔离级别解决了这个问题,前端用"乐观更新"这个漂亮的词绕过了它——说白了就是"我先假装成功了,等后端告诉我结果再说"。

隔离性?前端是单线程的,所以并发问题被掩盖了。但单线程不代表没有并发——async/await 让多个异步操作同时进行,它们的执行顺序不可预测。你在 pending 状态下又发了一个新请求,两个请求的响应返回顺序不确定,最后的 state 是哪个?React 18 的并发模式甚至让渲染都可以被打断和恢复,这让隔离性问题更加微妙。

持久性?localStorage、sessionStorage、IndexedDB——这些是前端的"持久化"方案,但它们的可靠性连 SQLite 都不如。浏览器随时可能清理存储,用户的隐私模式会忽略持久化,跨标签页的写入冲突更是经典问题。

所以前端状态管理本质上是在做一个没有 ACID 保证的数据库。你可以争论说前端本来就不需要 ACID,因为后端有。但这恰恰是问题的核心——如果你不需要 ACID,你就不需要这么复杂的状态管理方案;如果你需要,那你的状态管理方案给不了你。

 

React Query 不是银弹,是一个更好的缓存

 

有人会说:Redux 那一代方案确实有问题,但 React Query(或者说 TanStack Query)不一样——它承认了前端是后端的缓存层,不再试图在前端维护完整的数据副本。

这个认知进步是真实的。React Query 的设计哲学——"数据来源于服务端,前端只是缓存"——比 Redux 的"前端维护完整状态树"要清醒得多。staleTime、cacheTime、refetchOnWindowFocus、optimistic update——这些概念都是从缓存设计的角度出发的,而不是试图在前端重建一个数据库。

但问题在于,React Query 解决了"读取"的问题,没有解决"写入"的问题。

读取数据,前端确实就是后端的缓存层。但写入数据——表单提交、状态变更、批量操作——前端依然要面对分布式系统原子性问题。一个支付流程:调用支付接口、轮询支付结果、更新订单状态、刷新用户余额——这不是一个缓存能解决的问题,这是一个分布式事务问题。

你可以用 React Query 的 mutation + optimistic update 来"模拟"事务:先乐观地更新本地状态,等后端返回了再确认或回滚。但这个"模拟"有多脆弱?网络断了怎么办?用户在回滚之前关了页面怎么办?后端超时但实际处理成功了怎么办?这些问题在数据库里由事务日志和恢复机制兜底,在前端里只能祈祷。

更实际的问题是:React Query 把缓存的复杂度从前端开发者手里拿到了库里面,但"拿到库里面"不等于"消失"了。你在配置 staleTime、gcTime、refetchInterval 的时候,本质上是在做和数据库 DBA 一样的事情——调优缓存策略。只是你有一个更花哨的 API 而已。

 

为什么我们一直在造轮子

 

你可能会问:既然前端本质上是在做数据库的事情,为什么不直接用数据库的那些方案?

因为前端的运行环境和数据库有本质区别。

数据库运行在可控的服务器上,网络可靠、存储持久、计算资源充足、单节点可预测。前端运行在用户浏览器里——网络随时可能断、标签页随时可能关、内存随时可能被回收、CPU 随时可能被其他标签页抢占。在这种环境下谈 ACID,就像在漂流筏上建摩天大楼。

前端状态管理方案之所以一直在造轮子,不是因为设计者不知道数据库的方案,而是因为那些方案在前端环境下不适用,但又没有新的理论框架来替代。于是每一代方案都在试探:Redux 试图把数据库的事务模型搬到前端,MobX 试图把响应式数据库的思路搬过来,React Query 承认前端只是缓存层所以只做缓存,Zustand 说"算了我就做一个简单的 key-value store"。

每一代方案都解决了一部分问题,又留下了另一部分。然后下一代方案说"上一代的问题我解决了"——但它留下了新的问题。

这不是哪一个库的问题,是前端数据管理的元问题还没有被真正定义清楚。

 

金融场景是这个问题的一面照妖镜

 

做普通业务的时候,状态管理的缺陷不太容易暴露。一个商品列表的顺序和后端不完全一致?无所谓,用户刷新一下就好了。一个表单的本地状态和服务端状态不同步?弹出个提示让用户重新提交就好。

金融业务不行。

一个理财产品购买流程,前端要经过选品、风险测评、确认订单、输入密码、支付、结果轮询六个步骤。每一步都可能失败,每一步的状态都可能是"进行中"、"成功"、"失败"、"超时但不确定是否成功"。如果用户在第三步的时候网络断了,重新打开应用时应该回到哪一步?后端可能已经创建了订单,也可能没有。前端的状态已经丢了,但用户不关心这些——他们只想知道自己到底买没买。

或者更常见的场景:用户的可用余额。前端展示的余额是基于上一次查询的快照,但在这之后用户可能在另一个设备上做了赎回操作。前端怎么知道余额变了?轮询?推送?不管哪种方案,在用户点击"购买"和后端返回结果之间,永远存在一个时间窗口,在这个窗口里前端展示的余额是不准确的。在普通业务里这不重要,在金融业务里这可能就是一笔资损。

这根本不是一个状态管理方案能解决的。React Query 的 staleTime 再短、refetch 间隔再密,也无法消除客户端和服务端之间的时间差。这个时间差在数据库里通过事务隔离级别和锁机制来管理,在前端里只能通过业务规则来"容忍"——给用户一个"余额以实际到账为准"的提示,然后在操作之前强制刷新一次数据。

这基本等于承认了:前端管理不了金融状态的一致性,我们只能把最终决定权交给后端,前端做好"展示和引导"就行了。这话说出来,很多前端开发者可能不舒服——那我们精心设计的全局状态树、精确的数据归一化、优雅的乐观更新,到底是为了什么?

为了用户体验。不是为了数据一致性。

 

状态管理的真正价值,和它的边界

 

我觉得对这个问题的理解,可以从一个更根本的问题出发:前端为什么需要状态管理?

回到最原点:HTTP 是无状态的,浏览器是短连接的,用户需要连续操作的体验。

如果没有状态管理,每一次交互都要等后端返回完整的页面状态——这基本上就是传统的服务端渲染模式。用户点一个按钮,刷新整个页面。功能上没问题,但体验很差。状态管理的核心价值是:在前端维护一份"足够的"本地状态,让用户可以连续操作,不必每次都等后端。

"足够的"这个词很关键。它意味着前端状态不需要是完整的、权威的、一致的——只需要足够让用户觉得交互是流畅的。这就划定了状态管理的边界:它是体验优化工具,不是数据管理系统。

一旦你把它当成数据管理系统,就开始了重新发明数据库的道路——全局状态树就是数据库的内存版本,状态机就是事务的简化版,乐观更新就是弱一致性的前端实现。这条路走得越远,你离数据库越近,但也离前端该做的事越远。

前端的正事是什么?把后端的数据展示给用户,把用户的操作传递给后端,中间做必要的缓存和预判来优化体验。就这么简单。任何超出这个范围的事情——在前端做复杂的数据计算、维护多实体的关联关系、实现跨组件的状态同步——都是在侵入后端的领地,而且做的一定不如后端好。

 

那到底该怎么管状态

 

我不会列一个"最佳实践"清单然后大家照着做。但有些做法在我的经验里确实比其他做法更不容易出问题。

最核心的一条是:严格区分哪些状态是"你的",哪些状态是"后端的"。

你的状态——UI 状态:当前选中了哪个 tab,弹窗是否打开,表单的临时输入值。这些状态前端完全拥有,不需要和后端同步,用 useState 或最多一个轻量 store 就够了。这部分不存在一致性问题,因为不存在第二份拷贝。

后端的状态——业务数据:用户余额、订单列表、商品库存。这些状态的唯一权威来源是后端。前端持有的永远只是快照,不是真相。对这部分数据,最诚实的做法就是把它当缓存对待——设置合理的失效策略,在关键操作前刷新,永远不假设本地数据是最新的一定正确。

麻烦的是灰色地带——表单状态。一个编辑表单,数据来源于后端,修改由用户在前端完成,最终提交回后端。这个过程中,数据同时存在于前端和后端,但两边可能不一致。怎么管?

我见过的最务实的做法是:表单状态分两阶段管理。编辑阶段,状态完全属于前端,用本地 state 管理,不写全局 store。提交阶段,一次性把变更提交给后端,后端返回最新状态后刷新前端缓存。编辑中途不做乐观更新,不做中间同步——因为你不确定用户最终会不会提交,同步一个未提交的变更是没有意义的。唯一的例外是自动保存场景,但自动保存的本质是"提前提交",每一步自动保存都应该是完整的提交而不是中间状态的同步。

这个策略看起来"不够现代"——没有全局状态树,没有实时同步,没有乐观更新。但它有一个好处:你可以清楚地解释任何时刻前端状态和后端状态之间的关系,不需要靠猜。

 

全栈视角为什么重要

 

这个问题之所以在前端社区被反复讨论却始终没有共识,根因之一是:大多数参与讨论的人只从前端的视角看问题。

如果你只做过前端,你会觉得状态管理是一个需要被"解决"的前端问题——于是你设计更精巧的状态管理方案,更优雅的 API,更智能的缓存策略。每一步都合理,但方向可能是错的。

如果你同时理解后端,你会看到另一个图景:前端的"状态管理问题",本质上是分布式系统中客户端-服务端一致性的一个特例。这个问题在分布式系统领域已经被研究了几十年,有成熟的理论和方案——CAP 定理、最终一致性、CRDT、事件溯源。前端不是不想解决,而是在不引入后端复杂度的前提下,这个问题没有完美解。

所以全栈视角的价值不是让你"什么都会",而是让你在遇到一个看似前端的问题时,能判断它到底是前端问题还是后端问题的前端投影。

状态管理就是典型的后者。你在前端再怎么努力,也无法消除客户端和服务端之间的时间差和网络不确定性。这不是前端能力的问题,是物理定律。与其在前端花大力气"缓解"这个问题,不如在设计上"规避"它——让前端持有尽可能少的后端状态,让关键操作的最终决定权留在后端,让前端的缓存策略服务于体验而不是一致性。

说得直白一点:承认前端的局限性,比假装能超越它更诚实。

我们花了很多年把前端从"切页面的"做到"工程师",这是进步。但工程师该有的清醒是:知道自己造的轮子和工业级轮子的差距,知道什么时候该站在巨人的肩膀上,什么时候该自己造一个轮子。前端状态管理这个领域,我以为我们造了太多不该造的轮子,又把太多的精力花在了和数据库较劲上。

数据管理就交给数据管理系统吧。前端的核心价值,永远在用户体验,不在数据一致性。

You voted 1. Total votes: 19

添加新评论