Lesson 09:全局状态管理 — Zustand入门与项目状态共享
🧩 本节信息卡(学习前先看)
- 阶段定位:Phase 2(进阶篇)
- 推荐时长:75~120 分钟(首次学习)
- 先修要求:完成 Lesson 08,已搭建项目侧栏和动态看板
- 学习产出:共享项目列表、删除项目,并使用稳定selector订阅数据
✅ 本节完成标准(自检清单)
- [ ] 我可以独立复现文中的核心代码片段
- [ ] 我能解释“为什么这样实现”,而不只是“照着写”
- [ ] 我记录了至少 1 个踩坑点和修复方法
🧭 本节统一学习流程
- 学习目标:先明确本节要解决的业务问题与核心 API。
- 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
- 原理深挖:理解为什么这样设计,以及常见误区。
- 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
- 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。
建议节奏:阅读 20% + 编码 60% + 复盘 20%。
🎯 本节目标:解决 React 组件间跨级传参的痛点,使用 Zustand 搭建全局状态管理方案。
📦 本节产出:将散落在各个组件的 Sidebar 菜单数据和项目统计数据抽取为全局 store,让两个视图订阅同一份状态。
一、为什么需要全局状态管理?
在 Phase 1 (Todo App) 中,所有数据都存在根组件 App.tsx 的 useState 或 useReducer 里,然后一层一层地通过 Props 向下传。
这叫 状态提升 (State Lifting)。它的缺点是: 当组件层级极深时,中间层的组件(并不需要数据的组件)也被迫接收和传递 Props,这被称为 Props 钻取 (Prop Drilling)。
1.1 Phase 2 面临的痛点
我们的任务管理系统页面结构更复杂:
Sidebar(侧边栏) 需要显示所有项目的列表。Header(顶部导航) 需要显示当前选中项目的进展统计。ProjectBoard(看板区) 需要针对某个项目进行 CRUD 操作。
如果依然使用状态提升,我们就必须把状态放到最顶层 RootLayout 里:
二、React 内置方案:Context API
在学习 Zustand 之前,我们先看看 React 自带的跨组件通信方案 —— Context API。理解它的优势和局限,才能明白为什么我们需要 Zustand。
2.1 Context 三步走
import { createContext, useContext, useState, type ReactNode } from 'react'
// ① 创建 Context(可以给默认值)
interface ThemeContextType {
theme: 'light' | 'dark'
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextType | null>(null)
// ② Provider 组件:在组件树上层"广播"数据
function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light')
const toggleTheme = () => setTheme(prev => prev === 'light' ? 'dark' : 'light')
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}
// ③ 在任意深度的子组件中消费
function ThemeButton() {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('必须在 ThemeProvider 内使用')
return (
<button onClick={ctx.toggleTheme}>
当前主题:{ctx.theme === 'light' ? '☀️' : '🌙'}
</button>
)
}
// App 中使用
function App() {
return (
<ThemeProvider>
<ThemeButton /> {/* 直接 useContext 拿到数据! */}
</ThemeProvider>
)
}TIP
React 19 新增: 你可以用 use(ThemeContext) 替代 useContext(ThemeContext)。两者功能相同,但 use() 可以在条件语句和循环中使用(传统 Hook 不行)。
NOTE
React 19 语法简化: 从 React 19 开始,你可以直接用 <ThemeContext> 替代 <ThemeContext.Provider>。 现阶段两种写法都可用,.Provider 仍然有效;新写法主要是为了减少样板代码。
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider><ThemeContext value={{ theme, toggleTheme }}>
{children}
</ThemeContext>2.2 ⚠️ Context 的性能陷阱
Context 的订阅粒度需要留意:当 Provider 的 value 变化时,所有使用 useContext 消费该 Context 的组件都会重新渲染,无论它是否用到了变化的那个字段。
value 按 Object.is 比较。每次创建新对象也会通知消费者;可以按职责拆分 Context,并在确有收益时稳定 provider 的值。是否影响体验,应通过测量判断。
2.3 Context 适合什么?不适合什么?
| 适合 ✅ | 不适合 ❌ |
|---|---|
| 很少变的全局设置(语言、主题) | 把大量高频数据放进同一个 Context |
| 需要穿透很多层级的依赖注入 | 需要精确控制哪些组件重新渲染 |
| 组合组件模式(如 L15 的 Accordion) | 每个消费者都要求字段级订阅的状态 |
理解了 Context 的局限,就能明白为什么我们需要更好的方案。
三、Zustand 登场
"一只轻巧、快速、现代的熊(Zustand 德语原意为'状态',图标是一头熊)"。
Zustand 把状态存放在 React 组件树外,通过 selector 订阅所需数据。store 更新时,selector 结果未变的组件可跳过这次订阅更新;父组件、props 或组件自己的状态仍可能触发渲染。
3.1 安装
npm install zustand@53.2 Zustand vs Redux vs Context
| 特性 | Context API | Redux | Zustand |
|---|---|---|---|
| 常用写法 | Provider + useContext | Redux Toolkit + React Redux | create + selector |
| 订阅粒度 | 消费整个 Context 值 | connect / useSelector 选择数据 | selector 选择数据 |
| 依赖 | React 内置 | 额外安装 Toolkit / React Redux | 额外安装 Zustand;体积随入口和构建而变 |
| 书写 | 需要包一层 <Provider> | Provider + Slice + Actions | 只需写一个 Hook |
| 常见场景 | 配置、依赖注入、组件协作 | 需要规范化状态流程和工具链 | 需要较少配置的共享状态 |
四、创建第一个 Store
在 src/store/ 目录下创建一个专门管理项目的 Zustand Store。
// src/store/useProjectStore.ts
import { create } from 'zustand'
// 1. 定义我们 Store 里的数据长什么样(类型说明)
export interface Task {
id: string
title: string
status: 'todo' | 'in-progress' | 'done'
}
export interface Project {
id: string
name: string
icon: string
tasks: Task[]
}
// 2. 将数据和修改数据的方法,同时塞进一个接口里
interface ProjectState {
projects: Project[] // 数据状态 (State)
addProject: (name: string, icon: string) => void // 操作方法 (Action)
deleteProject: (id: string) => void // 操作方法 (Action)
}
// 3. 创造这头神奇的熊 (create store)
const useProjectStore = create<ProjectState>((set) => ({
// 初始数据
projects: [
{ id: 'app-rebuild', name: 'App 重构计划', icon: '📱', tasks: [
{ id: 't1', title: '分析竞品', status: 'done' },
{ id: 't2', title: '画原型图', status: 'in-progress' },
]},
{ id: 'marketing-q3', name: 'Q3 营销', icon: '🎯', tasks: [] },
],
// 操作方法:类似 setXxx(prev => ...)
addProject: (name, icon) => set((state) => ({
projects: [...state.projects, { id: crypto.randomUUID(), name: name.trim(), icon, tasks: [] }]
})),
// ⚠️ 记得不可变更新原则!使用 filter,不影响原对象
deleteProject: (id) => set((state) => ({
projects: state.projects.filter(p => p.id !== id)
}))
}))
export default useProjectStore这里创建的是模块级 store,组件通过 Hook 订阅它;普通 Vite SPA 无需额外 Provider。服务端渲染项目还要考虑请求之间的状态隔离,不能直接把这个模式用于所有服务器请求。
五、在组件中"消费" Store
现在让组件脱离 Props 苦海,直接向 Zustand "索要" 它们需要的数据。
5.1 改造侧边栏 (ProjectsLayout.tsx)
我们让侧边栏去订阅 projects 列表数组。
// src/layouts/ProjectsLayout.tsx
import { NavLink, Outlet } from 'react-router'
import useProjectStore from '../store/useProjectStore' // 引入 hook
export default function ProjectsLayout() {
// 🐻 关键一步:从 store 中取出 projects!
// 这句话等于宣告:"当 projects 发⽣变化时,请重新渲染我所在组件。"
const projects = useProjectStore(state => state.projects)
return (
<div className="flex h-full">
<aside className="w-64 bg-white border-r border-gray-200 shrink-0 flex flex-col py-4">
<h2 className="px-6 mb-2 text-sm text-gray-500">近期项目</h2>
<nav className="flex-1 px-3 space-y-1">
{projects.map(proj => ( // 直接拿取数据渲染
<NavLink key={proj.id} to={`/projects/${proj.id}`}
className={({ isActive }) => `block rounded px-3 py-2 ${isActive ? 'bg-indigo-50 text-indigo-700' : 'text-gray-600'}`}
>
{proj.icon} {proj.name}
</NavLink>
))}
</nav>
</aside>
<div className="min-w-0 flex-1 overflow-auto bg-gray-50/50 p-8">
<Outlet />
</div>
</div>
)
}5.2 改造具体看板页 (Board.tsx)
我们需要在这里同时拿到"当前看板对应的数据"和"修改行为",并且添加个“删除项目”按钮测试反应!
// src/pages/projects/Board.tsx
import { useParams, Navigate, useNavigate } from 'react-router'
import useProjectStore from '../../store/useProjectStore'
export default function Board() {
const { id } = useParams()
const navigate = useNavigate() // 编程式导航
// find 返回 store 中已有的对象引用;内联 selector 在这里可以正常使用。
const project = useProjectStore(state => state.projects.find(p => p.id === id))
const deleteProject = useProjectStore(state => state.deleteProject)
if (!project) return <Navigate to="/projects" replace />
const handleDelete = () => {
deleteProject(project.id) // 全局删除
navigate('/projects', { replace: true }) // 回退
}
return (
<div>
<header className="mb-8 flex justify-between items-center">
<div>
<h1 className="text-3xl font-extrabold text-gray-900">{project.name}</h1>
<p className="text-gray-500 mt-2">共 {project.tasks.length} 项任务</p>
</div>
<button
onClick={handleDelete}
className="bg-red-50 text-red-600 px-4 py-2 rounded font-medium hover:bg-red-100 transition"
>
删除项目
</button>
</header>
<ul className="space-y-2">
{project.tasks.map(task => (
<li key={task.id} className="rounded bg-white p-4">
{task.title} · {task.status}
</li>
))}
</ul>
</div>
)
}当我们在 Board 页面点击"删除"时:
deleteProject被调用,Store 中projects变化。- 订阅了
projects的Sidebar(侧边栏) 立刻、自动剥离并去除了该项目! - 删除处理器主动导航到
/projects;找不到项目时的<Navigate>则负责其他入口的兜底。
没有任何 Props 被传递,一切自然发生。这就是全局 Store 爽点所在。
六、🧠 深度专题:Zustand 选择器与精确渲染
在上面代码中,我们这样写: const projects = useProjectStore((state) => state.projects)
为什么要传个箭头函数(选择器/Selector)进去?为什么不直接解构?
// 可运行,但会订阅整个 store;无关字段变化也会触发更新
const { projects, addProject } = useProjectStore()Selector 的精细刀法
对于 store 发出的订阅更新,Zustand 5 默认用 Object.is 比较 selector 的结果。不传 selector 就会订阅整个 state。这个机制不阻止父组件或自身状态引发的渲染。
可以封装下面的 Hook 复用选择逻辑。返回新对象时必须稳定结果引用;Zustand 5 中直接返回对象字面量可能导致无限更新:
// src/store/useProjectStore.ts(追加;import 放在文件顶部)
import { useShallow } from 'zustand/react/shallow'
// ✅ 推荐实践:导出精确的 Selector (自定义 Hook 化)
export const useProjectList = () => useProjectStore(state => state.projects)
// 让挑选单个项目逻辑更丝滑
export const useProjectById = (id: string | undefined) =>
useProjectStore(state => state.projects.find(p => p.id === id))
export const useProjectActions = () => useProjectStore(useShallow(state => ({
addProject: state.addProject,
deleteProject: state.deleteProject
})))在 Board.tsx 中改用这些 Hook 时,也要从 store 文件导入它们:
import { useProjectById, useProjectActions } from '../../store/useProjectStore'
// 在 Board 组件内,替换原来的两个订阅:
const project = useProjectById(id)
const { deleteProject } = useProjectActions()TIP
Zustand 控制重新渲染的进阶玩法 selector 返回数组或对象时,可用 useShallow 复用浅层相等的结果。它不是深比较,不会修复直接修改嵌套对象的问题。需要自定义 equality 函数时,可使用 zustand/traditional 的 API 并安装其 peer dependency use-sync-external-store。参见 Zustand v5 迁移说明。
七、练习
- 实现新增项目:在
ProjectsLayout侧边栏下方,放一个输入框和按钮,调用刚才定义的addProject方法。测试当你敲击回车,左侧导航和对应的路由页面是否立刻可用。 - 任务 CRUD:现在 Store 里每个 Project 有个
tasks数组。试着为 Store 增加addTask,toggleTask,deleteTask的能力(注意,在修改嵌套很深对象中的某个数组时,不可变更新原则会让代码稍微有点点复杂)。
📌 本节小结
| 你做了什么 | 你学到了什么 |
|---|---|
| 明白了 Props Drilling 的窘境 | 全局状态库 (状态提升 vs 外部化 Store) |
| 用 Zustand 搭建了全局 Store | create((set) => ...) 核心 API |
| 跨页面完成项目删除交互同步 | 通过共享 store 的单向更新同步两个视图 |
| — | Zustand selector、稳定引用与订阅粒度 |