🚀 AI 全栈项目第三天:Zustand —— 给你的应用请一位“德国管家”

57 阅读9分钟

大家好!👋 欢迎回到 AI 全栈项目实战 的第三天。

回顾前两天,我们像个探险家一样:

  1. 第一天:掌握了 React Router,学会了在不同的页面“平行宇宙”间穿梭。
  2. 第二天:穿上了 TypeScript 的“钢铁侠战衣”,让代码健壮得像头牛。

但是,随着我们的应用越来越复杂,我们面临一个新的危机:数据通信的噩梦

想象一下,你(组件A)想把一块蛋糕(数据)传给你的重孙子(组件D),你需要先把蛋糕给儿子(组件B),儿子给孙子(组件C),孙子再给重孙子。这一路下来,蛋糕不仅容易掉,中间层的人(B和C)还莫名其妙被迫参与了传递,明明他们根本不吃蛋糕!这就是 React 开发中臭名昭著的 Prop Drilling(属性透传) 问题。

今天,我们要引入一位强力外援 —— Zustand,来彻底解决这个问题。


⏳ 一、 为什么我们需要“中央银行”?

如果说 React 组件是各自为政的“小家庭”,那么 State Management(状态管理) 就是国家的“中央银行”。

在没有状态管理之前:

  • 组件 = UI + State。数据通常只活在组件内部。
  • 要想共享,只能状态提升(Lift State Up),把数据放到父组件,然后一层层传下去。

有了状态管理之后:

  • Store(仓库):我们将那些需要在多个组件间共享的数据(比如用户信息、购物车、未读消息数),统一存放在一个独立的仓库里。
  • 全局共享:任何组件,不管在哪个角落,都可以直接伸手向仓库要数据,或者修改数据。不需要经过父组件的批准。

为什么选择 Zustand?

市面上有 Redux, MobX, Recoil 等等。为什么要选 Zustand?

  • 名字酷:Zustand 是德语“状态”的意思。
  • 体积小:它的体积只有 1KB 左右,相比 Redux 的“重装坦克”,Zustand 就像一把瑞士军刀。
  • Hooks 风格:如果你喜欢 useState,你就会爱上 Zustand,因为它们长得太像了。
  • 无样板代码:不需要写复杂的 Reducer、Action、Dispatch,上手即用。

🛠️ 二、 环境搭建与初体验

首先,让我们把这位“德国管家”请进我们的项目。打开终端:

npm install zustand

就这一行,搞定。不需要配置什么 Provider,不需要包裹 App 组件,简单得让人不敢相信。


🐻 三、 实战:从简单的计数器开始

我们先不急着写 Todo List,先用一个最简单的计数器(Counter)来理解 Zustand 的核心概念:Store(仓库)State(状态)Action(动作)

3.1 定义“规矩” (Types)与仓库

打开 src/store/counter.ts。我们要复习一下昨天学的 TypeScript,先立规矩。

import { create } from 'zustand'
// 👇 这个 persist 是中间件,后面会细讲,它是用来做持久化的
import { persist } from 'zustand/middleware'

// 1️⃣ TS 复习:定义 State 的接口
// 企业做大做强,必须要有财务制度。
// 这里定义了仓库里有哪些数据(count),以及有哪些修改数据的方法(increment, decrement...)
interface CounterState {
    count: number;          // 状态:计数
    increment: () => void;  // 动作:加 1
    decrement: () => void;  // 动作:减 1
    reset: () => void;      // 动作:重置
}

// 2️⃣ 创建仓库
// create<CounterState>():这里用到了 TS 的泛型,告诉 create 函数,我们要创建的仓库必须符合上面定义的形状。
// persist(...):这是一个中间件,它的作用是把状态自动存到 localStorage 里,刷新页面数据不丢失!
export const useCounterStore = create<CounterState>()(
    persist(
        (set) => ({
            // 3️⃣ 初始化状态
            count: 0,

            // 4️⃣ 定义修改状态的方法 (Action)
            // 重点来了!set 是 Zustand 提供的核心方法,用来更新状态。
            
            // 写法一:函数式更新
            // 场景:新状态依赖于旧状态(比如 count 要在原来的基础上 +1)
            // set((state) => ({ count: state.count + 1 }))
            // 解释:set 接收一个函数,这个函数的参数是当前的 state。
            // 我们返回一个对象 { count: ... },Zustand 会自动把这个对象合并到总状态里。
            // 箭头函数返回对象一定要加上括号,不然无法分辨你是要返回对象,还是不返回
            increment: () => set((state) => ({ count: state.count + 1 })),

            decrement: () => set((state) => ({ count: state.count - 1 })),

            // 写法二:直接更新
            // 场景:新状态不依赖旧状态(比如重置为 0)
            // 直接传一个对象给 set 即可
            reset: () => set({ count: 0 }),
        }),
        {
            //这是存到 localStorage 里的 key 名称
            name: 'counter-storage', 
        }
    )
);

🔍 深度解析 set 很多新手在这里会晕。set 是 Zustand 的灵魂。

  • 它是响应式的:一旦你调用 set 改变了数据,所有使用到这个数据的 React 组件都会自动重新渲染。
  • 它是合并策略:你只需要返回变化了的那部分属性。比如状态里有 {a: 1, b: 2},你 set({a: 99}),结果会自动变成 {a: 99, b: 2},不用担心 b 丢了(注意:这仅限于第一层属性)。

3.2 在组件中调用

现在仓库建好了,我们去 App.tsx 里使用它。你会发现,它用起来和普通的 Hook 一模一样

import { useCounterStore } from './store/counter'

function App() {
  // 5️⃣ 像去超市进货一样,按需解构
  // useCounterStore() 执行后返回的就是整个 store 对象
  // 我们解构出我们需要的数据和方法
  const { 
    count, 
    increment, 
    decrement, 
    reset 
  } = useCounterStore()

  return (
    <div className="card">
      <button onClick={increment}>
        count is {count}
      </button>
      <button onClick={decrement}>
        count is {count}
      </button>
      {/* 这里的 onClick={reset} 有个小知识点,待会儿讲⭐ */}
      <button onClick={reset}>
        Reset
      </button>
    </div>
  )
}

🎉 效果: 当你点击按钮时,数字会变。更神奇的是,因为我们用了 persist 中间件,你刷新浏览器,数字依然停留在那里!这就是“持久化”的魔力。


📝 四、 进阶实战:重构 Todo List

计数器太简单了,我们来搞点复杂的:Todo List。这里涉及到数组的操作(增、删、改)。

4.1 定义数据类型

先去 src/types/index.ts 定义一下 Todo 的长相。

export interface Todo {
    id: number;
    text: string;
    completed: boolean;
}

4.2 创建 Todo Store

打开 src/store/todo.ts。这里是核心逻辑所在。

import { create } from 'zustand';
import type { Todo } from '../types/index'; // 引入刚才定义的类型
import { persist } from 'zustand/middleware';

// 1️⃣ 定义 TodoState 接口
export interface TodoState {
    todos: Todo[]; // 这是一个 Todo 类型的数组
    addTodo: (text: string) => void;
    toggleTodo: (id: number) => void;
    removeTodo: (id: number) => void;
}

// 2️⃣ 创建 Store
export const useTodoStore = create<TodoState>()(
    persist(
        (set) => ({
            todos: [], // 初始状态是空数组

            // Action 1: 添加任务
            addTodo: (text: string) => 
                set((state) => ({
                    // ⚠️ 注意:我们要遵循“不可变性”原则
                    // 不能写 state.todos.push(...),因为这会修改原对象
                    // 正确做法:创建一个新数组,把旧的解构进去,再放一个新的
                    todos: [
                        ...state.todos, 
                        { id: Date.now(), text, completed: false }
                    ]
                })),

            // Action 2: 切换完成状态
            toggleTodo: (id: number) =>
                set((state) => ({
                    // 使用 map 遍历数组
                    // 找到 id 匹配的那一项,复制它 (...todo),并把 completed 取反
                    // id 不匹配的,原样返回
                    todos: state.todos.map((todo) =>
                        todo.id === id ? { ...todo, completed: !todo.completed } : todo
                    ),
                })),

            // Action 3: 删除任务
            removeTodo: (id: number) =>
                set((state) => ({
                    // 使用 filter 过滤
                    // 只要 id 不等于目标 id 的,都留下来
                    todos: state.todos.filter((todo) => todo.id !== id),
                })),
        }),
        {
            name: 'todo-storage', // localStorage 中的名字
        }
    )
);

💡 核心思想: 在 React 和 Zustand 中,永远不要直接修改 State(比如 arr.push),而是要替换 State(返回一个新的数组)。这样 React 才知道数据变了,需要更新界面。


4.3 在组件中实现交互

最后,我们回到 App.tsx,把 Todo 部分接上。

import { useState } from 'react'
import { useTodoStore } from './store/todo'

function App() {
  // ... count 的代码 ...

  // 1️⃣ 获取 Todo 的状态和方法
  const {
    todos,
    addTodo,
    toggleTodo,
    removeTodo
  } = useTodoStore();

  // 这个是输入框的临时状态,不需要放进全局 store,因为它只在这个组件用
  const [inputValue, setInputValue] = useState<string>('');
  
  const handleAdd = () => {
    if (!inputValue.trim()) return;
    addTodo(inputValue); // 调用 Store 里的方法
    setInputValue('');
  }

  return (
    <section>
        <input 
            type="text" 
            placeholder="Add a new todo"
            value={inputValue}
            onChange={(e) => setInputValue(e.target.value)} // 受控组件的双向绑定
            onKeyDown={(e) => e.key === 'Enter' && handleAdd()} 
          />
          
        <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)}>Remove</button>
              
            </li>
          ))}
        </ul>
    </section>
  )
}

💡 五、 面试必问:箭头函数的玄机

在刚才的代码中,细心的同学可能发现了一个有趣的细节:

在调用 reset 时,我们是这样写的:

<button onClick={reset}>Reset</button>

但是在调用 removeTodo 时,我们却是这样写的:

<button onClick={() => removeTodo(todo.id)}>Remove</button>

为什么一个直接写,一个要套个箭头函数? 这是一个非常经典的面试题!

1. onClick={reset} (函数引用)

  • reset 本身是一个函数:() => void
  • onClick 期望接收一个函数。
  • 当我们写 onClick={reset} 时,我们是把 reset 这个函数的引用传给了按钮。
  • 只有当用户点击按钮时,React 才会去执行 reset()
  • 适用场景:函数不需要额外的参数(或者只需要事件对象 event)。

2. onClick={() => removeTodo(todo.id)} (函数执行的包装)

  • removeTodo 需要一个参数 id
  • 如果我们写成 onClick={removeTodo(todo.id)} 会发生什么?
    • JS 会在渲染的时候立即执行 removeTodo(todo.id)
    • 这会导致页面一打开,所有任务都被删了(甚至可能导致无限循环渲染)。
  • 如果我们写成 onClick={removeTodo} 会发生什么?
    • JS 会把点击事件对象 event 当作 id 传进去,导致逻辑错误。
  • 解决方案:我们要传给 onClick 一个新的函数
    • () => removeTodo(todo.id) 就是一个临时创建的函数。
    • 当点击发生时,React 执行这个箭头函数,箭头函数内部再去执行 removeTodo(todo.id)
    • 这就实现了“点击时才调用,且带上了参数”。

📚 六、 总结

今天我们完成了一次架构升级,从“刀耕火种”的组件内部 State,进化到了“现代化”的 Zustand 全局状态管理。

知识点回顾:

  1. Zustand 是一个轻量级、基于 Hooks 的状态管理库。
  2. Store 通过 create 创建,用来存放数据和修改数据的方法。
  3. set 函数用于更新状态,支持合并更新和函数式更新。
  4. persist 中间件可以轻松实现数据持久化到 localStorage。
  5. Hooks 调用:在组件中 useStore() 即可获取数据,像使用 useState 一样自然。
  6. 事件绑定:区分 onClick={func}onClick={() => func(arg)} 的区别。

现在的代码,数据流向清晰,逻辑和 UI 分离,就算以后项目扩大十倍,我们也能从容应对!

下期预告: 数据有了,路由有了,但是我们的数据还是“死”的(保存在本地)。真正的全栈应用,数据应该来自服务器。下一节,我们将进入 后端开发 的世界,搭建属于我们自己的 API 服务器!


作业: 尝试给 Todo List 增加一个“编辑”功能,你需要修改 store 里的状态吗?需要在 App.tsx 里增加新的逻辑吗?动手试试吧!

如果你觉得这篇文章对你有帮助,请点赞、收藏、转发!我们在全栈的路上不见不散!🚀