第 10 课 · 状态管理 Zustand
你在 Vue 里写中后台,Pinia 基本是默认选项。这一课的目标是:让你到 React 项目里能像用 Pinia 一样自然地用 Zustand,并且看懂存量项目里的 Redux Toolkit 代码。
🎯 本课目标
- 说清楚「为什么需要全局状态」:props 钻井的痛点、Context 的局限、Pinia 在 Vue 里的定位对应到 React 是谁
- 用
create独立写出一个类型完整的 store:state 与 actions 同级、类型自动推导 - 用 selector 做细粒度订阅,能解释「整店订阅」为什么会导致多余重渲染(❌/✅ 对照)
- 在组件外(api 回调、路由守卫、工具函数)用
getState()/setState()读写 store - 用
persist中间件实现 localStorage 持久化,并用partialize排除函数字段 - 看懂 Redux Toolkit 的 slice 最小示例,知道企业新项目为什么更多选 Zustand
1. 为什么需要全局状态:props 钻井与 Context 的局限
Vue 里你这样写
在 Vue 里,跨层级传值你会依次试过三条路:props 一层层往下传、provide / inject、最后上 Pinia。Pinia 之所以成为默认,是因为它是「组件树之外的独立状态仓库」:任何组件、任何非组件代码(路由守卫、工具函数)都能拿到同一份数据,且响应式自动同步。
React 里这样写
React 内置的方案只有两条:props 和 Context。当组件树变深,props 会变成「钻井」——中间层组件被迫接收并转发一堆自己根本用不到的 props:
// ❌ props 钻井:A 的用户信息要传给 D,B、C 被迫当传话筒
function A() {
const [user, setUser] = useState<User | null>(null)
return <B user={user} setUser={setUser} />
}
// B 自己根本不用 user,却被迫在签名里接住再转发(真实项目往往要传五六个)
function B({ user, setUser }: { user: User | null; setUser: (u: User | null) => void }) {
return <C user={user} setUser={setUser} />
}
function C({ user, setUser }: Props) {
return <D user={user} setUser={setUser} /> // C 也只是转发
}
function D({ user, setUser }: Props) {
return <div>{user?.name} <button onClick={() => setUser(null)}>退出</button></div>
}
用 Zustand 之后,中间层彻底消失:
// stores/user.ts —— 组件树之外的独立仓库
import { create } from 'zustand'
interface User {
id: number
name: string
}
interface UserState {
user: User | null
setUser: (user: User | null) => void
}
export const useUserStore = create<UserState>()((set) => ({
user: null,
setUser: (user) => set({ user }),
}))
// 只有真正需要数据的组件才和 store 发生关系
function A() {
const setUser = useUserStore((s) => s.setUser)
return <button onClick={() => setUser({ id: 1, name: '张三' })}>登录</button>
}
function D() {
const user = useUserStore((s) => s.user)
const setUser = useUserStore((s) => s.setUser)
return <div>{user?.name} <button onClick={() => setUser(null)}>退出</button></div>
}
// B、C:什么都不用写,它们不再参与状态传递
关键差异与注意点
- React 没有
provide / inject的「祖先定向投放」概念,Context更接近 Vue 里的provide + 响应式注入,但它有两个硬伤:- 性能:Context 的 value 一变,所有消费组件全部重渲染,无法按字段订阅;
- 工程性:Provider 要挂在组件树上,组件外(api 层、路由守卫)访问很别扭。
- 所以企业里的分工惯例是:主题、语言、当前登录态这类「低频变化的全局配置」可以放 Context;业务数据(购物车、用户列表、表单草稿)放 Zustand。
- Pinia ≈ Zustand:都是组件树之外的仓库、都没有 mutation 仪式感、都能在组件外使用。最大的体感差异是「响应式怎么订阅」(第 3 节)。
2. create 创建 store:对照 defineStore
Vue 里你这样写
// Pinia:state / getters / actions 三段式
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const step = ref(1)
const increment = () => { count.value += step.value }
return { count, step, increment }
})
React 里这样写
Zustand 把 state 和 actions 拍平在同一层,一个函数写完。
// stores/counter.ts
import { create } from 'zustand'
interface CounterState {
count: number // ← 状态
step: number // ← 状态
increment: () => void // ← action,和状态写在同一层
decrement: () => void
setStep: (step: number) => void
reset: () => void
}
// create<CounterState>()( ... ) :中间那对括号不是手滑,
// 它是 TS 的「实例化表达式」,作用是让类型正确流动(忘写会得到宽泛的 any 推导)
export const useCounterStore = create<CounterState>()((set, get) => ({
count: 0,
step: 1,
// set 只做浅合并:传入哪些字段就替换哪些字段,其余保持不变
increment: () => set((s) => ({ count: s.count + s.step })),
decrement: () => set((s) => ({ count: s.count - s.step })),
setStep: (step) => set({ step }),
// 需要「当前完整状态」时用 get(),例如判断后再写
reset: () => {
if (get().count === 0) return
set({ count: 0, step: 1 })
},
}))
action 里写异步和在 Vue 里写 async action 一样自然,没有任何 mutation 概念(loading 也是普通字段):
export const useLoginStore = create<LoginState>()((set) => ({
token: null,
loading: false,
login: async (name) => {
set({ loading: true })
try {
const { token } = await http.post('/api/login', { name })
set({ token, loading: false })
} catch (e) {
set({ loading: false })
throw e // 让组件层去弹提示,store 不掺 UI 逻辑
}
},
}))
关键差异与注意点
- Pinia 的
state / getters / actions三段式(或 setup 写法)→ Zustand 只有一个「大对象」,靠命名区分:状态用名词,action 用动词。 set是浅合并(类似Object.assign),整对象替换用set(新对象, true),需要基于旧值计算时写set((s) => ...)。- 不可变更新的纪律和 useState 完全一样:数组用
[...]/map/filter,对象用{ ...old, x: 1 },绝不能state.list.push(x)。 - 类型上推荐「先写 interface 再
create<T>()」,让 store 的形状显式可见 —— code review 时一眼能看出这个 store 管了什么。 - 第 12 课的实战页会复用「状态 + action 同层定义、类型先声明」这套写法,这里把基础打牢。
3. 组件里怎么用:selector 细粒度订阅(对照 storeToRefs)
Vue 里你这样写
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
const { count } = storeToRefs(store) // 解构后不丢响应性
</script>
<template>
<div>{{ count }}</div>
</template>
Pinia 的 store 本身就是 reactive 对象,Vue 的依赖追踪是字段级的:模板里用了 count,只有 count 变才更新。storeToRefs 解决的只是「解构丢响应性」的问题。
React 里这样写
Zustand 没有依赖追踪,订阅粒度完全由你的 selector 决定:
// ❌ 整店订阅:不传 selector,store 里任何字段变了都重渲染本组件
function BadgeWrong() {
const prefs = usePrefsStore() // 只用了 nickname,却订阅了整间店
return <span>昵称:{prefs.nickname}</span>
}
// ✅ 字段订阅:selector 返回什么就订阅什么,值没变(Object.is 相等)就不渲染
function BadgeRight() {
const nickname = usePrefsStore((s) => s.nickname)
return <span>昵称:{nickname}</span>
}
需要一次拿多个字段时,用 useShallow 做「浅比较」,避免对象字面量每次都是新引用:
import { useShallow } from 'zustand/react/shallow'
function Panel() {
// theme 或 fontSize 任一变化才重渲染;notificationCount 变化不关本组件的事
const { theme, fontSize } = usePrefsStore(
useShallow((s) => ({ theme: s.theme, fontSize: s.fontSize })),
)
return <div style={{ fontSize }}>主题:{theme}</div>
}
取 action 只拿函数本身(引用永远不变),天然不引起多余渲染,也不需要 useShallow:const increment = useCounterStore((s) => s.increment)。
关键差异与注意点
storeToRefs是「拿到响应式引用」的手段;Zustand 的 selector 是「决定订阅范围」的手段。每个组件只订阅自己用到的字段,是 React 项目里最常见的一条 code review 意见。- selector 的比较规则是
Object.is:返回原始值最稳;返回对象/数组时每次都是新引用,会退化成「整店订阅」的渲染行为 —— 这时才需要useShallow。 - 整店订阅不是「错误」,小组件、demo、字段很少的 store 直接整店订阅完全没问题;别教条化。
- 有了 selector 之后,第 7 课讲的
memo很多时候是多余的:订阅没变,组件根本不会进入渲染流程。
4. 组件外使用:getState() / setState()
Vue 里你这样写
Vue 里你经常在路由守卫里直接用 store:
// router/index.ts —— 非组件环境
router.beforeEach((to) => {
const userStore = useUserStore() // pinia 在组件外也能 use
if (to.meta.requiresAuth && !userStore.user) return '/login'
})
React 里这样写
hook 只能在组件里调用,但 store 本身是模块级单例。Zustand 给了两个非 hook 的访问器:
// utils/permission.ts —— 不在任何组件里
import { useAuthStore } from '@/stores/auth'
// 读:getState() 返回瞬时快照,不建立订阅、不会触发任何重渲染
export function hasPermission(): boolean {
const { user } = useAuthStore.getState()
return user?.role === 'admin'
}
// 写:setState 在组件外等价于组件里的 set,partial 合并
export async function loginAndSave(name: string) {
const { token, user } = await http.post('/api/login', { name })
useAuthStore.setState({ token, user }) // 订阅它的组件自动更新
}
api 请求层同理:axios 响应拦截器里直接 useAuthStore.setState(...) 写 store,组件层完全无感知。
关键差异与注意点
getState()是「读一次快照」:值之后变了你不知道。需要「变化时跟着更新」就必须用 hook 订阅。setState()在组件外写入,订阅逻辑照常生效 —— 这是 Zustand 与「模块级普通变量 + 自己写事件通知」的本质区别。- 路由守卫场景:React Router 7 用
loader或受保护布局组件(第 9 课)做拦截,里面同样可以直接useAuthStore.getState()。 - 典型翻车:在工具函数里用
useAuthStore.getState().user判断登录态,然后登录状态变了逻辑却不触发 —— 因为快照不会订阅。这种「随登录态变化的 UI 判断」应该回到组件里用 hook。
5. persist 持久化:对照 pinia-plugin-persistedstate
Vue 里你这样写
// 全局注册插件后,在 store 的第三个参数里声明即可
defineStore('cart', { state, actions }, { persist: true })
React 里这样写
Zustand 用中间件包装 create:
// stores/cart.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface CartItem {
id: number
name: string
price: number
qty: number
}
interface CartState {
items: CartItem[]
addItem: (item: Omit<CartItem, 'qty'>) => void
removeItem: (id: number) => void
clear: () => void
}
export const useCartStore = create<CartState>()(
persist(
(set) => ({
items: [],
addItem: (item) =>
set((s) => {
const exist = s.items.find((i) => i.id === item.id)
if (exist) {
return { items: s.items.map((i) => (i.id === item.id ? { ...i, qty: i.qty + 1 } : i)) }
}
return { items: [...s.items, { ...item, qty: 1 }] }
}),
removeItem: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
clear: () => set({ items: [] }),
}),
{
name: 'app-cart', // localStorage 的 key,必填
partialize: (s) => ({ items: s.items }), // 只持久化数据,函数不存
},
),
)
关键差异与注意点
- 配置项基本一一对应:
name↔key,storage↔storage(默认 localStorage),partialize↔paths。 - 一定要
partialize:默认会尝试序列化整个 state,函数字段虽不会被 JSON 存下来,但把大对象(缓存、临时表单)一起存进去会撑爆 5MB 配额并拖慢每次写入。 - 存储里的数据没有版本概念,结构变更要换 key 或加 version + migrate,否则老用户的旧结构数据会直接灌进新代码。
- 别把「服务端数据」持久化进 Zustand 来做缓存 —— 那是第 11 课 TanStack Query 的职责。
6. 模块拆分与 Redux Toolkit 一瞥
模块拆分:一域一 store 文件
src/
├── stores/
│ ├── auth.ts // 登录态、权限、token
│ ├── cart.ts // 购物车
│ └── app.ts // 主题、语言、侧边栏折叠
- 文件名即领域,
use+ 领域 +Store命名 hook:useAuthStore、useCartStore; - store 文件里只放状态与业务动作,不写 UI 文案、不弹 toast(UI 反馈留给组件层);
- 跨 store 依赖时,在一个 store 的 action 里调用另一个 store 的
getState(),别循环 import。
Zustand 没有 $reset,怎么应对
Pinia 的 options store 自带 $reset()。Zustand 没有这个方法(actions 是你手写的函数,框架不知道初始值),企业里常见两种写法:
// 方案 1:写一个 reset action,把字段逐个归位(最直观,推荐)
logout: () => set({ items: [], coupon: null })
// 方案 2:初始状态提成常量,reset 时整体替换
const initialState = { items: [], coupon: null }
export const useCartStore = create<CartState>()((set) => ({
...initialState,
reset: () => set({ ...initialState }),
}))
Redux Toolkit 一瞥:看懂老项目即可
存量 React 项目大量使用 Redux Toolkit(RTK)。它的心智是「单一 store + 切片(slice)+ reducer 纯函数」,没有 Pinia/Zustand 那种直接改的爽快感:
// features/counter/counterSlice.ts —— 最小 slice,能看懂这段就够入职了
import { createSlice, configureStore } from '@reduxjs/toolkit'
import { useDispatch, useSelector } from 'react-redux'
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 }, // 初始 state
reducers: { // 每个 key 就是一个 action
increment: (state) => { state.value += 1 }, // 看似原地改,其实是 Immer 代理
addAmount: (state, action) => { state.value += action.payload },
},
})
export const { increment, addAmount } = counterSlice.actions
export const store = configureStore({ reducer: { counter: counterSlice.reducer } })
// 组件里:useSelector 订阅(相当于 zustand 的 selector)、useDispatch 派发
function Counter() {
const value = useSelector((s: RootState) => s.counter.value)
const dispatch = useDispatch()
return <button onClick={() => dispatch(addAmount(5))}>{value}</button>
}
读懂三件事即可:① reducers 里的函数叫 reducer,必须是纯函数,靠 Immer 允许「看起来原地改」;② 修改要先 dispatch(action),不能像 Zustand 那样直接调函数;③ 必须有 <Provider store={store}> 包裹根组件。
选型建议:企业新项目(中后台)目前主流是 Zustand —— 无 Provider、无样板、TS 友好;RTK 留给存量项目维护。面试时两个都要能说清楚。
📊 Vue ↔ React 对照速查表
| Vue 3 / Pinia | React + Zustand | 一句话差异 |
|---|---|---|
defineStore('cart', ...) | create<CartState>()(...) | 一个函数定义完整 store,state 与 action 同层 |
state: { count: 0 } | count: 0 直接写在返回对象里 | 没有 state 包一层,靠命名与类型区分 |
getters: { double() } | 组件里现算 / useMemo | 派生数据不进 store,避免两份状态 |
actions: { increment() } | increment: () => set(...) | 无 mutation 概念,action 里可直接 async |
store.count / storeToRefs(store) | useStore((s) => s.count) | selector 决定订阅粒度,替代依赖追踪 |
| 解构丢响应性问题 | 返回对象要用 useShallow | 引用相等性由你自己负责 |
store.$reset() | 手写 reset action | Zustand 没有 $reset,需要自己实现 |
store.$patch({ count: 1 }) | useStore.setState({ count: 1 }) | 组件外也能用;浅合并语义相同 |
pinia-plugin-persistedstate | persist 中间件 | name/partialize ≈ key/paths |
路由守卫里 useUserStore() | useAuthStore.getState() | 非 hook 环境用 getState/setState |
| Redux(老项目) | Redux Toolkit(存量)→ Zustand(新项目) | RTK 有 reducer/Provider 样板,Zustand 零仪式 |
🏢 企业实战要点
- 目录规范:
src/stores/一域一文件(auth.ts/cart.ts/app.ts),hook 命名useXxxStore;禁止把所有状态塞进一个appStore,那等于回到「全局大泥球」。 - selector 一律显式写:code review 里最常见意见就是「这里不要整店订阅,改成字段 selector」。判断标准:这个字段变了,该组件真的需要重新渲染吗?
- action 里直接写 async:Zustand 没有 mutation,接口请求、加载态、错误态都可在 action 内维护;但 UI 提示(toast、跳转)留给组件层,store 不要 import UI 组件。
- $reset 的替代:把初始 state 提成常量 + 手写
resetaction;登出、切换租户时显式调用,避免上一个用户的数据残留。 - persist 必配
partialize与version:只存需要的数据;结构演进加版本号迁移,否则线上老用户会被旧数据结构打挂。 - 别用 store 缓存服务端数据:列表、详情这类「服务端状态」交给 TanStack Query(第 11 课),store 只放客户端状态。这条边界不清,项目会到处出现「手写缓存刷新」。
- 真实坑:对象 selector 无效重渲染 ——
useStore((s) => ({ a: s.a, b: s.b }))每次都返回新对象,等于整店订阅;包useShallow才生效。 - 真实坑:模块级 store 与热更新 —— Vite HMR 替换 store 文件会重置状态,企业做法是收敛 store 定义并在导出处加
import.meta.hot保护(面试可提,平时不必手写)。
💼 面试高频
1. Zustand 为什么轻?它没有 Provider 是怎么工作的?
Zustand 的 store 是模块级单例,通过 useSyncExternalStore 订阅,不依赖 React Context,所以不需要 <Provider> 包裹。好处有三:非组件代码可直接 getState()/setState();没有 Context 全量重渲染问题;组件树更干净。代价是订阅粒度要自己用 selector 控制。
2. selector 订阅是怎么减少重渲染的?
组件把 (state) => 感兴趣的值 传给 hook,store 变化时 Zustand 用 Object.is 比较新旧 selector 结果,相等就跳过本次渲染。因此返回原始值最稳;返回新对象/数组会被判定为「变了」,需要 useShallow 做浅比较。这与 Pinia 的字段级依赖追踪结果一致,只是机制从「自动追踪」变成了「显式声明」。
3. Zustand 和 Redux Toolkit 怎么选?
RTK:单一 store、slice/reducer/dispatch 样板、需要 Provider、调试生态(DevTools、中间件)最成熟,适合大型存量项目。Zustand:无 Provider、无样板、action 直接调用、TS 推导好、支持组件外访问。国内中后台新项目首选 Zustand,维护 RTK 存量代码时要能看懂 slice。
4. 说说 Zustand 与 Pinia 的概念映射。
defineStore ↔ create;state ↔ 返回对象里的数据字段;actions ↔ 同层的函数(可直接 async);getters ↔ 组件派生计算;storeToRefs ↔ selector;$patch ↔ setState;$reset ↔ 手写 reset;persistedstate 插件 ↔ persist 中间件。差异核心:Pinia 靠响应式自动追踪,Zustand 靠显式 selector。
5. 非 React 环境怎么访问 store?有什么坑?
用 store.getState() 读、store.setState() 写,例如 api 拦截器、路由守卫、埋点工具。坑:getState() 是一次性快照,不会随状态变化回调;需要响应变化时要么改回组件里用 hook,要么用 store.subscribe(listener) 手动订阅,并在卸载时取消。
📌 小结 + 下节预告
这一课把「全局状态」补齐了:create 定义 store(state 与 action 同层、类型先声明)、selector 决定订阅粒度、getState/setState 覆盖非组件环境、persist 处理持久化、stores/ 一域一文件,外加能看懂 RTK 的 slice。到这里,你手里已经有了 Vue 三件套(组件通信、组合式函数、Pinia)的 React 等价物。
但中后台真正的重头戏是列表页:搜索、分页、loading、缓存、刷新。下一课引入 TanStack Query,解决手写请求 hook 搞不定的「服务端状态」管理 —— 这是 React 面试出现频率最高的话题之一。
本文系统讲解Zustand在React中的应用,涵盖全局状态必要性、store创建与类型定义、selector细粒度订阅机制、组件外getState/setState使用、persist中间件持久化配置、模块拆分规范及与Redux Toolkit的对比选型。重点强调TS友好、无Provider设计、响应式订阅控制及企业级最佳实践。
1718

被折叠的 条评论
为什么被折叠?



