07 · 状态管理演进与选型
本篇解决什么问题: 状态管理库是前端最容易过度设计的地方:小项目上 Redux、大项目全塞 Context。 本篇给出按项目规模选型的判断框架,并讲清 Signals 为什么是收敛方向。
📌 核心原则:状态管理的难点不是"存",而是"谁该因为什么而重渲染"。
开篇速览
| 阶段 | 代表 | 解决的核心问题 |
|---|---|---|
| 第一代 | Flux / Redux | 多来源状态不可预测 → 单一数据源 + 纯函数 reducer |
| 第二代 | MobX | Redux 样板代码太多 → 响应式自动追踪 |
| 第三代 | Zustand / Jotai / Pinia | 只要"一个能订阅的盒子" → 极简 API |
| 收敛方向 | Signals | 精确更新到细粒度,跳过虚拟 DOM diff |
一、先问:到底什么是"状态管理"
先分类,再谈工具——很多混乱来自把不同种类的状态塞进同一个库。
| 类型 | 例子 | 该放哪 |
|---|---|---|
| 服务端状态(Server State) | 接口数据、分页、缓存、loading/error | TanStack Query / SWR(不是 Redux!) |
| 客户端 UI 状态 | 弹窗开关、Tab 激活、表单草稿 | 组件本地 useState 或轻量 store |
| 全局共享状态 | 用户信息、主题、权限、购物车 | 状态库(Zustand / Pinia) |
| URL 状态 | 路由参数、筛选条件 | 放在 URL 里(可分享、可回退) |
🎯 最重要的一条经验: 服务端状态不要用 Redux 管。它需要的是缓存、失效、重试、去重, 这正是 TanStack Query 的职责。把接口数据塞进 Redux,是过去十年最常见的误用。
二、演进路线:每一代解决了什么矛盾
2.1 Redux:可预测性的代价
// 单向数据流:action → reducer → new state → 视图更新
dispatch({ type: 'ADD_TODO', payload: { text } })| ✅ 优点 | ❌ 代价 |
|---|---|
| 可预测:状态变更全部可追溯(时间旅行调试) | 样板代码爆炸(action/reducer/type/selector) |
| 可测试:reducer 是纯函数 | 简单需求也要写一堆文件 |
| 严格的单向数据流 | 学习曲线陡 |
Redux Toolkit(RTK) 大幅简化了样板代码,今天要用 Redux 就该用 RTK,别再手写 action type。
2.2 MobX:响应式的反击
class Store {
@observable count = 0
@action inc() { this.count++ }
@computed get double() { return this.count * 2 }
}思路:自动追踪"哪个组件读了哪个字段",改了就精确更新——不需要手写订阅逻辑。
| ✅ 优点 | ❌ 代价 |
|---|---|
| 代码极少,心智负担低 | "魔法"较多,数据流不如 Redux 显式 |
| 精确更新,性能好 | 调试可追溯性弱于 Redux |
| 适合复杂领域模型 | 与 React 严格模式/并发特性的配合要小心 |
2.3 Zustand / Pinia:只要一个能订阅的盒子
// Zustand(React)
const useStore = create(set => ({
count: 0,
inc: () => set(s => ({ count: s.count + 1 })),
}))
const count = useStore(s => s.count) // ✅ **选择器订阅**:count 变了才重渲染// Pinia(Vue)
export const useStore = defineStore('main', {
state: () => ({ count: 0 }),
getters: { double: s => s.count * 2 },
actions: { inc() { this.count++ } },
})为什么它们赢了:
| 原因 | 说明 |
|---|---|
| API 极小 | 10 分钟学会,没有 action/reducer 概念负担 |
| 选择器订阅 | 天然解决"无关更新导致重渲染" |
| 可在组件外使用 | useStore.getState() / store.count,不依赖 Hook |
| 体积tiny | Zustand ~1KB |
📌 Vue 生态:Pinia 已是官方推荐(Vuex 5 实质就是 Pinia),新项目直接用 Pinia。
2.4 Jotai / Valtio:原子化
Jotai:把状态拆成最小原子(atom),组件只订阅它真正用到的 atom。
const countAtom = atom(0)
const doubleAtom = atom(get => get(countAtom) * 2)
const [count, setCount] = useAtom(countAtom) // 只订阅 count优势:天然精确——不存在"一个 store 变了导致全量比对选择器"的问题。 代价:状态分散后,全局数据流图不如集中式 store 直观。
三、Signals:收敛方向
Signal = 一个自带订阅的值,读取时自动收集依赖,修改时只更新真正用到它的地方。
// Solid / Preact Signals / Vue ref / Angular Signals 思路一致
const count = signal(0)
const double = computed(() => count.value * 2)
effect(() => console.log(double.value)) // 自动订阅
count.value = 1 // 只重跑依赖了 count 的 effect,**组件级重渲染被跳过**和"响应式"的关系:
Vue 的
ref/computed就是 Signals。 所谓"Signals 是收敛方向",指的是 React 生态也在往这个方向走 (Preact Signals、Angular Signals、Solid 全靠它)。
为什么是收敛方向:
| 优势 | 说明 |
|---|---|
| 细粒度更新 | 更新直接落到具体 DOM 节点 / effect,跳过虚拟 DOM diff |
| 无需手动优化 | 不需要 memo / useMemo / useCallback |
| 心智模型统一 | 各框架都在收敛到同一套:signal + computed + effect |
⚠️ 与 React 的结合仍不完美(React 的渲染模型是"自上而下重跑组件"), 因此 React 生态里 Signals 更多作为性能优化手段,而非默认范式。
四、选型判断框架(本篇重点)
按项目规模与团队情况直接选:
| 场景 | 推荐 | 理由 |
|---|---|---|
| 小项目 / 个人项目 | useState + Context,必要时 Zustand | 别为"以后可能变大"提前设计 |
| 中后台 / 业务系统 | Zustand(React) / Pinia(Vue) | 极简 + 够用 + 可在组件外访问 |
| 大量服务端状态 | TanStack Query + 少量 Zustand | 接口数据交给 Query,别塞 store |
| 强可追溯 / 强审计需求(金融、协作编辑) | Redux Toolkit | 时间旅行、严格的 action 日志 |
| 复杂领域模型 / 大量派生状态 | MobX 或 Jotai | 自动依赖追踪,少写胶水 |
| 极致性能 / 大数据量表格 | Signals / Jotai 原子化 | 细粒度更新,避免全量 diff |
| 跨框架 / 微前端共享 | Zustand(框架无关,纯 JS store) | 见 A24 · 微前端架构落地 |
决策流程图
状态是服务端来的吗?
├─ 是 → TanStack Query(缓存/失效/重试)
└─ 否
├─ 只在一个组件树内用? → useState / Context
├─ 需要跨路由/跨组件共享?
│ ├─ 需要强审计/时间旅行? → Redux Toolkit
│ ├─ 需要大量派生状态? → MobX / Jotai
│ └─ 其他(绝大多数) → Zustand / Pinia
└─ 需要放在 URL 里分享? → 路由参数(见 A15)五、实践要点(比选型更容易踩坑)
5.1 选择器订阅必须"精"
// ❌ 订阅整个 store:任何字段变了都重渲染
const state = useStore()
// ✅ 只订阅用到的字段
const count = useStore(s => s.count)
// ⚠️ 返回新对象会每次都重渲染(引用变了)
const { a, b } = useStore(s => ({ a: s.a, b: s.b })) // ❌
const a = useStore(s => s.a); const b = useStore(s => s.b) // ✅
// 或用 useShallow 做浅比较:useStore(s => ({a:s.a,b:s.b}), shallow)5.2 别把"能派生的状态"存起来
// ❌ 冗余状态,容易不同步
{ items: [...], count: 3, isEmpty: false }
// ✅ 只存源头,其余用 computed / selector 派生
const count = useStore(s => s.items.length)单一数据源原则:存得越少,不一致的 bug 越少。
5.3 Context 的性能陷阱
Context 的值一变,所有 consumer 全部重渲染(React 没有选择器订阅)。 高频变化的状态不要放 Context;用它传低频的主题、用户信息、配置。
5.4 状态该放在 URL 里就要放
筛选条件、分页、Tab —— 放进 URL 才能分享链接、刷新保持、浏览器回退。 详见 A15 · 路由与数据加载。
自检清单
- [ ] 能区分服务端状态与客户端状态,并说出各自的归属工具
- [ ] 能说清 Redux → MobX → Zustand 每一代解决的矛盾
- [ ] 能按项目规模直接给出选型,并说出理由
- [ ] 知道"选择器订阅"的写法,以及返回新对象会导致的问题
- [ ] 能解释 Signals 为什么是收敛方向,以及它与 Vue
ref的关系
常见误区
| ❌ 误区 | ✅ 事实 |
|---|---|
| "接口数据要存进 Redux" | 应该用 TanStack Query 管缓存与失效 |
| "小项目也该上 Redux,方便扩展" | 过度设计;先用 useState,需要时再上 Zustand |
| "Context 可以当状态管理用" | 它没有选择器订阅,高频更新会全量重渲染 |
| "Signals 是 React 官方新特性" | 是跨框架的收敛范式(Solid/Preact/Vue/Angular 都有) |
| "把能算出来的值也存进 store" | 违反单一数据源,容易不同步 |
动手练习
- 把一个用
useState+ Context 实现的多页面共享购物车,重构成 Zustand,对比代码量与重渲染次数。 - 用 React DevTools 的 "Highlight updates" 观察"订阅整个 store"与"选择器订阅"的重渲染差异。
- 把项目里一个"接口数据存在 Redux"的模块迁移到 TanStack Query,删掉 loading/error 的手写逻辑。
相关文档
- 响应式与依赖收集的底层 → A20 · 框架底层原理
- 横向对比(React vs Vue)→ A05 · React 与 Vue 核心机制对比
- URL 状态与数据加载 → A15 · 路由与数据加载
- 跨应用状态共享 → A24 · 微前端架构落地