📚 全栈开发学习系列
从零到全栈 · 8 个阶段 · 25+ 篇
✅ 阶段一:编程基础
01-08
路线总览 · Python · C语言 · 数据结构 · Git · Linux
✅ 阶段二:Web开发基础
09-16
HTML · CSS · JavaScript · HTTP · FastAPI · PostgreSQL · 认证授权 · 综合实战
阶段三:前端深化
17-25 · 进行中
✓ 17 React入门:组件化思想与JSX语法
✓ 18 React进阶:状态管理与副作用钩子
✓ 19 TypeScript入门:类型系统与类型注解
✓ 20 Next.js基础:SSR与路由体系
✓ 21 Tailwind CSS入门:实用优先的CSS框架
✓ 22 前端工程化:Vite与Webpack构建工具
✓ 23 前端测试:Vitest与Playwright
✓ 24 API测试:接口自动化与Mock服务
▸ 25 状态管理:Zustand与服务端状态(当前)
组件层级变深后,useState 逐层传递 props 的链路越来越长。更要命的是,API 返回的数据被多个组件共享——谁负责缓存?过期了谁重新拉取?用户改了数据谁同步?
本篇把状态分为两类:客户端状态用 Zustand 5.0(主题切换、购物车),服务端状态用 TanStack Query v5.102(API 数据缓存、自动刷新)。读完本篇你将能设计合理的全局状态架构,避免"所有数据塞进一个 Redux"的常见错误。
📋 目录
1. 客户端状态 vs 服务端状态
2. React内置状态的瓶颈
3. Zustand 5.0:轻量全局状态
4. 创建第一个Store
5. 选择性订阅与性能优化
6. 异步操作与中间件
7. TanStack Query v5:服务端状态
8. useQuery:数据获取与缓存
9. useMutation:数据变更
10. Zustand + TanStack Query实战
1. 客户端状态 vs 服务端状态
状态管理像一个餐厅的订单系统——服务员手边的便签纸记录"5号桌加一份辣椒酱",这是客户端状态:临时、本地、不需要和厨房同步。厨房的出票系统记录"订单#23正在炒、订单#24排队中",这是服务端状态:共享、有生命周期、需要持续同步。
如果把这两种状态混在一起管理,就像让服务员去厨房查菜做没做好——能做,但每次都要跑一趟,效率极低。
| 维度 |
客户端状态 |
服务端状态 |
| 数据来源 |
用户操作产生 |
后端 API 返回 |
| 所有权 |
前端独占 |
后端是真相源 |
| 更新方式 |
用户操作触发 |
需重新请求同步 |
| 典型场景 |
主题、侧边栏开关、表单草稿 |
用户列表、文章详情、搜索结果 |
| 推荐工具 |
Zustand |
TanStack Query |
💡 小贴士
判断用哪个工具的黄金法则:问自己"这个数据如果后端更新了,前端需要知道吗?"——如果"是",用 TanStack Query;如果"否"(纯前端数据),用 Zustand。
2. React内置状态的瓶颈
React 提供了三种内置状态方案:useState、useReducer、useContext。小项目完全够用,但组件树变深后出现两个瓶颈:
瓶颈一:Props 逐层传递
Props Drilling 问题
// 用户信息从顶层传到底层,中间组件不使用但要转发
<App> // 持有 user 状态
<Layout user={user}> // 仅转发,不使用
<Sidebar user={user}> // 又转发一层
<UserProfile user={user}> // 终于用到了
</Sidebar>
</Layout>
</App>
瓶颈二:Context 全局重渲染
useContext 能解决 props 传递,但有一个硬伤:Context 值变化时,所有消费该 Context 的组件全部重渲染——哪怕它只用其中一个字段。一个购物车状态包含 50 件商品,改了某件商品的数量,整个用户列表也会重渲染。
Zustand 和 TanStack Query 分别解决这两个问题:Zustand 用选择器实现精确订阅(只订阅用到的字段),TanStack Query 把服务端状态完全从组件树中抽离出来。
3. Zustand 5.0:轻量全局状态
Zustand(德语"状态")是 Poimandres 团队推出的轻量状态库,API 极简——一个 create 函数搞定一切。当前最新版本 5.0.15(2026年8月)。相比 Redux 的 action/reducer/middleware 三件套,Zustand 没有模板代码。
安装:
bash
npm install zustand
# 零依赖,包体积 ~1KB (gzipped)
| 维度 |
Zustand |
Redux Toolkit |
| 包体积 |
~1KB |
~17KB |
| 模板代码 |
几乎为零 |
需 slice + reducer |
| TypeScript |
原生支持 |
需额外配置 |
| 学习曲线 |
10 分钟 |
数小时 |
4. 创建第一个Store
Store 是 Zustand 的核心——一个包含状态和操作函数的对象,用 create 创建:
src/store/cart.ts
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 |
import { create } from 'zustand'
interface CartState {
items: string[]
addItem: (item: string) => void
removeItem: (item: string) => void
clear: () => void
}
export const useCartStore = create<CartState>((set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
removeItem: (item) => set((state) => ({
items: state.items.filter((i) => i !== item)
})),
clear: () => set({ items: [] })
}))
|
核心概念:
create<T>() — 创建 Store,泛型 T 定义状态类型
set — 更新状态的函数,类似 React 的 setState 但作用于全局
- 返回的
useCartStore 是一个 Hook,组件中直接调用
- 命名约定:Store Hook 以
use 开头
5. 选择性订阅与性能优化
Zustand 最强的特性是选择器——只订阅 Store 中用到的字段,其他字段变化不触发重渲染:
src/components/CartBadge.tsx
|
1
2
3
4
5
6
7
8
9
10
11
12
13 |
import { useCartStore } from '../store/cart'
function CartBadge() {
// 只订阅 items.length,items 内容变化才重渲染
const count = useCartStore((s) => s.items.length)
const clear = useCartStore((s) => s.clear)
return (
<button onClick={clear}>
购物车&npsp;({count})
</button>
)
}
|
useCartStore((s) => s.items.length) 只订阅 items.length。如果 Store 中增加一个 isOpen 字段并修改它,CartBadge 不会重渲染——因为它不依赖 isOpen。
⚠️ 常见错误:返回新对象导致无限重渲染
错误写法:const { items, clear } = useCartStore((s) => ({ items: s.items, clear: s.clear }))
原因:选择器每次调用都返回一个新对象字面量,Zustand 默认用 Object.is 比较,新对象永远不等于旧对象,导致无限重渲染。解决方法:分别订阅每个字段(如上例),或使用 useShallow 浅比较。
6. 异步操作与中间件
Zustand 的 set 支持异步——直接在 action 中调用 fetch 后 set 结果即可,无需 Redux Thunk 那样的中间件包装:
src/store/userStore.ts
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14 |
import { create } from 'zustand'
interface UserState {
profile: { name: string } | null
loading: boolean
fetchProfile: () => Promise<void>
}
export const useUserStore = create<UserState>((set) => ({
profile: null,
loading: false,
fetchProfile: async () => {
set({ loading: true })
// 后续用 TanStack Query 替代此模式
|
虽然 Zustand 能处理异步数据,但API 数据缓存、重试、过期验证这些能力不该用 Zustand 手动实现——这正是 TanStack Query 的领域。
💡 小贴士
Zustand 中间件用 persist 可以把状态自动存入 localStorage,实现页面刷新后状态不丢失:create(persist((set) => ({...}), { name: 'cart' }))。适合主题、语言、购物车等需要持久化的客户端状态。
7. TanStack Query v5:服务端状态
TanStack Query(原名 React Query)是服务端状态管理的标准答案。当前最新版本 v5.102.x(2026年8月),每周 npm 下载量超过 1800 万次。它处理了服务端状态的所有脏活:缓存、去重、后台刷新、过期管理、乐观更新。
安装:
bash
npm install @tanstack/react-query
# v5 最低要求 React 18
在应用根组件包裹 QueryClientProvider:
src/app/providers.tsx
|
1
2
3
4
5
6
7 |
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
function Providers({ children }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
|
TanStack Query v5 的关键变化:status: 'loading' 改为 status: 'pending',isLoading 改为 isPending,最低要求 React 18。
8. useQuery:数据获取与缓存
useQuery 接收一个查询键和查询函数,自动管理加载、错误、缓存状态:
src/hooks/useProducts.ts
|
1
2
3
4
5
6
7
8
9
10
11
12
13 |
import { useQuery } from '@tanstack/react-query'
function useProducts() {
return useQuery({
queryKey: ['products'], // 缓存键
queryFn: async () => {
const res = await fetch('/api/products')
if (!res.ok) throw new Error('fetch failed')
return res.json()
},
staleTime: 60000 // 1 分钟内不重新请求
})
}
|
组件中使用:
src/components/ProductList.tsx
function ProductList() {
const { data, isPending, error } = useProducts()
if (isPending) return <p>加载中...</p>
if (error) return <p>出错了</p>
return data.map((p) => <div key={p.id}>{p.name}</div>)
}
TanStack Query 自动处理的行为:
- 缓存:同一 queryKey 的请求只发一次,后续组件用缓存数据
- 去重:多个组件同时请求同一 queryKey,只发一个 HTTP 请求
- 后台刷新:staleTime 过期后,窗口聚焦时自动重新请求
- 垃圾回收:没有组件使用的数据,5 分钟后自动从缓存清除
9. useMutation:数据变更
useMutation 处理 POST/PUT/DELETE 等变更操作。变更成功后通过 invalidateQueries 让相关缓存失效,触发自动重新获取:
src/hooks/useAddProduct.ts
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 |
import { useMutation, useQueryClient } from '@tanstack/react-query'
function useAddProduct() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (newProduct) => {
const res = await fetch('/api/products', {
method: 'POST',
body: JSON.stringify(newProduct)
})
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] })
}
})
}
|
流程:组件调用 mutate(newProduct) → POST 请求发出 → 成功后 invalidateQueries 让 ['products'] 缓存失效 → useProducts 自动重新请求 → 列表更新。全程无需手动管理状态。
10. Zustand + TanStack Query 实战
典型项目同时使用两者,各管各的领域:
状态分工
// Zustand 管:UI 状态 + 用户偏好
useUIStore // theme, sidebarOpen, selectedTab
useAuthStore // token, isAuthenticated(纯前端标记)
// TanStack Query 管:所有 API 数据
useProducts() // GET /api/products → 缓存 + 自动刷新
useUserProfile() // GET /api/users/me → 缓存 + 后台同步
useAddProduct() // POST /api/products → 成功后刷新列表
⚠️ 常见错误:用 Zustand 管理 API 数据
错误模式:在 Zustand action 中 fetch 用户列表并存入 store,手动维护 loading/error/lastFetched 状态。
问题:缺少缓存失效、后台刷新、请求去重、乐观更新——这些都要手写,容易出 bug。正确做法:API 数据全部用 TanStack Query 的 useQuery,Zustand 只管"用户切不切换暗色模式"这类纯前端状态。
分级练习
基础(理解概念)
1. 用 Zustand 创建一个 useThemeStore,包含 theme: 'light' | 'dark' 和 toggle() 方法,在两个组件中分别读取和修改。
2. 用 useQuery 获取 /api/users 数据,展示加载中/错误/成功三种状态。
进阶(实际应用)
3. 用 persist 中间件让 useThemeStore 持久化到 localStorage,刷新页面后主题不丢失。
4. 用 useMutation 实现删除用户功能,成功后自动刷新用户列表缓存。
挑战(综合实战)
5. 搭建一个电商页面:用 Zustand 管理购物车状态(添加/删除/清空,persist 持久化),用 TanStack Query 管理商品列表(useQuery 缓存 + staleTime 1 分钟)和下单操作(useMutation + invalidateQueries 刷新)。验证:刷新页面后购物车数据保留,商品列表从缓存秒显后后台刷新。
📌 知识回顾
客户端状态
服务端状态
Zustand 5.0.15
create()
选择器订阅
persist中间件
TanStack Query v5.102
useQuery
useMutation
invalidateQueries
staleTime
Props Drilling
下一篇预告
26 跨平台起步:Flutter 环境搭建与 Dart 入门
前端深化阶段结束,进入阶段四:跨平台 App 开发。从 Dart 语言基础到 Flutter 环境配置,用一套代码覆盖 Android 和 iOS——先在 macOS 上搭建开发环境,写第一个 Widget。