大家好!👋 欢迎回到 AI 全栈项目实战 的第三天。
回顾前两天,我们像个探险家一样:
- 第一天:掌握了 React Router,学会了在不同的页面“平行宇宙”间穿梭。
- 第二天:穿上了 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)。 - 这会导致页面一打开,所有任务都被删了(甚至可能导致无限循环渲染)。
- JS 会在渲染的时候立即执行
- 如果我们写成
onClick={removeTodo}会发生什么?- JS 会把点击事件对象
event当作id传进去,导致逻辑错误。
- JS 会把点击事件对象
- 解决方案:我们要传给
onClick一个新的函数。() => removeTodo(todo.id)就是一个临时创建的函数。- 当点击发生时,React 执行这个箭头函数,箭头函数内部再去执行
removeTodo(todo.id)。 - 这就实现了“点击时才调用,且带上了参数”。
📚 六、 总结
今天我们完成了一次架构升级,从“刀耕火种”的组件内部 State,进化到了“现代化”的 Zustand 全局状态管理。
知识点回顾:
- Zustand 是一个轻量级、基于 Hooks 的状态管理库。
- Store 通过
create创建,用来存放数据和修改数据的方法。 - set 函数用于更新状态,支持合并更新和函数式更新。
- persist 中间件可以轻松实现数据持久化到 localStorage。
- Hooks 调用:在组件中
useStore()即可获取数据,像使用useState一样自然。 - 事件绑定:区分
onClick={func}和onClick={() => func(arg)}的区别。
现在的代码,数据流向清晰,逻辑和 UI 分离,就算以后项目扩大十倍,我们也能从容应对!
下期预告: 数据有了,路由有了,但是我们的数据还是“死”的(保存在本地)。真正的全栈应用,数据应该来自服务器。下一节,我们将进入 后端开发 的世界,搭建属于我们自己的 API 服务器!
作业:
尝试给 Todo List 增加一个“编辑”功能,你需要修改 store 里的状态吗?需要在 App.tsx 里增加新的逻辑吗?动手试试吧!
如果你觉得这篇文章对你有帮助,请点赞、收藏、转发!我们在全栈的路上不见不散!🚀