React学习:状态管理的中央银行——Zustand

88 阅读6分钟

前言:关于“状态管理”的那点破事

兄弟们,咱们做前端开发的,大概率都经历过被 Redux 支配的恐惧。

那是怎样一种体验?想要修改一个 count,你得先写 Action Type,再写 Action Creator,然后跑到 Reducer 里写 switch-case,最后还得在组件里用 connect 或者 useDispatch 连半天。写完这一套,头发都掉两根了,回头一看,我特么只是想让数字 +1 啊!

后来有了 Context API + useReducer,虽然原生了,但性能优化又成了噩梦(Context 一变,全家重渲染)。

这时候,Zustand 骑着一只可爱的小熊来了。

Zustand(德语“状态”)主打一个极简、轻量、Hooks 风格。它不需要你在外面包一层 Provider,也不需要繁琐的模板代码。今天,咱们就结合一个全栈项目(React + TS + Node)的实战场景,从零开始,把 Zustand 玩得明明白白。


一、 起步:为什么是 Zustand?

在我们的全栈规划中,前端部分是 React + TypeScript。如果把组件比作“切图仔”,那状态管理就是“中央银行”。

Zustand 的核心优势就在于:

  1. 极简主义:API 极少,心智负担几乎为零。
  2. Hooks 优先:完美契合 React 16.8+ 的开发范式。
  3. 灵活的中央集权:支持多 Store,也可以单 Store,随你喜欢。
  4. 不需要 Context Provider:告别组件树顶层那一堆乱七八糟的嵌套。

准备好了吗?咱们直接开整。

环境准备

假设你已经建好了一个 Vite + React + TS 的项目:

npm install zustand

就这一行,完事。


二、 Level 1:从计数器开始(基础用法)

咱们先拿最经典的 Counter 练练手。在 src/store 下新建 counter.ts。

1. 定义状态的“规矩” (Interface)

既然用了 TypeScript,咱们就得讲究点,别满屏 any 乱飞。先定义好咱们的 State 长啥样。

// src/store/counter.ts
import { create } from "zustand";

// 1. 定义接口:状态里有什么,怎么改状态
interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}

2. 创建仓库 (Create Store)

Zustand 的 create 函数是核心。它接收一个回调,回调里有两个关键参数:set 和 get。

  • set: 用于更新状态(你可以把它理解为全局的 setState)。
  • get: 用于获取当前状态(偶尔在 action 里需要读取其他状态时用)。
// 2. 创建 Store
export const useCounterStore = create<CounterState>()((set) => ({
  count: 0, // 初始值
  
  // 动作 Action:直接写函数
  increment: () => set((state) => ({ count: state.count + 1 })),
  
  decrement: () => set((state) => ({ count: state.count - 1 })),
  
  // 直接赋值这种写法也行,set 会自动合并第一层属性
  reset: () => set({ count: 0 }),
}));

知识点
Zustand 的 set 默认是合并更新(Merge),不是替换。也就是说,如果你状态里有 a 和 b,你 set({ a: 1 }),b 不会丢。


三、 Level 2:搞定复杂对象(Todo List 实战)

计数器太简单了,咱们来点真实的。Todo 表结构如下:

1. 类型定义

先在 src/types/index.ts 里把类型锁死:

// src/types/index.ts
export interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

2. Todo Store 实现

在 src/store/todo.ts 里,我们要处理数组的操作:增、删、改。这里展示了如何在 Zustand 里优雅地操作不可变数据。

// src/store/todo.ts
import { create } from "zustand";
import type { Todo } from "../types";

export interface TodoState {
  todos: Todo[];
  addTodo: (text: string) => void;
  toggleTodo: (id: number) => void;
  removeTodo: (id: number) => void;
}

export const useTodoStore = create<TodoState>()((set) => ({
  todos: [],

  // 增:展开旧数组,追加新对象
  addTodo: (text: string) =>
    set((state) => ({
      todos: [
        ...state.todos,
        {
          id: +new Date(), // 简单生成个 ID,实际项目请用 uuid 或后端 ID
          text,
          completed: false,
        },
      ],
    })),

  // 改:Map 遍历,找到目标取反,其他的保持原样
  toggleTodo: (id: number) =>
    set((state) => ({
      todos: state.todos.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      ),
    })),

  // 删:Filter 过滤
  removeTodo: (id: number) =>
    set((state) => ({
      todos: state.todos.filter((todo) => todo.id !== id),
    })),
}));

看看这代码,是不是极其清爽?逻辑和数据在一起,没有 Redux 那种文件跳来跳去的感觉。

3. 在组件中使用

来到 App.tsx,咱们看看怎么用。

// src/App.tsx
import { useState } from "react";
import { useTodoStore } from "./store/todo";

function App() {
  // 就像用 useState 一样简单,直接解构
  const { todos, addTodo, toggleTodo, removeTodo } = useTodoStore();
  const [inputValue, setInputValue] = useState("");

  const handleAdd = () => {
    if (!inputValue.trim()) return;
    addTodo(inputValue);
    setInputValue("");
  };

  return (
    <section>
      <h2>Todos 数量: {todos.length}</h2>
      {/* 输入框区域 */}
      <div>
        <input
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && handleAdd()}
        />
        <button onClick={handleAdd}>Add</button>
      </div>

      {/* 列表区域 */}
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id)}
            />
            <span style={{ textDecoration: todo.completed ? "line-through" : "none" }}>
              {todo.text}
            </span>
            <button onClick={() => removeTodo(todo.id)}>Del</button>
          </li>
        ))}
      </ul>
    </section>
  );
}

export default App;

四、 Level 3:数据持久化(Middleware 中间件)

咱们现在的 Todo List 有个致命弱点:一刷新页面,数据全没了。用户辛辛苦苦写的待办事项瞬间归零,这在生产环境是要被祭天的。

以前咱们得手动 localStorage.setItem,现在 Zustand 自带中间件—— persist。

我们改造一下 todo.ts:

import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware"; // 引入中间件
import type { Todo } from "../types";

// ... TodoState 接口保持不变 ...

export const useTodoStore = create<TodoState>()(
  // 使用 persist 包裹你的核心逻辑
  persist(
    (set, get) => ({
      todos: [],
      addTodo: (text) => set(state => ({ ... })), 
      toggleTodo: (id) => set(state => ({ ... })),
      removeTodo: (id) => set(state => ({ ... })),
    }),
    {
      name: "todos-storage", // 必填:localStorage 里的 key 叫什么
      // storage: createJSONStorage(() => sessionStorage), // 选填:默认是 localStorage,你也可以换成 sessionStorage
    }
  )
);

见证奇迹的时刻:当你写完这段代码,打开浏览器的 Application -> Local Storage,你会发现多了一个 todos-storage。刷新页面,数据依然健在!Zustand 自动帮你处理了序列化和反序列化。


五、 Level 4:异步与用户信息(模拟登录)

在全栈项目中,最常见的场景就是:登录
这是一个典型的异步操作流程:

  1. 调用后端 API。
  2. 等待响应。
  3. 成功 -> 存 User 信息,改登录状态。
  4. 失败 -> 报错。
// src/store/user.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { User } from "../types";

interface UserState {
  isLoggin: boolean;
  userInfo: User | null;
  // 这里的 login 支持接收参数
  login: (params: { username: string; password: string }) => Promise<void>;
  logout: () => void;
}

export const useUserStore = create<UserState>()(
  persist(
    (set) => ({
      isLoggin: false,
      userInfo: null,

      // 异步 Action:直接写 async/await,毫无黑魔法
      login: async ({ username, password }) => {
        // 模拟 API 请求
        // const res = await axios.post('/api/login', { username, password });
        
        console.log("正在登录...", username);
        // 模拟延迟
        await new Promise(resolve => setTimeout(resolve, 1000));
        
        // 模拟后端返回的用户数据
        const mockUser: User = { id: 1, username: username, avatar: "https://i.pravatar.cc/150" };

        // 更新状态
        set({ isLoggin: true, userInfo: mockUser });
      },

      logout: () => set({ isLoggin: false, userInfo: null }),
    }),
    { name: "user-session" }
  )
);

六、 进阶:性能优化

在 App.tsx 中,我们用了这种写法:

// 写法 A (全量订阅)
const { todos, addTodo } = useTodoStore();

这有个小问题:如果 Store 里还有个无关的状态(比如 filterType)变了,虽然 todos 没变,但组件可能会因为 Hook 的返回值变了而重新渲染(Zustand 默认会进行浅比较,通常问题不大,但有极致优化空间)。

专家级写法(Selector 模式)

如果你想极致优化性能,只订阅你需要的那个切片:

// 写法 B (精确订阅)
const todos = useTodoStore((state) => state.todos);
const addTodo = useTodoStore((state) => state.addTodo);

这样,只有当 state.todos 发生变化时,组件才会重渲染。对于大型列表或复杂页面,这是减少 Render 次数的利器。

此外,Zustand 甚至可以在 React 组件外部使用!

// 在非组件文件中使用(例如 axios 拦截器)
import { useUserStore } from "./store/user";

axios.interceptors.request.use((config) => {
  // 直接读取状态,不需要 hooks 规则
  const token = useUserStore.getState().userInfo?.token;
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

总结

Zustand 就像一把瑞士军刀,没有多余的装饰,拿起来就能干活。

  • :包体积极小。
  • :基于 Flux 模型,性能强悍。
  • :支持中间件、DevTools、TS 类型推断。

兄弟们,别再犹豫了。如果你的项目还没上 Redux 那些重型武器,或者你正在从 Vue 转 React 寻找那份“可变状态”的亲切感,Zustand 绝对是你的不二之选