首页 / React 学习笔记 / 20

REACT · Vol.III · LESSON 20 · 状态管理

Context / Zustand / Redux 如何选型

进阶#状态#选型

先认清三者本质:广播、单例、事件容器

卷三过半:第 17 篇把状态分成「客户端」与「服务端」两类,第 18 篇 Zustand、第 19 篇 Redux Toolkit 接管客户端状态。三件工具(加上第 15 篇的 Context)都摆上了货架,工作里被问得最多的却从来不是「怎么用」,而是「我的项目该用哪个」。回答之前,先把三者的本质摆平——它们对应后端三种你天天打交道的形态:

维度Context(第 15 篇)Zustand(第 18 篇)Redux Toolkit(第 19 篇)
一句话本质依赖注入式广播组件树外的可订阅单例纪律严格的事件驱动容器
后端类比配置中心下发配置进程内单例缓存 BeanEventStore 事件溯源
状态放哪组件树上层的 Provider模块级全局单例configureStore 单例
更新方式重设 value(整体替换)直接调用 action 函数必须 dispatch action
订阅粒度value 整体,不可拆selector 精准到字段useSelector + 引用比较
变更留痕devtools 中间件可选天然 action 流水
心智与样板成本最低中(RTK 已大幅缩减)
后端类比:配置中心 vs 内存缓存 vs EventStore

三个类比展开说。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
Zustandselector 返回字面量新对象重渲染失控(第 18 篇)
RTKreducer 里发请求、取当前时间纯函数被污染,回放失真
RTK页面级小状态也建 slice样板税得不偿失

可执行决策树:自上而下,命中即停

把三个工具和「都不选」排成一棵树,自上而下问,命中即停。注意:大多数项目会在第 2、3 问就停下——这不是偷懒,是正确。

#自问命中 → 用什么详见
1数据来自服务端,要缓存 / 失效 / 重试?都不选 → TanStack Query第 17 / 33 篇
2只有一两个组件用到?local state / 状态提升第 4 / 14 篇
3低频且近乎只读的全局配置?Context第 15 篇
4超大团队,要审计回放与强规范?Redux Toolkit第 19 篇
5其余中小型客户端状态Zustand第 18 篇
数据来自服务端? 都不选:TanStack Query 只有一两个组件用到? local state / 状态提升 低频·近乎只读的配置? Context 超大团队·要审计回放? Redux Toolkit Zustand(默认落点) yes yes yes yes no(一路 no 到底) 前四问全 no,才到这里
图 1状态管理选型决策树:自上而下五问,命中即停;大多数项目停在 local state / 状态提升,中小型客户端状态默认 Zustand
后端类比:同款问题「要不要上微服务」

这套问法你在架构评审里见过:先问数据归属(业务库还是外部数据),再问规模与团队,最后才选技术。工具跟着问题走,不跟着热榜走——「为了用而用」的苦,服务端同学最有体会:引入 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 篇)每帧变化,根本不需要触发渲染

真实项目:四种工具同时上岗

选型的终点不是「挑出一个赢家」。真实的中型项目里,几种工具几乎总是同时在场——各管各的状态,谁也不抢谁的活。看一个电商后台的布局组件,每一处状态都标注了它在决策树上命中的那一问:

Dashboard.tsx · 五种状态,四个答案
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,请求级临时值留在方法栈里——数据性质决定容器,工具天然分工。前端状态管理是同一个问题换了舞台:真源在哪、频率多高、共享多广,三个性质问完,容器自己就选好了。「全家桶信仰」和「一刀切微服务」是同一种病。

两个工具看着都合适时,选更轻的那个,等真实的痛信号出现再升级:第三个不相邻的组件要读它,是 local state 升 Zustand 的信号;需要审计与回放,是 Zustand 升 RTK 的信号。信号来自痛点,不来自热榜——这也是本篇五站合起来的最后一句话。
核心要点
  • 三者本质: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 · 评论