我们做过一次内部统计:三年里换过四次状态库。从 Redux 到 Dva,到 MobX,再到 Zustand。每次迁移都信誓旦旦说「这次对了」,每次都在两年后推倒重来。
问题不在库本身,而在于没有先回答「我们在管的是什么状态」。这篇文章试图把这个问题彻底讲清楚,再回头看四个主流方案各自适合什么。
先厘清:你在管什么状态
「状态」是个被滥用的词。它至少指代三类完全不同的东西,而把它们塞进同一个 store 是大多数混乱的源头。
三类状态的边界
| 类型 | 来源 | 生命周期 | 该用谁管 |
|---|---|---|---|
| 服务端状态 | API 返回的数据 | 与远端同步,可失效 | React Query / SWR |
| URL 状态 | 路由、查询参数 | 随导航变化 | 路由 / searchParams |
| 客户端状态 | UI 交互、表单、本地草稿 | 仅当前会话 | 才是状态库的战场 |
把服务端数据塞进 Redux 是十年前的标配,今天来看就是把缓存当状态管——你会被迫手写 loading、error、staleTime、缓存键,重新发明 React Query。把这类需求剥离出去后,真正需要状态库处理的,往往比你以为的少得多。
状态库的真正职责只剩两件:跨组件共享客户端数据,以及协调多组件的派生计算。如果你只是想在父子间传一个值,useState + Context 足够。
在引入状态库之前,先问一句:这是状态,还是缓存?
— 决定迁移前的自检
四个方案背后的哲学
不写「哪个好」,只看它们各自把哪件事当第一性原则。
Zustand:极简的全局 store
import { create } from 'zustand';
const useCart = create((set, get) => ({
items: [],
add(item) {
set((s) => ({ items: [...s.items, item] }));
},
get total() {
return get().items.reduce((sum, i) => sum + i.price, 0);
},
}));
Zustand 的哲学是「全局单一 store,但用 hook 订阅」。它不强制 action/reducer,更新即函数调用。优势是心智负担最小、API 表面积只有 4 个。代价是——当 store 变大后,切片订阅全靠开发者自觉,没人逼你。
Jotai:原子化的最小单位
import { atom, useAtom } from 'jotai';
// 原子:最小可订阅单元
const priceAtom = atom(0);
const qtyAtom = atom(1);
// 派生原子:依赖变化时自动重算
const totalAtom = atom((get) => get(priceAtom) * get(qtyAtom));
function CartTotal() {
const [total] = useAtom(totalAtom);
return <span>¥{total}</span>;
}
Jotai 把状态拆到「原子」粒度,订阅天然精细——只有真正用到的原子变化才会触发渲染。哲学是「自下而上」:不预设全局结构,需要什么就建什么原子。代价是状态间的关系隐式化,调试时要在脑中重建依赖图。
Redux Toolkit:约束优先的可预测性
import { createSlice, configureStore } from '@reduxjs/toolkit';
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [] },
reducers: {
add(state, { payload }) {
state.items.push(payload); // Immer 代理,写起来像突变
},
},
});
const store = configureStore({
reducer: { cart: cartSlice.reducer },
// 内置 devtools、thunk、序列化检测
});
RTK 的哲学是「用结构换可预测」。action/reducer/selector 三段式强制状态变更走单一通道,时间旅行调试、序列化、可测试性都因此受益。代价是样板代码即使被 Immer 和 createSlice 削弱,仍比前两者重。它的甜点是多人协作的中大型项目,约束本身就是生产力。
Recoil:与 React 调度深度耦合
Recoil 的设计目标之一是「并发模式下不阻塞渲染」。它把原子挂载到 React 树上,由 React 自己调度更新。理论上能拿到最接近 React 心智模型的体验,缺点是更新节奏慢、社区活跃度低于前三者,长线投入需要评估。
| 方案 | 第一原则 | 擅长 | 代价 |
|---|---|---|---|
| Zustand | 极简全局 store | 中小项目、快速起步 | 切片自觉,store 膨胀后难管 |
| Jotai | 原子级订阅 | 派生多、更新精细 | 依赖关系隐式,调试需重建图 |
| Redux Toolkit | 约束即生产力 | 多人协作、中大型 | 样板代码偏重 |
| Recoil | 与 React 调度同构 | 并发模式敏感场景 | 社区活跃度、维护节奏 |
按规模做对照
光看哲学不够,得拿真实体量说话。我们把同一套购物车 + 用户偏好 + 主题切换的需求,用四个方案各实现一遍,记录关键指标。
小项目里这个差距几乎无感,但当状态规模翻到几十个 slice 时,重渲染次数才是真正的成本。我们用 60 个组件订阅同一 store 的压测结果:
| 方案 | 单次更新触发重渲染 | 需手写切片 | 派生计算成本 |
|---|---|---|---|
| Zustand | 视 selector 质量 | 是 | get() 调用时算 |
| Jotai | 仅相关原子 | 否(天然) | 依赖图缓存 |
| RTK | 视 selector 记忆 | 是 | reselect 记忆 |
结论很直接:Jotai 在「更新精细化」这件事上结构性胜出,因为它从设计上就按原子订阅;Zustand 和 RTK 靠 selector 记忆,写得好才追平。但 Jotai 的代价是——当原子数量上百后,没人能一眼看懂它们之间的关系。
组合而非站队
真实项目里我们从不只用一个方案。最舒服的组合是——分层用不同的工具。
// 分层状态架构:每层用最合适的工具
// 1. 服务端数据 → React Query
const { data: user } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
});
// 2. URL 状态 → 路由
const [tab, setTab] = useSearchParams();
// 3. 客户端 UI 状态 → Zustand(小型)或 Jotai(派生多)
const useUI = create((set) => ({
drawerOpen: false,
toggle: () => set((s) => ({ drawerOpen: !s.drawerOpen })),
}));
// 4. 复杂业务流程 → RTK(如果团队已惯用 reducer 心智)
const orderSlice = createSlice({ /* ... */ });
关键是让每层只做自己擅长的事:React Query 管缓存与失效,路由管可分享的状态,状态库只管真正的客户端交互。混在一起的代价是——你会花大量时间在「这段逻辑到底归谁」上争吵。
定一条规则:禁止把服务端数据复制进状态库。如果它已经由 React Query 管着,状态库里就只存它的本地视图(如「当前选中的那条」),不要复制整份数据。这一条能消掉一半的状态混乱。
决策清单
把前面的讨论收成一份可勾选的清单。下次团队争论时,先把它过一遍:
- 规模:组件数 < 30、状态 slice < 10?先用
useState+ Context,别上库。 - 派生密度:大量「A 变 B 也变 C 也变」?Jotai 的原子订阅结构性占优。
- 协作人数:≥ 5 人长期维护?RTK 的约束在评审时会变成生产力。
- 起步速度:原型 / 个人项目?Zustand 的 4 个 API 最快上手。
- 状态来源:是否已用 React Query 管服务端数据?没有就先上它,状态库的需求会自动缩水。
- 调试需求:需要时间旅行?RTK + devtools 是目前最成熟的方案。
没有一条是「X 比 Y 好」。所有取舍都锚定在具体的规模、协作密度、派生复杂度上。脱离这些维度谈优劣,都是抽象的空谈。
银弹不存在。但「在正确维度上做选择」是可以学会的。
— 写给争论中的团队
最后一句提醒:选库只是开始,状态架构腐化的速度永远快过你的预期。定期做一次「这个 slice 还有谁在订阅」的清理,比换库更能延长项目寿命。