主题
第 14 章 · 状态管理
一句话开篇:当状态需要在"五层组件之外"被读写时,把它从组件树里搬出来,放到一个独立的"全局仓库"——这就是状态管理。
0. 生活类比
类比一:家里的"公共冰箱" vs "私人零食柜"
- 私人零食柜(组件 state) = 自己房间里的小柜子,只有你用,别人不知道
- 公共冰箱(全局状态) = 客厅里大家都能开的冰箱,谁拿什么都看得见
如果一瓶可乐你想让妈妈、爸爸、弟弟都能拿,又要让大家都知道还剩几瓶——放冰箱里。如果只是你自己睡前嚼几粒糖,放卧室抽屉就够了。
类比二:Redux = 银行系统
Redux 是个 "严格的银行":
- 钱只放在总行金库里(单一数据源 store)
- 想动钱不能直接撬保险箱(state 只读)
- 必须填一张取款单(action)交给柜员(reducer),柜员按规则处理后更新账本(new state)
- 每一笔交易都有记录可查(DevTools 时间旅行)
类比三:Zustand = 家里的便签本
Zustand 是"便签本":
- 在墙上贴张便签写着"鸡蛋还剩 3 颗"
- 谁要更新就直接改写便签
- 谁要看就抬头看一眼
- 没有取款单、没有柜员,简单粗暴
1. 何时该用全局状态?
不需要的场景
- 数据只在一个组件内部用 → 用
useState/ref - 父子两层 → 用 props
- 同级两个组件 → 提升到共同父级(lifting state up)
该上全局状态的信号
| 信号 | 含义 |
|---|---|
| Props 透传 5 层以上 | 中间组件被迫当传话筒 |
| 多个无关组件共享状态 | 如登录用户信息、主题、购物车 |
| 跨页面持久化 | 路由切换后状态不能丢 |
| 状态变化要触发副作用 | 如全局 toast、加入购物车飞入动画 |
| 复杂的状态机/异步流程 | 如订单流转、表单分步流程 |
一句口诀:"props 传 5 层就该用了"。
2. Redux 核心三原则
原则一:单一数据源(Single Source of Truth)
整个应用的 state 存在唯一一个 store 里,是一棵 JS 对象树。
原则二:State 只读(State is Read-Only)
不能直接 state.x = 1,唯一改变 state 的方式是 dispatch 一个 action。
js
store.dispatch({ type: 'cart/add', payload: { id: 1 } });原则三:用纯函数 reducer 修改
reducer 是 (state, action) => newState 的纯函数:
- 相同输入 → 相同输出
- 不修改原 state,返回新 state(不可变更新)
- 不能有副作用(请求、随机数、定时器都不行)
Redux 数据流(ASCII 图)
┌──────────┐ dispatch(action) ┌──────────┐
│ View │ ─────────────────→ │ Store │
│ (组件) │ │ │
└────▲─────┘ │ ┌────────────┐
│ │ │ Reducer │
│ subscribe (新 state) │ │ (纯函数) │
└──────────────────────────┴──┴────▲────────┘
│
return newState经典 Redux 写法(逐渐被淘汰,但要看得懂)
js
import { createStore } from 'redux';
const initialState = { count: 0 };
function reducer(state = initialState, action) {
switch (action.type) {
case 'INC': return { ...state, count: state.count + 1 };
case 'DEC': return { ...state, count: state.count - 1 };
default: return state;
}
}
const store = createStore(reducer);
store.subscribe(() => console.log(store.getState()));
store.dispatch({ type: 'INC' });3. Redux Toolkit(RTK)—— 推荐用法
经典 Redux 模板代码太多(action types / action creators / reducer 三处分别写),RTK 把它们合并成 slice。
安装
bash
npm i @reduxjs/toolkit react-reduxcreateSlice
js
import { createSlice, configureStore } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
inc: (state) => { state.value++; }, // 直接"改"!
dec: (state) => { state.value--; },
addBy: (state, action) => { state.value += action.payload; }
}
});
export const { inc, dec, addBy } = counterSlice.actions;
const store = configureStore({
reducer: { counter: counterSlice.reducer }
});⚠️ 注意
state.value++看起来在直接改,其实是 Immer 在底层帮你转成不可变更新!
在 React 中使用
jsx
import { Provider, useSelector, useDispatch } from 'react-redux';
import { store, inc, addBy } from './store';
function Counter() {
const value = useSelector(s => s.counter.value);
const dispatch = useDispatch();
return (
<>
<p>{value}</p>
<button onClick={() => dispatch(inc())}>+1</button>
<button onClick={() => dispatch(addBy(5))}>+5</button>
</>
);
}
<Provider store={store}><Counter /></Provider>RTK Query —— 内置数据请求
js
const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
endpoints: (b) => ({
getUser: b.query({ query: (id) => `/user/${id}` })
})
});
const { useGetUserQuery } = api;
// 组件中
const { data, isLoading } = useGetUserQuery(123);4. Zustand —— 极简方案
Zustand(德语 "状态")是当今最受欢迎的轻量状态库,约 1KB,无需 Provider。
创建 store(一行函数)
js
import { create } from 'zustand';
const useCounter = create((set, get) => ({
count: 0,
inc: () => set(s => ({ count: s.count + 1 })),
dec: () => set(s => ({ count: s.count - 1 })),
reset: () => set({ count: 0 }),
// 也可以读 state:
double: () => get().count * 2
}));使用(直接当 Hook 用)
jsx
function Counter() {
// 选择器:只订阅 count,其他字段变化不会重渲染
const count = useCounter(s => s.count);
const inc = useCounter(s => s.inc);
return <button onClick={inc}>{count}</button>;
}Zustand 的优势
- 无 Provider:不用包根组件
- 细粒度订阅:用 selector 只订阅自己关心的字段
- TS 友好:类型自动推导
- 可放在组件外:非 React 代码也能
useCounter.getState()读、.setState()写 - 支持中间件:persist(持久化)、devtools、immer
持久化示例
js
import { persist } from 'zustand/middleware';
const useStore = create(persist(
(set) => ({ token: null, setToken: (t) => set({ token: t }) }),
{ name: 'auth-storage' }
));5. Pinia —— Vue 推荐
Pinia 是 Vue 官方推荐的状态库(取代了 Vuex),支持 Composition API。
创建 store
js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useCartStore = defineStore('cart', () => {
// state
const items = ref([]);
// getter
const total = computed(() => items.value.reduce((s, i) => s + i.price * i.qty, 0));
// action
function add(item) {
const exist = items.value.find(i => i.id === item.id);
if (exist) exist.qty++;
else items.value.push({ ...item, qty: 1 });
}
return { items, total, add };
});使用
vue
<script setup>
import { useCartStore } from './stores/cart';
const cart = useCartStore();
</script>
<template>
<p>共 {{ cart.items.length }} 件,合计 ¥{{ cart.total }}</p>
<button @click="cart.add({ id: 1, name: '苹果', price: 5 })">加苹果</button>
</template>Options API 风格也支持
js
defineStore('counter', {
state: () => ({ count: 0 }),
getters: { double: (s) => s.count * 2 },
actions: { inc() { this.count++ } }
});6. 全方位对比表
| 维度 | Redux Toolkit | Zustand | Pinia | Context+Reducer |
|---|---|---|---|---|
| 适用框架 | React 主,能用别的 | React/Vue/Solid 通用 | Vue 专属 | React |
| 体积 | ~13 KB | ~1 KB | ~6 KB | 0(内置) |
| 模板代码 | 中(slice 合并简化) | 极少 | 少 | 中 |
| 学习曲线 | 中(需理解 action/reducer) | 低 | 低 | 低 |
| Provider | 需要 | 不需要 | 需要(pinia 实例) | 需要 |
| 选择器 | useSelector | useStore(selector) | 自动响应式 | 需自己 memoize |
| 异步 | createAsyncThunk / RTK Query | action 内直接 async | action 内直接 async | 自己写 effect |
| DevTools | ⭐⭐⭐⭐⭐(业界标杆) | ⭐⭐⭐(middleware) | ⭐⭐⭐⭐ | ❌ |
| 时间旅行 | ✅ | 部分 | ✅ | ❌ |
| 持久化 | redux-persist | persist 中间件 | pinia-plugin-persistedstate | 自己写 |
| 推荐场景 | 大型项目、需要严格规范 | 中小项目、追求简洁 | Vue 项目首选 | 简单跨层级共享 |
7. 选型决策树
你需要全局状态吗?
│
├─ 否 → useState / ref 即可
│
└─ 是 → 用什么框架?
│
├─ Vue → Pinia(官方推荐,结束)
│
└─ React → 项目规模?
│
├─ 小(共享几个字段)→ Context + useReducer 或 Zustand
│
├─ 中 → Zustand(极简、好维护)
│
└─ 大(多人协作、严格规范、复杂异步)→ Redux Toolkit8. 异步处理对比
Redux Toolkit(createAsyncThunk)
js
const fetchUser = createAsyncThunk('user/fetch', async (id) => {
const res = await fetch(`/api/user/${id}`);
return res.json();
});
const userSlice = createSlice({
name: 'user',
initialState: { data: null, loading: false },
reducers: {},
extraReducers: (b) => {
b.addCase(fetchUser.pending, (s) => { s.loading = true; });
b.addCase(fetchUser.fulfilled,(s, a) => { s.loading = false; s.data = a.payload; });
b.addCase(fetchUser.rejected, (s) => { s.loading = false; });
}
});
dispatch(fetchUser(123));Zustand(直接写 async)
js
const useUserStore = create((set) => ({
user: null, loading: false,
fetchUser: async (id) => {
set({ loading: true });
const res = await fetch(`/api/user/${id}`);
set({ user: await res.json(), loading: false });
}
}));Pinia(action 内直接 async)
js
defineStore('user', {
state: () => ({ user: null, loading: false }),
actions: {
async fetchUser(id) {
this.loading = true;
this.user = await fetch(`/api/user/${id}`).then(r => r.json());
this.loading = false;
}
}
});Zustand 和 Pinia 的 async 体验显著优于经典 Redux。
9. 实战案例
详见 examples/ 目录:
redux-counter.js— Redux Toolkit 计数器zustand-counter.jsx— Zustand 计数器(含 selector)pinia-counter.js— Pinia 计数器(Composition API 写法)
demo/index.html — 购物车计数器:对比"props 透传 5 层" vs "Zustand 全局状态"
10. ⚠️ 易踩的坑
Redux 直接改 state
jscase 'add': state.list.push(x); return state; // ❌ 不是不可变更新 case 'add': return { ...state, list: [...state.list, x] }; // ✅RTK 用 Immer 让你"看似可变",但底层仍是不可变。
Zustand 不写 selector → 全字段订阅
jsxconst { count } = useStore(); // ❌ 任何字段变都重渲染 const count = useStore(s => s.count); // ✅Context 当全局状态用 单个 Context 频繁更新会让所有消费者重渲染。频繁变化的状态(如鼠标位置)请用 Zustand/Jotai。
Pinia 解构丢失响应性
jsconst { count } = useCounter(); // ❌ const { count } = storeToRefs(useCounter()); // ✅副作用写进 reducer reducer 必须是纯函数。请求、定时器要放在 thunk / middleware / store action 中。
Redux 存非序列化值
Date、Map、函数等不应进 store(影响 DevTools 和持久化)。Zustand 没用 immer,深层更新写 spread 很累
jsset(s => ({ user: { ...s.user, profile: { ...s.user.profile, age: 18 } } })); // 复杂时引入 immer 中间件 import { immer } from 'zustand/middleware/immer';过度设计 100 行代码的项目硬上 Redux 是浪费。先 useState,再 lift up,再 Context,最后才是 Zustand/Redux。
11. 一句话总结
全局状态管理 = 把"会被多处读写的数据"搬出组件树。React 项目首选 Zustand(轻)或 RTK(严谨),Vue 项目首选 Pinia。原则永远是:能不上就不上,要上就选合适的。
12. 延伸阅读
- Redux Toolkit 官方文档:https://redux-toolkit.js.org/
- Zustand:https://github.com/pmndrs/zustand
- Pinia:https://pinia.vuejs.org/
- 类似方案:Jotai(原子化)、Recoil、Valtio(Proxy)、MobX