REACT · Vol.III · LESSON 18 · 状态管理
Zustand:轻量状态管理实战
状态提升与 Context 之后,还差一块拼图
上一篇第 17 篇把状态劈成两类:服务端状态交给专用方案(第 33 篇 TanStack Query),客户端状态才是本篇的主角。商品页点「加入购物车」,导航栏徽章要 +1,购物车页要显示新条目——同一份状态,三四个不相邻的组件同时关心。
你已经会两招。第一招状态提升(第 14 篇):把 items 提到最近的公共祖先再层层下发。可公共祖先是 App——它拿着一份和自己毫无关系的购物车数据,中间每层组件被迫加两个 props 进出口,像「调用链上每个方法都得加个参数,只为传给最底层」。
第二招 Context(第 15 篇)。能用,但有两处别扭:
import { createContext, useState, type ReactNode } from "react";
// CartValue = { items: CartItem[]; add: (i: CartItem) => void }(类型略)
const CartContext = createContext<CartValue | null>(null);
function CartProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
// 每次渲染 value 都是新引用,items 一变则全体消费组件陪跑
const value = { items, add: (i: CartItem) => setItems([...items, i]) };
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}
别扭一:广播粒度太粗。value 是个整体,items 变一次,所有 useContext 消费者全部重渲染——没法「只订 items」。别扭二:离开组件树就够不着。想在路由守卫、工具函数里读购物车?Context 只在 React 树内可见,Provider 还必须包在所有消费者上方。
更符合直觉的形态,是后端早就用熟的东西:组件树之外放一个全局单例,谁关心谁订阅,谁想改谁调用。这就是 Zustand——npm install zustand 一行装好,核心 API 只有一个 create,也是当下小型应用状态管理的事实标准。
Spring 里跨模块共享内存态,你的第一反应从来不是层层传参:建一个 @Component 单例 Bean 持有状态,各处直接注入;变更后 publish 一个 ApplicationEvent,关心方 @EventListener 按需响应。Zustand 的 store 就是那个单例 Bean,selector 就是那个监听器——订阅、退订、分发的接线全自动。
create<T>():一个对象装下 state 和 action
Zustand 的全部魔法浓缩成一个函数 create:传入「初始化函数」,返回值直接就是 Hook。返回的对象里数据字段和修改函数肩并肩——没有 action type,没有 dispatch,没有 reducer。完整购物车 store 如下:
import { create } from "zustand";
export interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: CartItem[];
add: (item: Omit<CartItem, "quantity">) => void;
remove: (id: number) => void;
clear: () => void;
}
export const useCartStore = create<CartState>()((set, get) => ({
items: [],
add: (item) => {
// get:随时读当前最新 state,这里做「最多 20 件」的守卫
if (get().items.length >= 20) return;
set((state) => {
const found = state.items.find((i) => i.id === item.id);
if (!found) {
return { items: [...state.items, { ...item, quantity: 1 }] };
}
// 不可变更新:map 造出新数组,命中的项换成新对象
return {
items: state.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
),
};
});
},
remove: (id) =>
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
clear: () => set({ items: [] }),
}));
五个值得停留的点:
create<CartState>()(...)双括号:先标泛型再调用,TypeScript 才能正确推断 Hook 类型——v4/v5 标准写法,照抄即可。- state 与 action 一体:
items是数据,add/remove/clear是普通函数字段,改状态 = 调函数。 set是浅合并:只更新你给的字段,其余原样保留——像只 UPDATE 指定的列。传函数时参数保证是最新 state,连续调用也读不到旧值。get用来读最新 state,在 action 内部跨字段判断时用——上面做了「最多 20 件」的守卫。- 不可变更新是硬纪律:
map/filter/展开永远造新数组新对象,绝不 push/splice。为什么拧巴?回忆第 5 篇:React 靠Object.is引用比较判断「变没变」,原地改引用不变,等于「改了但没通知」,界面纹丝不动。
对照一个 Spring 单例 Service 看:items 是它的字段,add/remove/clear 是三个 public 方法,set/get 就是方法里的读写。唯一多出的纪律是「换新对象而非原地改」——每次变更留下一版完整快照,而非改内存指针。快照式变更正是引用比较与回放的地基,第 19 篇会把它推到极致。
选择器订阅:Context 是广播,Zustand 是点播
store 写好了,组件怎么「用」它?关键动作是给 selector——一个从整个 state 里挑出你关心的那一小块的函数:
import { useCartStore } from "./cartStore";
// 导航栏徽章:只订阅「总件数」这一个原始值(派生 selector)
function CartBadge() {
const count = useCartStore((s) =>
s.items.reduce((n, i) => n + i.quantity, 0)
);
return <span className="badge">{count}</span>;
}
// 购物车页:订阅 items 数组和 remove 函数本身
function CartList() {
const items = useCartStore((s) => s.items);
const remove = useCartStore((s) => s.remove);
return items.map((i) => <CartRow key={i.id} item={i} onRemove={remove} />);
}
机制值得拆开看:store 每次变更,Zustand 会对每个订阅组件跑一遍它的 selector,把这次的结果和上次做 Object.is 比较——相等就当无事发生,组件不重渲染。于是 add 一件商品时:CartList 的 items 引用变了,重渲染;CartBadge 算出的 count 还是 5,跳过;用着别的 store 的组件毫不知情。
顺带一个反例:useCartStore() 不带 selector 等于订阅整个 store,任何字段变都重渲染——精准订阅就白买了,别这么写。
| 维度 | Context(第 15 篇) | Zustand |
|---|---|---|
| 数据放哪 | 组件树上层的 Provider | 组件树之外的全局单例 |
| 更新传播 | value 一变,所有 useContext 消费者全部重渲染 | selector 结果没变就不重渲染 |
| 订阅粒度 | 整个 value,不可拆分 | 任意切片,还支持派生计算 |
| 组件外读写 | 不行,离开 React 树拿不到 | 行,store 是普通模块对象 |
| 适合的状态 | 低频、近乎只读的全局配置 | 高频交互的业务状态 |
Context 像「全量推送」的配置中心:任何字段变了,整个 value 打包推给所有订阅方,谁拿到都得重算一遍。Zustand 的 selector 像带过滤条件的消息订阅(JMS 的 MessageSelector、MQTT 的 topic):订阅方声明「我只关心 items 的引用」,比对没变就不投递。广播是「你猜要不要处理」,点播是「没点单不上菜」。
useCartStore((s) => ({ count: s.items.length, empty: !s.items.length }))——每次都是新引用,Object.is 永远不等,重渲染比不订阅还勤。返回原始值(数字、字符串、布尔)或 store 里的原引用都安全;一次要拿多个字段,用 useShallow(zustand/react/shallow)做浅比较。组件外也要读写?store 是普通模块对象
表格里「组件外读写:行」这一条不是虚言——store 就是一个导出的模块对象,没有 Hook 也能碰它:
import { useCartStore } from "./cartStore";
// 埋点上报:不在 React 树里,没有 Hook 可调
export function reportCartSize() {
const { items } = useCartStore.getState(); // 直接读当前快照
track("cart_size", { size: items.length });
}
// 写也一样:路由拦截里直接调 action
export function blockCheckoutIfEmpty() {
if (useCartStore.getState().items.length === 0) {
return "/cart"; // 购物车为空,拦回购物车页
}
return null;
}
两点语义要记牢:getState() 返回的是调用那一刻的快照,不订阅、不触发任何重渲染——组件外要「持续跟踪」变更,用 useCartStore.subscribe(listener) 手动挂监听、记得适时取消。而 Context 离了 React 树连影子都摸不到——这正是第 1 站说「离开组件树就够不着」那处别扭的解药。
persist 与 devtools:store 身上的 AOP 切面
刷新页面,购物车空了——用户体验的直接扣分项。按第 22 篇的框架,持久化要做「写入存储、启动恢复、版本迁移」三件事,Zustand 把它们做成中间件:在 create 外包一层,业务代码零改动。
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
// CartState 接口与 action 实现同第 2 站,略
export const useCartStore = create<CartState>()(
devtools(
persist(
(set) => ({
items: [],
// add / remove / clear 与第 2 站完全一致,略
clear: () => set({ items: [] }),
}),
{
name: "cart-storage", // localStorage 里的 key,唯一必填项
// 只挑数据字段持久化:函数不可序列化,UI 临时状态不该跨会话恢复
partialize: (s) => ({ items: s.items }),
}
)
)
);
拆开看:
- persist:每次 set 后自动把(partialize 挑出的)状态写进
localStorage,下次启动自动读回当初始 state。「两行代码」毫不夸张——name一行、partialize一行。 - partialize 为什么必要:action 是函数,序列化不了;「抽屉开没开」这类临时 UI 状态也不该跨会话恢复。哪些字段该落盘,每次都要想清楚。
- devtools:接入浏览器 Redux DevTools 插件,每次 set 记一条变更,可查看、可回跳——线上问「购物车怎么变成这样」有账可查。
这两个中间件对你毫无新意——它们就是 AOP。persist 是「写后通知」切面:业务只管改内存状态,切面在变更后把快照落盘(write-behind 缓存的思路),业务代码对存储零感知。devtools 是审计日志切面:每次变更自动记一条流水。换切面不改业务代码,正是「切面」的定义。
什么规模该上,什么规模不必
工具到手,最后一件事是判断:什么时候该请它出场。先给一张速查表:
| 场景 | 建议 | 一句话理由 |
|---|---|---|
| 单组件内的开关、输入值 | local state(第 4 篇) | 别人的状态别外包 |
| 父子两层传递 | 状态提升(第 14 篇) | 一层 props 就解决 |
| 低频全局配置(主题/语言) | Context(第 15 篇) | 只读广播,够用 |
| 3 层以上共享、跨路由存活的客户端状态 | Zustand | 精准订阅 + 组件外可读写 |
| 超大团队、需要强规范与审计 | Redux Toolkit(第 19 篇) | 为纪律交样板税 |
| 服务端数据(列表、详情) | TanStack Query(第 33 篇) | 别塞进任何 store |
该上的信号:同一状态被 3 个以上不相邻的组件使用(购物车横跨导航、详情、列表页);状态需要跨路由存活,切到详情页再回来侧边栏不能被重置;Context 已出现「一处变更、全树骚动」的性能问题而状态偏偏更新频繁。
不必上的信号:只有一个组件用,useState 就到头了(第 4 篇);数据来自接口、要缓存要失效,那是服务端状态,TanStack Query 的事(第 17、33 篇);表单输入这类高频临时值,连状态提升都该慎重,第 21 篇专门讲。
一条红线:别把接口返回的数据塞进 Zustand 当缓存。一旦塞进去,缓存失效、去重、竞态、重试、乐观更新,每一件都得手搓且比看起来难——这正是第 17 篇把状态分成两类的全部理由。
- Zustand = 组件树外的全局单例 + 按需订阅:
create<T>()((set, get) => ({ ...数据, ...action }));store 是普通模块对象,组件外getState()读快照、直接调 action。 - state 与 action 一体;set 浅合并、get 读最新值;不可变更新是硬纪律——引用比较决定 UI 是否更新。
- selector 精准订阅:结果 Object.is 不变就不重渲染;别返回字面量新对象,多字段用 useShallow。
- persist/devtools 是 AOP 式中间件:两行配置完成持久化与审计,业务代码零改动。
- 规模判断:3 层以上共享、跨路由存活的客户端状态才值得上;服务端数据一律交给 TanStack Query。
本章回顾
第 17 篇问「状态分几类」,这一篇给了客户端状态的默认答案:几行代码的全局单例,无 Provider、无样板、精准订阅。但隐忧也在:Zustand 给的自由太大——action 随便定义、状态随便组织、变更没有强制记录。三五人的项目里这是优点,三十人的项目里这就是悬案工厂:「items 到底被谁改的?」下一篇第 19 篇,看 Redux Toolkit 如何用「一切变更都是事件」的纪律,换回大规模下的可预测性。
Comments · 评论