Skip to content

第 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-redux

createSlice

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 ToolkitZustandPiniaContext+Reducer
适用框架React 主,能用别的React/Vue/Solid 通用Vue 专属React
体积~13 KB~1 KB~6 KB0(内置)
模板代码中(slice 合并简化)极少
学习曲线中(需理解 action/reducer)
Provider需要不需要需要(pinia 实例)需要
选择器useSelectoruseStore(selector)自动响应式需自己 memoize
异步createAsyncThunk / RTK Queryaction 内直接 asyncaction 内直接 async自己写 effect
DevTools⭐⭐⭐⭐⭐(业界标杆)⭐⭐⭐(middleware)⭐⭐⭐⭐
时间旅行部分
持久化redux-persistpersist 中间件pinia-plugin-persistedstate自己写
推荐场景大型项目、需要严格规范中小项目、追求简洁Vue 项目首选简单跨层级共享

7. 选型决策树

你需要全局状态吗?

├─ 否 → useState / ref 即可

└─ 是 → 用什么框架?

    ├─ Vue → Pinia(官方推荐,结束)

    └─ React → 项目规模?

        ├─ 小(共享几个字段)→ Context + useReducer 或 Zustand

        ├─ 中 → Zustand(极简、好维护)

        └─ 大(多人协作、严格规范、复杂异步)→ Redux Toolkit

8. 异步处理对比

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. ⚠️ 易踩的坑

  1. Redux 直接改 state

    js
    case 'add': state.list.push(x); return state; // ❌ 不是不可变更新
    case 'add': return { ...state, list: [...state.list, x] }; // ✅

    RTK 用 Immer 让你"看似可变",但底层仍是不可变。

  2. Zustand 不写 selector → 全字段订阅

    jsx
    const { count } = useStore(); // ❌ 任何字段变都重渲染
    const count = useStore(s => s.count); // ✅
  3. Context 当全局状态用 单个 Context 频繁更新会让所有消费者重渲染。频繁变化的状态(如鼠标位置)请用 Zustand/Jotai。

  4. Pinia 解构丢失响应性

    js
    const { count } = useCounter(); // ❌
    const { count } = storeToRefs(useCounter()); // ✅
  5. 副作用写进 reducer reducer 必须是纯函数。请求、定时器要放在 thunk / middleware / store action 中。

  6. Redux 存非序列化值DateMap、函数等不应进 store(影响 DevTools 和持久化)。

  7. Zustand 没用 immer,深层更新写 spread 很累

    js
    set(s => ({ user: { ...s.user, profile: { ...s.user.profile, age: 18 } } }));
    // 复杂时引入 immer 中间件
    import { immer } from 'zustand/middleware/immer';
  8. 过度设计 100 行代码的项目硬上 Redux 是浪费。先 useState,再 lift up,再 Context,最后才是 Zustand/Redux。


11. 一句话总结

全局状态管理 = 把"会被多处读写的数据"搬出组件树。React 项目首选 Zustand(轻)或 RTK(严谨),Vue 项目首选 Pinia。原则永远是:能不上就不上,要上就选合适的


12. 延伸阅读