Skip to content

07 · 状态管理演进与选型 ​

本篇解决什么问题: 状态管理库是前端最容易过度设计的地方:小项目上 Redux、大项目全塞 Context。 本篇给出按项目规模选型的判断框架,并讲清 Signals 为什么是收敛方向。

📌 核心原则:状态管理的难点不是"存",而是"谁该因为什么而重渲染"。


开篇速览 ​

阶段代表解决的核心问题
第一代Flux / Redux多来源状态不可预测 → 单一数据源 + 纯函数 reducer
第二代MobXRedux 样板代码太多 → 响应式自动追踪
第三代Zustand / Jotai / Pinia只要"一个能订阅的盒子" → 极简 API
收敛方向Signals精确更新到细粒度,跳过虚拟 DOM diff

一、先问:到底什么是"状态管理" ​

先分类,再谈工具——很多混乱来自把不同种类的状态塞进同一个库。

类型例子该放哪
服务端状态(Server State)接口数据、分页、缓存、loading/errorTanStack Query / SWR(不是 Redux!)
客户端 UI 状态弹窗开关、Tab 激活、表单草稿组件本地 useState 或轻量 store
全局共享状态用户信息、主题、权限、购物车状态库(Zustand / Pinia)
URL 状态路由参数、筛选条件放在 URL 里(可分享、可回退)

🎯 最重要的一条经验: 服务端状态不要用 Redux 管。它需要的是缓存、失效、重试、去重, 这正是 TanStack Query 的职责。把接口数据塞进 Redux,是过去十年最常见的误用。


二、演进路线:每一代解决了什么矛盾 ​

2.1 Redux:可预测性的代价 ​

js
// 单向数据流: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:响应式的反击 ​

js
class Store {
  @observable count = 0
  @action inc() { this.count++ }
  @computed get double() { return this.count * 2 }
}

思路:自动追踪"哪个组件读了哪个字段",改了就精确更新——不需要手写订阅逻辑。

✅ 优点❌ 代价
代码极少,心智负担低"魔法"较多,数据流不如 Redux 显式
精确更新,性能好调试可追溯性弱于 Redux
适合复杂领域模型与 React 严格模式/并发特性的配合要小心

2.3 Zustand / Pinia:只要一个能订阅的盒子 ​

js
// Zustand(React)
const useStore = create(set => ({
  count: 0,
  inc: () => set(s => ({ count: s.count + 1 })),
}))
const count = useStore(s => s.count)   // ✅ **选择器订阅**:count 变了才重渲染
js
// 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
体积tinyZustand ~1KB

📌 Vue 生态:Pinia 已是官方推荐(Vuex 5 实质就是 Pinia),新项目直接用 Pinia。

2.4 Jotai / Valtio:原子化 ​

Jotai:把状态拆成最小原子(atom),组件只订阅它真正用到的 atom。

js
const countAtom = atom(0)
const doubleAtom = atom(get => get(countAtom) * 2)
const [count, setCount] = useAtom(countAtom)   // 只订阅 count

优势:天然精确——不存在"一个 store 变了导致全量比对选择器"的问题。 代价:状态分散后,全局数据流图不如集中式 store 直观。


三、Signals:收敛方向 ​

Signal = 一个自带订阅的值,读取时自动收集依赖,修改时只更新真正用到它的地方。

js
// 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 选择器订阅必须"精" ​

js
// ❌ 订阅整个 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 别把"能派生的状态"存起来 ​

js
// ❌ 冗余状态,容易不同步
{ 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"违反单一数据源,容易不同步

动手练习 ​

  1. 把一个用 useState + Context 实现的多页面共享购物车,重构成 Zustand,对比代码量与重渲染次数。
  2. 用 React DevTools 的 "Highlight updates" 观察"订阅整个 store"与"选择器订阅"的重渲染差异。
  3. 把项目里一个"接口数据存在 Redux"的模块迁移到 TanStack Query,删掉 loading/error 的手写逻辑。

相关文档 ​