4.2 状态管理库生态 本节摘要:第 3.3 节定了选型框架,本节把生态补全。Redux 周边有一整套工具(Toolkit、Thunk、Saga、Reselect、DevTools),Zustand 靠极简与中间件立足,Recoil 用原子模型对标。本节用 TODO 应用分别实现 Redux 与 Zustand 版本,展示「同样功能、不同心智」,并给出「什么场景配什么生态」的清单。 你能学到什么 阅读完本节,你应当能够: 说出 Redux 生态的核心成员及各自职责 用 Redux Toolkit 的 createAsyncThunk 处理异步状态 用 Zustand 的中间件扩展能力 区分「要 Redux 全家桶」与「Zustand 就够」的场景 为一个项目拼出完整的状态管理方案
本节摘要:第 3.3 节定了选型框架,本节把生态补全。Redux 周边有一整套工具(Toolkit、Thunk、Saga、Reselect、DevTools),Zustand 靠极简与中间件立足,Recoil 用原子模型对标。本节用 TODO 应用分别实现 Redux 与 Zustand 版本,展示「同样功能、不同心智」,并给出「什么场景配什么生态」的清单。
阅读完本节,你应当能够:
第 3.3 节讲过「按复杂度选方案」。但真实选型还有一个维度:库的周边生态。Redux 强不只是因为它本身,而是它的周边——处理异步的中间件、缓存 selectors 的库、时间旅行调试的 DevTools——构成了一个「全家桶」。选 Redux 等于选了这套全家桶。
Zustand 则不同:核心只有几十行 API,没有全家桶,需要什么自己搭。它的「生态」是「极简 + 无锁缚」。
这两条路没有对错,只有合不合适。本节把两套生态摊开看。
| 工具 | 职责 |
|---|---|
| Redux Toolkit | 官方工具集,configureStore/createSlice 简化配置 |
| Redux Thunk | 中间件,允许 dispatch 函数处理异步 |
| Redux Saga | 中间件,用 Generator 处理复杂异步流程 |
| Reselect | 创建可缓存的 selectors,避免重复计算 |
| Redux DevTools | 浏览器扩展,action 日志与时间旅行调试 |
第 3.3 节已经写过 configureStore 与 createSlice,这里补上它和传统 Redux 的关系:Toolkit 是官方推荐的「现代入口」,底层仍是经典 Redux 的三原则——它封装了样板,没有改变思想。
真实项目里最常用的异步处理方式:
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; const fetchTodos = createAsyncThunk('todos/fetch', async () => { const res = await fetch('/api/todos'); return res.json(); }); const todosSlice = createSlice({ name: 'todos', initialState: { items: [], status: 'idle', error: null }, extraReducers: (builder) => { builder .addCase(fetchTodos.pending, (state) => { state.status = 'loading'; }) .addCase(fetchTodos.fulfilled, (state, action) => { state.status = 'done'; state.items = action.payload; }) .addCase(fetchTodos.rejected, (state, action) => { state.status = 'error'; state.error = action.error.message; }); }, });
pending/fulfilled/rejected 三态由 thunk 自动生成,slice 里只声明「每个状态怎么更新」。组件里 dispatch(fetchTodos()) 触发整个流程。这套模式替代了早期手写「请求中/成功/失败」三组 action 的样板。
Thunk 处理「一次请求」够用,但遇到「登录后拉取用户信息 + 拉取权限 + 拉取菜单」这种编排型流程,Thunk 会变得嵌套。Saga 用 Generator 把流程写成「顺序步骤」:
// 示意:监听登录 action,成功后依次拉数据 function* handleLogin(action) { try { yield put({ type: 'LOGIN_START' }); const user = yield call(loginApi, action.payload); yield put({ type: 'LOGIN_SUCCESS', payload: user }); yield put(fetchPermissions(user.id)); // 编排后续请求 } catch (e) { yield put({ type: 'LOGIN_FAILURE', error: e.message }); } }
Saga 强大但学习成本高——Generator、effect、saga 模式都是新概念。Thunk 够用就别上 Saga,是 Redux 生态的普遍共识。
组件频繁从 store 取派生数据时,重复计算是浪费。Reselect 的 createSelector 会缓存结果,输入没变就用缓存:
import { createSelector } from '@reduxjs/toolkit'; const selectTodos = state => state.todos.items; const selectDoneTodos = createSelector( [selectTodos], todos => todos.filter(t => t.done) );
「筛选已完成 todo」这个派生计算只在 todos 变化时执行一次,其余读取走缓存。第 2.4 节的「派生状态勿存」在这里有了性能侧解法——派生值不存 store,用缓存 selector 按需算。列表越长、派生逻辑越重,Reselect 的收益越明显。
Zustand 核心只有 create 一个函数,生态围绕「中间件」和「选择器」展开。
把状态同步到 localStorage:
import { create } from 'zustand'; import { persist } from 'zustand/middleware'; const useStore = create(persist( (set) => ({ user: null, setUser: (user) => set({ user }), }), { name: 'user-storage' } // 存到 localStorage 的 key ));
刷新页面后登录态自动恢复——不用手写 localStorage 读写。这是 Zustand 生态里用得最多的扩展。
import { devtools } from 'zustand/middleware'; const useStore = create(devtools((set) => ({ count: 0, increment: () => set(state => ({ count: state.count + 1 })), })));
devtools 中间件把 Zustand 接入 Redux DevTools——想看 action 日志和时间旅行,装个中间件就行,不必为此换 Redux。
Zustand 生态的特点是不强制:核心极简,能力靠中间件按需加。需要持久化就加 persist,需要调试就加 devtools,不需要就零负担。这和 Redux 的「全家桶」形成鲜明对比——一个是点菜,一个是套餐。
用一个 TODO 应用对比 Redux 和 Zustand 的「思维差异」,比抽象对比更直观。
Redux Toolkit 版本:
// slice:状态 + 更新逻辑集中定义 const todosSlice = createSlice({ name: 'todos', initialState: { items: [], status: 'idle' }, reducers: { addTodo: (state, action) => { state.items.push({ id: Date.now(), text: action.payload, done: false }); }, toggleTodo: (state, action) => { const todo = state.items.find(t => t.id === action.payload); if (todo) todo.done = !todo.done; }, }, }); export const { addTodo, toggleTodo } = todosSlice.actions; // 组件:dispatch 触发,useSelector 读取 function TodoApp() { const items = useSelector(state => state.todos.items); const dispatch = useDispatch(); return ( <ul> {items.map(t => ( <li key={t.id} onClick={() => dispatch(toggleTodo(t.id))}> {t.text} - {t.done ? '已完成' : '未完成'} </li> ))} </ul> ); }
Zustand 版本:
const useStore = create((set) => ({ items: [], addTodo: (text) => set(state => ({ items: [...state.items, { id: Date.now(), text, done: false }] })), toggleTodo: (id) => set(state => ({ items: state.items.map(t => t.id === id ? { ...t, done: !t.done } : t ), })), })); function TodoApp() { const items = useStore(state => state.items); const toggleTodo = useStore(state => state.toggleTodo); return ( <ul> {items.map(t => ( <li key={t.id} onClick={() => toggleTodo(t.id)}> {t.text} - {t.done ? '已完成' : '未完成'} </li> ))} </ul> ); }
对比能看出两套心智的核心差异:Redux 强调「action 描述意图、reducer 负责更新」——更新逻辑和界面严格分离;Zustand 把「状态 + 更新函数」直接放进 store,组件调用 store 上的函数即可。Redux 多一层抽象(action/reducer),换来的是一致性约束与调试可追踪;Zustand 少一层抽象,换来的是直接与简洁。没有谁更好,只有你的团队更适应哪种思维。 这也是面试常问「Redux 和 Zustand 区别」的真正考点——不是 API,而是抽象层的取舍。
Recoil 的生态相对小而聚焦。它的核心是 atom + selector,配套能力围绕「派生状态」构建:
Recoil 适合「状态关系复杂、派生状态多」的应用。但它的周边配套(异步处理、持久化、调试工具)不如 Redux 和 Zustand 成熟,选它要有「陪生态成长」的心理准备。
「Thunk 和 Saga 到底怎么分?能不能一起用?」 能一起用,但绝大多数项目不需要。分界线在「异步流程的编排复杂度」:单次请求、成功后更新一个状态,Thunk 足够;请求之间有依赖、要按顺序编排(登录后拉用户、拉权限、拉菜单)、要处理取消与重试,Saga 的 Generator 写法更清晰。注意:Saga 的学习成本不只是语法,还有 effect 模型(call/put/take)和并发控制的思路,团队没有人熟悉它之前,贸然引入的风险不小。务实建议是先用 Thunk 顶住,等真正出现「编排地狱」再评估 Saga。
「Reselect 的缓存失效条件是什么?」 createSelector 的缓存是「浅比较输入」——输入参数引用没变就用缓存,变了就重算。所以它有两面性:正确地用,列表派生计算只在数据变化时执行一次;错误地用,你传进去的依赖每次都是新数组(比如每次渲染都 .filter() 生成新结果再传给 selector),缓存形同虚设。用 Reselect 的黄金法则是「让 selector 的输入尽量来自 store 本身,别在渲染期现算中间结果」。
「Zustand 的 persist 会把整个 store 都存下来吗?」 默认会,但通常不该这样。persist 中间件支持 partialize 选项,指定只持久化哪些字段。登录态、用户信息值得存;实时计数器、临时 UI 状态存了反而有「刷新后恢复旧值」的困扰。还有一点容易忽略:持久化的数据结构一变,老用户浏览器里的旧数据会和新的 state 合并出错,处理方式是给 persist 配版本号和 migrate 函数。这些细节不处理,持久化会从「便利」变成「坑」。
「选了 Redux 又想用 Zustand 的某个中间件,可以吗?」 不建议。生态之间不是「拼积木」,每个库的中间件和它的数据流设计是耦合的。Redux 的中间件(Thunk/Saga)作用在 dispatch 链路,Zustand 的中间件作用在 set 链路,机制不同、拼不到一起。真遇到「Redux 缺这个能力」的情况,先问自己:是不是该用数据请求库(TanStack Query)而不是状态库?数据请求、缓存、持久化这些「横切能力」,很多其实不该由状态库承担。
第 3.3 节提过状态三层,这里从生态角度再展开一层。判断一份数据属于哪类,看它「从哪来、变多快、谁拥有」:
| 状态类型 | 特征 | 谁管 |
|---|---|---|
| 服务器状态 | 从接口来、会被别处修改 | TanStack Query / SWR |
| 客户端全局状态 | 本应用内共享、低频 | Context / Zustand |
| 组件局部状态 | 只在本组件用 | useState / useReducer |
很多团队把「接口返回的数据」和「界面开关」都塞进 Redux,结果 store 里塞满了「实际上是从后端拿来的、本该有自己缓存策略」的数据。用 TanStack Query 管服务器状态后,loading、error、重试、缓存全部交给它,Redux 或 Zustand 只留真正的 UI 状态——store 小了一半,复杂度降了一个量级。这个「职责拆分」比任何库的选型都重要。

| 场景 | 推荐生态 |
|---|---|
| 大型团队、强可预测性需求 | Redux Toolkit + Thunk + DevTools |
| 复杂异步编排流程 | Redux Toolkit + Saga |
| 中小项目、追求简洁 | Zustand + persist/devtools 中间件 |
| 状态关系复杂、派生多 | Recoil atom/selector |
| 数据请求为主的状态 | TanStack Query(数据请求库) |
💡 关键直觉:别让「生态丰富」本身成为选 Redux 的理由。生态是「你已经决定用 Redux 后获得的加成」,不是「选 Redux 的原因」。先按复杂度决定方案(第 3.3 节),再看方案对应的生态够不够你当前的需求。
⚠️ 常见坑:全家桶套全家桶。选了 Redux 又把 Thunk、Saga、Reselect、Immutable 全部装上,项目初期就背上一堆概念负担。按需引入——Thunk 够用就不上 Saga,计算量小就不上 Reselect。生态是选项,不是必选。
给一个新项目定状态管理方案,完整流程是四步:
第一,列出所有「跨组件共享」的状态,标出变化频率。第二,按频率和复杂度选核心方案——低频少量用 Context,中等用 Zustand,复杂用 Redux Toolkit。第三,按「是否异步、是否要持久化、是否要缓存派生值」决定加哪些周边。第四,把最终方案写成团队约定,避免每人各选一套。
以「后台管理系统」为例推演:登录态与用户信息(低频全局)→ Context 或 Zustand;权限列表(低频、需持久化)→ Zustand + persist;订单列表(服务器数据、异步、缓存)→ TanStack Query;UI 状态(弹窗开关、侧边栏折叠)→ 组件内 state 或 Context。推演结果是:这个系统甚至不需要 Redux——一个 Zustand 加一个数据请求库就够了。
状态库和「服务器数据」的边界值得专门强调。很多团队把接口数据全塞进 Redux store,用 action/reducer 管理 loading、error、缓存——这是早期 Redux 的常见模式,但样板多且不适合缓存。
现代的做法是把「服务器数据」交给专门的数据请求库(TanStack Query、SWR),它们内置缓存、重试、竞态处理、失效刷新;状态库只存「客户端 UI 状态」。这条分工让两类状态各归其位:接口数据归请求库管,界面状态归状态库管,互不干扰。选型时把「服务器状态」从「客户端状态」里拆出来,是方案清晰的关键一步。
拆出来之后还有一个习惯要养成:接口返回的数据结构变了(字段改名、层级调整),改的地方应该集中在请求层和数据请求库的配置里,组件和状态库都不用动。这份「变更隔离」能力,是数据分工带来的额外红利——它让「接口格式变动」从「全局事故」变成「局部改动」。判断一套方案健不健壮,一个简单的检验就是「改一次接口字段,要动几个文件」——动得越少,分工越成功。
最后提醒:状态方案会随项目成长而演进。小项目 Context 起步,状态复杂了迁 Zustand,再到 Redux Toolkit——这是正常的路径。别因为「当初选了 Context 就要一条道走到黑」,也别「一上来就上最重的」。保持「够用就好、复杂了再升级」的心态,让方案跟着需求走,而不是让需求将就方案。
下一节看 UI 组件库生态——状态库管数据,UI 库管界面。Ant Design、Material UI、Mantine、shadcn/ui 这些方案各有什么特点,选组件库看哪些标准,是本节要回答的问题。组件库的选择直接影响开发效率,也影响后续所有页面的写法。