状态管理
当数据需要跨组件共享时,全局状态管理登场
🎯 学习目标
学完本章,你将能够:
- 能够理解何时需要状态管理以及不同方案的适用场景
- 能够使用 Zustand 创建类型安全的 React 全局状态
- 能够使用 Redux Toolkit 创建 slice 与异步 thunk
- 能够使用 Pinia 创建类型安全的 Vue 3 状态管理
- 能够运用 TanStack Query 管理服务端状态与缓存
你有没有想过
购物车的数据在 Header 显示数量、在页面显示列表、在弹窗显示总价——它们怎么保持同步?
如果把购物车数据放在父组件,通过 Props 一层层往下传,传到第五层组件时你已经写了五个 :cart="cart"。这就是 prop drilling 问题——层级越深,代码越难维护。
状态管理就是解决这个问题的:把共享数据放到一个"全局仓库"里,任何组件都能直接读写,不需要层层传递。
何时需要
不是所有状态都需要全局管理
需要状态管理
- 多个组件共享同一份数据
- 组件层级太深,Props 传递繁琐
- 需要跨组件协调状态变化
不需要状态管理
- 状态只在单个组件内用——useState / ref
- 父子组件传递——Props / emit
- 简单跨层传递——Context / provide-inject
| 框架 | 推荐方案 | 过时方案 |
|---|---|---|
| React | Zustand、Jotai | Redux(太重) |
| Vue | Pinia | Vuex(已过时) |
Part 1: React 状态
Zustand 轻量简洁,Redux Toolkit 功能完整
React 生态有两大主流方案。Zustand 适合中小项目,上手快;Redux Toolkit 适合大型团队,工具链成熟。两者都是好选择,关键看你的项目规模和团队偏好。
Zustand(推荐)
极简的 React 状态管理库,API 少到你不敢相信
$ npm install zustand
// 定义 store
import { create } from "zustand";
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartStore {
items: CartItem[];
addItem: (item: Omit<CartItem, "quantity">) => void;
removeItem: (id: string) => void;
clearCart: () => void;
total: () => number;
}
const useCartStore = create<CartStore>((set, get) => ({
items: [],
addItem: (item) => set((state) => {
const existing = state.items.find(i => i.id === item.id);
if (existing) {
return {
items: state.items.map(i =>
i.id === item.id
? { ...i, quantity: i.quantity + 1 }
: i
),
};
}
return { items: [...state.items, { ...item, quantity: 1 }] };
}),
removeItem: (id) => set((state) => ({
items: state.items.filter(i => i.id !== id),
})),
clearCart: () => set({ items: [] }),
total: () => get().items.reduce(
(sum, i) => sum + i.price * i.quantity, 0
),
}));
Zustand 组件中使用
选择器模式,只订阅需要的数据
function Cart() {
// 用选择器只订阅需要的数据,避免不必要的渲染
const items = useCartStore((s) => s.items);
const total = useCartStore((s) => s.total());
const removeItem = useCartStore((s) => s.removeItem);
return (
<div>
{items.map(item => (
<div key={item.id}>
{item.name} x{item.quantity} = ¥{item.price * item.quantity}
<button onClick={() => removeItem(item.id)}>
删除
</button>
</div>
))}
<p>总计: ¥{total}</p>
</div>
);
}
Zustand 的核心优势:没有 Provider 包裹、没有 reducer 样板代码、组件外也能使用(比如在 axios 拦截器里)。
Redux Toolkit
功能更完整,适合大型团队协作
$ npm install @reduxjs/toolkit react-redux
// store/cartSlice.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface CartState { items: CartItem[]; }
const initialState: CartState = { items: [] };
const cartSlice = createSlice({
name: "cart",
initialState,
reducers: {
addItem(state, action: PayloadAction<Omit<CartItem, "quantity">>) {
const existing = state.items.find(
i => i.id === action.payload.id
);
if (existing) {
existing.quantity++;
} else {
state.items.push({ ...action.payload, quantity: 1 });
}
},
removeItem(state, action: PayloadAction<string>) {
state.items = state.items.filter(
i => i.id !== action.payload
);
},
},
});
export const { addItem, removeItem } = cartSlice.actions;
export default cartSlice.reducer;
Redux Toolkit 使用
useSelector 读取,useDispatch 写入
import { useSelector, useDispatch } from "react-redux";
import { addItem } from "./store/cartSlice";
function ProductList() {
const items = useSelector((state: RootState) => state.cart.items);
const dispatch = useDispatch();
return (
<button onClick={() => dispatch(
addItem({ id: "1", name: "商品", price: 99 })
)}>
加入购物车
</button>
);
}
Zustand vs Redux Toolkit
- Zustand:更轻量,API 更简洁,学习成本低
- Redux Toolkit:生态更成熟,DevTools 强大,适合大团队
- 新项目优先选 Zustand,除非团队已有 Redux 经验
Part 2: Vue 状态
Pinia 是 Vue 3 的官方推荐方案,TS 支持一流
Pinia 用 Composition API 的写法定义 store,天然支持 TypeScript。比 Vuex 更轻量、更直观,是 Vue 3 项目的唯一推荐方案。
Pinia 定义 Store
Setup 语法,TS 支持最完美
$ npm install pinia
// stores/cart.ts
import { defineStore } from "pinia";
// Setup 语法(推荐)
export const useCartStore = defineStore("cart", () => {
const items = ref<CartItem[]>([]);
const total = computed(() =>
items.value.reduce((sum, i) => sum + i.price * i.quantity, 0)
);
const itemCount = computed(() =>
items.value.reduce((sum, i) => sum + i.quantity, 0)
);
function addItem(item: Omit<CartItem, "quantity">) {
const existing = items.value.find(i => i.id === item.id);
if (existing) {
existing.quantity++;
} else {
items.value.push({ ...item, quantity: 1 });
}
}
function removeItem(id: string) {
items.value = items.value.filter(i => i.id !== id);
}
function clearCart() {
items.value = [];
}
return { items, total, itemCount, addItem, removeItem, clearCart };
});
Pinia 组件中使用
直接访问,完全类型推断
<script setup lang="ts">
import { useCartStore } from "@/stores/cart";
import { storeToRefs } from "pinia";
const cart = useCartStore();
// 直接访问 state / getter / action
cart.items; // CartItem[]
cart.total; // number(自动解包)
cart.addItem({ id: "1", name: "商品", price: 99 });
// 解构时保持响应性
const { total, itemCount } = storeToRefs(cart);
const { addItem, removeItem } = cart; // actions 不需要 storeToRefs
</script>
<template>
<div>
{{ cart.itemCount }} 件商品,总计 ¥{{ cart.total }}
</div>
</template>
storeToRefs 解构,否则会丢失响应性。action 可以直接解构,因为它本来就是普通函数。
Pinia vs Vuex
为什么 Pinia 是 Vue 3 的官方推荐
| 对比项 | Pinia | Vuex |
|---|---|---|
| TS 支持 | 一流,无需类型体操 | 需要大量辅助类型 |
| mutations | 没有,直接修改 state | 必须通过 mutation 修改 |
| 打包体积 | 约 1KB | 较大 |
| Composition API | 原生支持 | 需要额外适配 |
| DevTools | 支持 | 支持 |
一句话总结:新项目用 Pinia,老项目用 Vuex。 Vuex 4 还能用在 Vue 3 项目中,但官方已经不推荐了。
Part 3: 异步操作
真实项目离不开 API 调用,异步怎么管?
前面的例子都是同步操作。但真实项目中,数据来自后端 API,你需要处理 loading、error、成功三种状态。每个库处理异步的方式不同,但思路一致:先设 loading,成功写数据,失败存错误。
Zustand 异步
直接写 async action,就这么简单
interface ItemStore {
items: Item[];
loading: boolean;
error: string | null;
fetchItems: () => Promise<void>;
}
const useItemStore = create<ItemStore>((set) => ({
items: [],
loading: false,
error: null,
fetchItems: async () => {
set({ loading: true, error: null });
try {
const res = await fetch("/api/items");
if (!res.ok) throw new Error("请求失败");
const data: Item[] = await res.json();
set({ items: data, loading: false });
} catch (e) {
set({ error: (e as Error).message, loading: false });
}
},
}));
// 组件中使用
function ItemList() {
const { items, loading, error, fetchItems } = useItemStore();
useEffect(() => { fetchItems(); }, []);
if (loading) return <p>加载中...</p>;
if (error) return <p>出错了: {error}</p>;
return <ul>{items.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
}
Zustand 的异步最简单:action 本身就是普通 async 函数,不需要额外的中间件或配置。
RTK 异步
createAsyncThunk 处理三种状态
// store/itemSlice.ts
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
export const fetchItems = createAsyncThunk<Item[]>(
"items/fetchAll",
async () => {
const res = await fetch("/api/items");
return res.json();
}
);
const itemSlice = createSlice({
name: "items",
initialState: {
data: [] as Item[],
loading: false,
error: null as string | null,
},
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchItems.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchItems.fulfilled, (state, action) => {
state.data = action.payload;
state.loading = false;
})
.addCase(fetchItems.rejected, (state, action) => {
state.error = action.error.message ?? "未知错误";
state.loading = false;
});
},
});
createAsyncThunk 只处理"触发 → 完成/失败"的流程。如果你需要更复杂的场景(轮询、自动重试、乐观更新),建议直接用 TanStack Query。
Pinia 异步
action 里直接写 async,和普通函数一样
export const useItemStore = defineStore("items", () => {
const items = ref<Item[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
async function fetchItems() {
loading.value = true;
error.value = null;
try {
const res = await fetch("/api/items");
items.value = await res.json();
} catch (e) {
error.value = (e as Error).message;
} finally {
loading.value = false;
}
}
return { items, loading, error, fetchItems };
});
三种方案对比
| 方案 | 复杂度 | 特点 |
|---|---|---|
| Zustand | 最低 | 直接写 async action |
| Pinia | 低 | 原生 async,无需额外配置 |
| RTK | 中等 | 需 createAsyncThunk + extraReducers |
Part 4: 中间件
让 store 自动存到 localStorage
用户刷新页面后,购物车数据还在吗?默认不会。持久化中间件帮你自动把 store 数据存到 localStorage,下次打开时自动恢复。这是生产项目最常见的中间件需求。
Zustand 持久化
一行代码实现 localStorage 存储
import { create } from "zustand";
import { persist } from "zustand/middleware";
const useCartStore = create<CartStore>()(
persist(
(set, get) => ({
items: [],
addItem: (item) => { /* ... */ },
removeItem: (id) => { /* ... */ },
clearCart: () => set({ items: [] }),
total: () => get().items.reduce(
(sum, i) => sum + i.price * i.quantity, 0
),
}),
{
name: "cart-storage", // localStorage 的 key
partialize: (state) => ({
items: state.items, // 只存 items,不存 action
}),
}
)
);
partialize 很重要:只持久化数据,不持久化函数。避免把 action 序列化到 localStorage 里。
其他中间件
Redux 用插件,Pinia 用插件
Redux Toolkit 日志中间件
const logger: Middleware = (store) =>
(next) => (action) => {
console.log("dispatching", action);
const result = next(action);
console.log("next state", store.getState());
return result;
};
const store = configureStore({
reducer: rootReducer,
middleware: (getDefault) =>
getDefault().concat(logger),
});
Pinia 持久化插件
// 安装: npm i pinia-plugin-persistedstate
import piniaPluginPersistedstate
from "pinia-plugin-persistedstate";
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
// 在 store 中启用
export const useCartStore =
defineStore("cart", () => {
const items = ref<CartItem[]>([]);
// ...
return { items };
}, {
persist: {
key: "cart",
pick: ["items"],
},
});
Part 5: 服务端状态
API 数据不适合放在全局 store 里
从后端获取的数据(用户列表、商品详情)本质上是服务端状态的"缓存"。它有过期时间、需要重新获取、可能被其他人修改。用 Zustand/Redux 管理这类数据,你需要手写 loading、error、缓存逻辑——太累了。
TanStack Query
专为服务端数据设计的"状态管理器"
// 安装: npm i @tanstack/react-query
import { useQuery, useMutation, useQueryClient }
from "@tanstack/react-query";
// 查询数据:自动管理 loading / error / data
function UserList() {
const { data, isLoading, error } = useQuery<User[]>({
queryKey: ["users"],
queryFn: () => fetch("/api/users").then(r => r.json()),
staleTime: 5 * 60 * 1000, // 5分钟内不重新请求
});
if (isLoading) return <p>加载中...</p>;
if (error) return <p>出错了</p>;
return <ul>{data!.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
// 修改数据:自动让相关缓存失效
function AddUser() {
const client = useQueryClient();
const mutation = useMutation({
mutationFn: (name: string) =>
fetch("/api/users", {
method: "POST",
body: JSON.stringify({ name }),
}),
onSuccess: () => {
client.invalidateQueries({ queryKey: ["users"] });
},
});
return <button onClick={() => mutation.mutate("新用户")}>添加</button>;
}
选哪个?
服务端状态和本地状态用不同工具
用 TanStack Query / SWR
- 数据来自 API,需要缓存和自动刷新
- 列表页、详情页、搜索结果
- 需要乐观更新、分页、无限滚动
用 Zustand / Pinia
- 纯客户端状态:主题、语言、侧边栏开关
- 购物车、表单草稿、本地设置
- 跨组件共享,但不涉及 API
最佳实践:两者可以共存。TanStack Query 管 API 数据,Zustand/Pinia 管客户端 UI 状态。很多生产项目就是这么搭配的。
Part 6: Store 拆分
一个大 store 还是多个小 store?
项目变大后,把所有状态塞进一个 store 会变得难以维护。合理的拆分能让代码更清晰,也能避免不必要的组件重渲染。但拆太碎又会增加复杂度——需要找到一个平衡点。
组合模式
三种框架各自的拆分方式
Zustand:多个 store
// 按领域拆分,各自独立
const useCartStore = create(/* ... */);
const useUserStore = create(/* ... */);
const useThemeStore = create(/* ... */);
// 一个 store 也能引用另一个
const useCartStore = create((set, get) => ({
items: [],
userName: () =>
useUserStore.getState().name,
}));
Pinia:store 互相引用
export const useCartStore =
defineStore("cart", () => {
const items = ref<CartItem[]>([]);
function checkout() {
// 直接使用另一个 store
const user = useUserStore();
api.checkout(user.id, items.value);
}
return { items, checkout };
});
Redux 的方式:用多个 slice 组合成一个 rootReducer。每个 slice 管一个领域(cart、user、theme),最终通过 configureStore 合并。这也是 Redux "单一 store" 哲学的体现。
拆分原则
按领域拆分,按团队维护
该拆的时候
- store 超过 200 行,难以快速定位
- 不同团队成员负责不同模块
- 某个 state 频繁更新导致无关组件重渲染
- 需要按功能模块独立测试
不必拆的时候
- 项目小,state 总共不到 10 个字段
- state 之间高度耦合,拆开后互相引用更多
- 拆分后代码可读性反而下降
Part 7: DevTools
调试状态问题的利器
状态 bug 最难排查:数据不知道什么时候被改了,改成了什么。DevTools 让你能看到每一次 state 变化的历史记录,甚至能"时光倒流"回到之前的状态。
调试实战
浏览器扩展帮你追踪每一次变更
Redux DevTools
安装浏览器扩展后自动支持 Redux Toolkit。能做时间旅行调试:点击任意历史 action,state 回到那一刻。排查"数据怎么变了"的问题非常有效。
// Zustand 也能接入:
import { devtools } from "zustand/middleware";
const useStore = create(
devtools((set) => ({
count: 0,
inc: () => set(
{ count: ++n }, { name: "increment" }
),
}))
);
Pinia DevTools
安装 Vue DevTools 后,Pinia store 自动出现在面板中。可以直接查看、编辑 state,追踪 action 调用历史。
// 无需额外配置
// Vue DevTools 自动识别 Pinia store
// 在 "Pinia" 面板中可以:
// - 查看所有 store 的 state
// - 手动修改 state 测试
// - 时间旅行回到之前的状态
调试技巧:当组件表现异常时,先打开 DevTools 看 state 是不是你预期的值。如果是,问题在渲染层;如果不是,追踪是哪个 action 修改了它。
Part 8: 数据规范化
数据量大时,数组不够用
小型项目用数组存列表数据没问题。但当中等规模的应用需要频繁查找、更新单条数据时,数组的 find 操作是 O(n) 的。规范化(Normalization)把数组变成按 ID 索引的对象,查找变成 O(1)。
规范化存储
用 Record<string, T> 替代数组
之前:数组存储(慢)
interface State {
items: Item[];
}
// 更新某个 item:O(n)
state.items.map(item =>
item.id === targetId
? { ...item, done: true }
: item
);
// 查找某个 item:O(n)
state.items.find(
i => i.id === targetId
);
之后:按 ID 索引(快)
interface State {
entities: Record<string, Item>;
ids: string[];
}
// 更新某个 item:O(1)
state.entities[targetId] = {
...state.entities[targetId],
done: true,
};
// 查找某个 item:O(1)
state.entities[targetId];
Redux Toolkit 提供 createEntityAdapter:自动帮你管理 entities + ids 的数据结构,自带 CRUD 方法。适合管理大量同类实体(用户列表、商品列表等)。
选型指南
一个流程图帮你做决定
flowchart TD
A[需要共享状态?] -->|否| B[用组件内部 state]
A -->|是| C[跨几个组件?]
C -->|2-3个, 父子关系| D[用 Props / emit]
C -->|多个, 同层级| E[用 Context / provide-inject]
C -->|大量组件| F[用状态管理库]
F -->|React| G[Zustand 或 Redux Toolkit]
F -->|Vue| H[Pinia]
常见排错
问题一:Zustand store 的值变了,但组件没更新
原因:直接修改了对象属性,没有创建新引用。
修复:用 set 返回新对象:set({ items: [...state.items, newItem] })。
问题二:Pinia 解构后数据不更新
原因:直接解构 store 丢失了响应性。
修复:用 storeToRefs 解构 state 和 getter。
问题三:Redux dispatch 后组件没更新
原因:reducer 中直接修改了 state(mutation),没有返回新的 state。
修复:Redux Toolkit 内部使用了 Immer,可以直接写"修改"语法,它会自动生成不可变更新。
试一试
练习一(React)
任务:用 Zustand 创建一个 useTodoStore,支持添加、删除、切换完成状态三个 action。在组件中展示列表并实现交互。
// 提示:interface Todo { id: string; title: string; done: boolean }
// 你的代码:
参考答案
interface Todo {
id: string; title: string; done: boolean;
}
interface TodoStore {
todos: Todo[];
add: (title: string) => void;
remove: (id: string) => void;
toggle: (id: string) => void;
}
const useTodoStore = create<TodoStore>((set) => ({
todos: [],
add: (title) => set((s) => ({
todos: [...s.todos, { id: Date.now().toString(), title, done: false }],
})),
remove: (id) => set((s) => ({
todos: s.todos.filter(t => t.id !== id),
})),
toggle: (id) => set((s) => ({
todos: s.todos.map(t =>
t.id === id ? { ...t, done: !t.done } : t
),
})),
}));
练习二(Vue)
任务:用 Pinia 创建一个 useCounterStore,包含 count(ref)、doubleCount(computed)、increment 和 reset(action)。在组件中展示。
// 提示:用 Setup 语法写 defineStore
// 你的代码:
参考答案
export const useCounterStore = defineStore("counter", () => {
const count = ref(0);
const doubleCount = computed(() => count.value * 2);
function increment() { count.value++; }
function reset() { count.value = 0; }
return { count, doubleCount, increment, reset };
});
📝 要点总结
本章核心知识点
选型指南
Zustand(轻量)、Redux Toolkit(规范)、Pinia(Vue)、TanStack Query(服务端)
Zustand
create 工厂、类型推断、中间件持久化
Redux Toolkit
createSlice、createAsyncThunk、类型化 store
Pinia
defineStore、组合式 API、类型推导
TanStack Query
useQuery、useMutation、缓存管理与失效策略
下一章
状态管理搞定了。下一章学习 前端测试——怎么保证你写的代码不出 bug?用测试来守护你的代码质量。