REACT · Vol.III · LESSON 20 · 状态管理
Context / Zustand / Redux 如何选型
先认清三者本质:广播、单例、事件容器
卷三过半:第 17 篇把状态分成「客户端」与「服务端」两类,第 18 篇 Zustand、第 19 篇 Redux Toolkit 接管客户端状态。三件工具(加上第 15 篇的 Context)都摆上了货架,工作里被问得最多的却从来不是「怎么用」,而是「我的项目该用哪个」。回答之前,先把三者的本质摆平——它们对应后端三种你天天打交道的形态:
| 维度 | Context(第 15 篇) | Zustand(第 18 篇) | Redux Toolkit(第 19 篇) |
|---|---|---|---|
| 一句话本质 | 依赖注入式广播 | 组件树外的可订阅单例 | 纪律严格的事件驱动容器 |
| 后端类比 | 配置中心下发配置 | 进程内单例缓存 Bean | EventStore 事件溯源 |
| 状态放哪 | 组件树上层的 Provider | 模块级全局单例 | configureStore 单例 |
| 更新方式 | 重设 value(整体替换) | 直接调用 action 函数 | 必须 dispatch action |
| 订阅粒度 | value 整体,不可拆 | selector 精准到字段 | useSelector + 引用比较 |
| 变更留痕 | 无 | devtools 中间件可选 | 天然 action 流水 |
| 心智与样板成本 | 最低 | 低 | 中(RTK 已大幅缩减) |
三个类比展开说。Context 是配置中心(Nacos / @Value 那套):启动时拉一次,运行期几乎不变;一旦变更就是全量推送,所有消费方一起刷新——所以它只配承载低频数据。Zustand 是进程内的单例缓存 Bean:谁都能读写,监听器按字段精准订阅,轻快自由,但默认没人记账。Redux 是 EventStore 事件溯源:一切写入先成为事件,状态是投影——审计、回放、规范全齐,代价是每次写入都要「走流程」。
没有高下,只有形态。选型问题其实是两个问题:你的数据更新频率多高?要不要变更留痕?带着这两个问题看下一站。
各自的主场与禁区
Context:低频配置的只读广播
主场:主题与暗色模式、语言、当前登录人、组件库的全局配置——启动定一次、顶多变几次的「环境值」。禁区一:高频变更,输入框每敲一个字符就广播一次,全树消费者陪跑。禁区二:把它当大型应用唯一状态方案,很快退化成 Provider 嵌套地狱加全局重渲染风暴。口诀:一年变一次用 Context,一秒变一次快逃。
Zustand:中小型客户端状态的事实标准
主场:购物车、侧边栏开合、当前 tab、跨路由存活的操作草稿——更新频繁、但每次只影响少数组件的客户端状态。禁区一:服务端数据,接口列表塞进 store,缓存失效、去重、竞态全要手搓(第 17 篇的分野,第 33 篇的正解)。禁区二:selector 里返回字面量新对象,重渲染失控(第 18 篇的坑);超大团队需要强制规范时,「自由」管不住人。
Redux Toolkit:大团队与强规范的主场
主场:变更需要审计与回放(撤销重做、协同编辑)、状态联动复杂、超大团队统一写法、依赖中间件生态。禁区:三五个组件共享一个开关也建 slice,样板税远超收益;小项目为了简历上写「会 Redux」而上,面试官问一句 reducer 纯度就露馅。
反模式速查,对照自查:
| 工具 | 典型反模式 | 后果 |
|---|---|---|
| Context | 输入框值放进 Context | 每敲一键,全树重渲染 |
| Context | 业务状态层层嵌套 Provider | 数据来源难追,重构困难 |
| Zustand | 接口数据塞 store 当缓存 | 手搓残缺版 TanStack Query |
| Zustand | selector 返回字面量新对象 | 重渲染失控(第 18 篇) |
| RTK | reducer 里发请求、取当前时间 | 纯函数被污染,回放失真 |
| RTK | 页面级小状态也建 slice | 样板税得不偿失 |
可执行决策树:自上而下,命中即停
把三个工具和「都不选」排成一棵树,自上而下问,命中即停。注意:大多数项目会在第 2、3 问就停下——这不是偷懒,是正确。
| # | 自问 | 命中 → 用什么 | 详见 |
|---|---|---|---|
| 1 | 数据来自服务端,要缓存 / 失效 / 重试? | 都不选 → TanStack Query | 第 17 / 33 篇 |
| 2 | 只有一两个组件用到? | local state / 状态提升 | 第 4 / 14 篇 |
| 3 | 低频且近乎只读的全局配置? | Context | 第 15 篇 |
| 4 | 超大团队,要审计回放与强规范? | Redux Toolkit | 第 19 篇 |
| 5 | 其余中小型客户端状态 | Zustand | 第 18 篇 |
这套问法你在架构评审里见过:先问数据归属(业务库还是外部数据),再问规模与团队,最后才选技术。工具跟着问题走,不跟着热榜走——「为了用而用」的苦,服务端同学最有体会:引入 Kafka 救不了没有消费者的消息堆积,引入 Redux 同样救不了状态设计混乱。
演进式选型:先 local state,需要时再上工具
最后修正一个常见误解:选型不是开工第一天就要锁死的「架构决策」,而是跟着功能长出来的。今天一个组件自己用——local state;明天第二个组件要共享——状态提升(第 14 篇);后天提到三层之外——Zustand 十行迁完;大后天要审计回放——再迁 RTK。每一级的迁移成本都不高,尤其 Zustand → RTK:selector 换 useSelector,函数调用换 dispatch,状态实体几乎原样搬运。既然逐级上不贵,「一步到位」就更没必要。
误区一:一上来就 Redux
样板税先交齐,纪律却未必建立:没有 code review 兜底时,reducer 里照样塞请求、action 命名照样放飞。「用了 Redux」和「有了可预测性」之间,隔着一整套团队规范。等规范需求真的出现再引入,目标清晰,迁移也有方向——这是对第 19 篇「纪律买什么」最诚实的回答。
误区二:把服务端数据塞进全局 store
第 17 篇的分野值得再念一遍:服务端状态自带一套缓存协议——新鲜度、失效、去重、重试、乐观更新。TanStack Query 把这些全做好了(第 33 篇);塞进 Zustand 或 Redux,等于手写一个残缺版缓存中间件,还把两类状态搅成一锅,最后谁也说不清哪份数据可信。
收尾给一张「常见状态归位表」,可以直接贴进团队文档:
| 常见状态 | 推荐方案 | 理由 |
|---|---|---|
| 主题 / 语言 / 当前登录人 | Context(第 15 篇) | 低频只读,注入一次全树可用 |
| 购物车 / 侧边栏 / tab 选中 | Zustand(第 18 篇) | 高频交互的客户端状态,精准订阅 |
| 撤销重做 / 协同 / 审计回放 | RTK(第 19 篇) | action 流水天然是事件日志 |
| 表单输入值 | local state(第 21 / 24 篇) | 高频变更,不该广播给任何人 |
| 接口列表 / 详情 / 分页 | TanStack Query(第 33 篇) | 服务端状态,缓存竞态失效内置 |
| 动画坐标 / 拖拽位置 | useRef(第 12 篇) | 每帧变化,根本不需要触发渲染 |
真实项目:四种工具同时上岗
选型的终点不是「挑出一个赢家」。真实的中型项目里,几种工具几乎总是同时在场——各管各的状态,谁也不抢谁的活。看一个电商后台的布局组件,每一处状态都标注了它在决策树上命中的那一问:
import { createContext, useContext, useState } from "react";
import { create } from "zustand";
// 第 3 问命中:主题这类低频、近乎只读的全局配置 → Context
const ThemeContext = createContext<"light" | "dark">("light");
// 第 5 问命中:要跨路由存活的布局偏好 → Zustand
const useLayoutStore = create<{ collapsed: boolean; toggle: () => void }>()(
(set) => ({
collapsed: false,
toggle: () => set((s) => ({ collapsed: !s.collapsed })),
})
);
function Dashboard() {
// 第 2 问命中:只有本组件用的搜索词 → local state
const [keyword, setKeyword] = useState("");
const theme = useContext(ThemeContext);
// 订阅只挑需要的字段:侧边栏折叠态变了,才重渲染
const collapsed = useLayoutStore((s) => s.collapsed);
// 第 1 问命中:订单列表是服务端数据,不进任何 store
// → useQuery({ queryKey: ["orders", keyword], ... }),第 33 篇展开
return (/* 侧边栏、搜索框、订单表:各取所需,略 */);
}
五种状态、四个答案,没有一种工具「垄断」这个项目:Context 不碰订单列表,Zustand 不碰搜索词,谁也没越界。注意最后一行——服务端数据连登场的机会都没有,红线在写第一行代码时就划好了。这才是决策树的正确用法:不是面试背书的流程图,而是每天落笔时的路标——每写一份新状态,先过一遍五问,归属自然浮现。
后端从没人争论「MySQL、Redis、Kafka 哪个最好」:业务事实进 MySQL,热点读走 Redis,事件流进 Kafka,请求级临时值留在方法栈里——数据性质决定容器,工具天然分工。前端状态管理是同一个问题换了舞台:真源在哪、频率多高、共享多广,三个性质问完,容器自己就选好了。「全家桶信仰」和「一刀切微服务」是同一种病。
- 三者本质:Context = 依赖注入式广播(配置中心);Zustand = 外部可订阅单例(单例缓存 Bean);RTK = 事件驱动容器(EventStore)。
- 决策树五问,命中即停:服务端数据 → TanStack Query;局部使用 → local state;低频只读配置 → Context;大团队要审计 → RTK;其余客户端状态 → Zustand。
- 两条红线:服务端数据不进任何客户端 store;高频变更不进 Context。
- 选型是演进不是豪赌:local state → 状态提升 → Zustand → RTK,逐级迁移成本可控,别「一步到位」。
- 真实项目是混合使用:Context 管环境值、Zustand 管交互状态、local state 管局部临时值、取数库管服务端数据——每份状态都能说出自己命中的是哪一问。
本章回顾
卷三至此收束:第 17 篇分清两类状态,第 18、19 篇交出两件客户端工具,这一篇把它们和 Context 放上同一张货架、给出决策树和红线。细心的你会发现归位表里有一行始终没展开——表单输入值。它是前端状态里最高频、最容易写崩的一类:受控还是非受控、一个字段一个 state 还是一个大对象、校验逻辑放哪。下一篇第 21 篇,专治表单状态管理。
Comments · 评论