Skip to content

Lesson 09:全局状态管理 — Zustand入门与项目状态共享 ​

🧩 本节信息卡(学习前先看) ​

  • 阶段定位:Phase 2(进阶篇)
  • 推荐时长:75~120 分钟(首次学习)
  • 先修要求:完成 Lesson 08,已搭建项目侧栏和动态看板
  • 学习产出:共享项目列表、删除项目,并使用稳定selector订阅数据
✅ 本节完成标准(自检清单)
  • [ ] 我可以独立复现文中的核心代码片段
  • [ ] 我能解释“为什么这样实现”,而不只是“照着写”
  • [ ] 我记录了至少 1 个踩坑点和修复方法

🧭 本节统一学习流程 ​

  1. 学习目标:先明确本节要解决的业务问题与核心 API。
  2. 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
  3. 原理深挖:理解为什么这样设计,以及常见误区。
  4. 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
  5. 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。

建议节奏:阅读 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 三步走 ​

tsx
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 仍然有效;新写法主要是为了减少样板代码。

tsx
<ThemeContext.Provider value={{ theme, toggleTheme }}>
  {children}
</ThemeContext.Provider>
tsx
<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 安装 ​

bash
npm install zustand@5

3.2 Zustand vs Redux vs Context ​

特性Context APIReduxZustand
常用写法Provider + useContextRedux Toolkit + React Reduxcreate + selector
订阅粒度消费整个 Context 值connect / useSelector 选择数据selector 选择数据
依赖React 内置额外安装 Toolkit / React Redux额外安装 Zustand;体积随入口和构建而变
书写需要包一层 <Provider>Provider + Slice + Actions只需写一个 Hook
常见场景配置、依赖注入、组件协作需要规范化状态流程和工具链需要较少配置的共享状态

四、创建第一个 Store ​

在 src/store/ 目录下创建一个专门管理项目的 Zustand Store。

ts
// 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 列表数组。

tsx
// 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) ​

我们需要在这里同时拿到"当前看板对应的数据"和"修改行为",并且添加个“删除项目”按钮测试反应!

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 页面点击"删除"时:

  1. deleteProject 被调用,Store 中 projects 变化。
  2. 订阅了 projects 的 Sidebar (侧边栏) 立刻、自动剥离并去除了该项目!
  3. 删除处理器主动导航到 /projects;找不到项目时的 <Navigate> 则负责其他入口的兜底。

没有任何 Props 被传递,一切自然发生。这就是全局 Store 爽点所在。


六、🧠 深度专题:Zustand 选择器与精确渲染 ​

在上面代码中,我们这样写: const projects = useProjectStore((state) => state.projects)

为什么要传个箭头函数(选择器/Selector)进去?为什么不直接解构?

tsx
// 可运行,但会订阅整个 store;无关字段变化也会触发更新
const { projects, addProject } = useProjectStore()

Selector 的精细刀法 ​

对于 store 发出的订阅更新,Zustand 5 默认用 Object.is 比较 selector 的结果。不传 selector 就会订阅整个 state。这个机制不阻止父组件或自身状态引发的渲染。

可以封装下面的 Hook 复用选择逻辑。返回新对象时必须稳定结果引用;Zustand 5 中直接返回对象字面量可能导致无限更新:

ts
// 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 文件导入它们:

tsx
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 迁移说明。


七、练习 ​

  1. 实现新增项目:在 ProjectsLayout 侧边栏下方,放一个输入框和按钮,调用刚才定义的 addProject 方法。测试当你敲击回车,左侧导航和对应的路由页面是否立刻可用。
  2. 任务 CRUD:现在 Store 里每个 Project 有个 tasks 数组。试着为 Store 增加 addTask,toggleTask,deleteTask 的能力(注意,在修改嵌套很深对象中的某个数组时,不可变更新原则会让代码稍微有点点复杂)。

📌 本节小结 ​

你做了什么你学到了什么
明白了 Props Drilling 的窘境全局状态库 (状态提升 vs 外部化 Store)
用 Zustand 搭建了全局 Storecreate((set) => ...) 核心 API
跨页面完成项目删除交互同步通过共享 store 的单向更新同步两个视图
—Zustand selector、稳定引用与订阅粒度

项目驱动 · 边写边学 · React 19