状态管理

当数据需要跨组件共享时,全局状态管理登场

🎯 学习目标

学完本章,你将能够:

  • 能够理解何时需要状态管理以及不同方案的适用场景
  • 能够使用 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
框架推荐方案过时方案
ReactZustand、JotaiRedux(太重)
VuePiniaVuex(已过时)

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>
解构 store 时注意:state 和 getter 必须用 storeToRefs 解构,否则会丢失响应性。action 可以直接解构,因为它本来就是普通函数。

Pinia vs Vuex

为什么 Pinia 是 Vue 3 的官方推荐

对比项PiniaVuex
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"],
    },
  });
常见问题:localStorage 容量约 5MB。如果 store 数据量大,考虑用 IndexedDB 或只持久化关键字段。

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 状态。很多生产项目就是这么搭配的。

常见误区:不要把 API 数据存到 Zustand 里再手写 loading/error 逻辑。TanStack Query 已经帮你做了,而且做得更好(自动重试、窗口聚焦刷新、请求去重)。

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]

常见排错

状态管理不是万能药。 如果只是两个组件之间传数据,用 Props 就够了。过度使用全局状态反而会让代码更难理解。

问题一: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?用测试来守护你的代码质量。

1 / X