首页 / React 学习笔记 / 18

REACT · Vol.III · LESSON 18 · 状态管理

Zustand:轻量状态管理实战

实战核心#状态#Zustand

状态提升与 Context 之后,还差一块拼图

上一篇第 17 篇把状态劈成两类:服务端状态交给专用方案(第 33 篇 TanStack Query),客户端状态才是本篇的主角。商品页点「加入购物车」,导航栏徽章要 +1,购物车页要显示新条目——同一份状态,三四个不相邻的组件同时关心

你已经会两招。第一招状态提升(第 14 篇):把 items 提到最近的公共祖先再层层下发。可公共祖先是 App——它拿着一份和自己毫无关系的购物车数据,中间每层组件被迫加两个 props 进出口,像「调用链上每个方法都得加个参数,只为传给最底层」。

第二招 Context(第 15 篇)。能用,但有两处别扭:

CartContext.tsx · Context 版购物车:能跑,但别扭
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,也是当下小型应用状态管理的事实标准。

后端类比:单例 Bean + 事件监听

Spring 里跨模块共享内存态,你的第一反应从来不是层层传参:建一个 @Component 单例 Bean 持有状态,各处直接注入;变更后 publish 一个 ApplicationEvent,关心方 @EventListener 按需响应。Zustand 的 store 就是那个单例 Bean,selector 就是那个监听器——订阅、退订、分发的接线全自动。

create<T>():一个对象装下 state 和 action

Zustand 的全部魔法浓缩成一个函数 create:传入「初始化函数」,返回值直接就是 Hook。返回的对象里数据字段和修改函数肩并肩——没有 action type,没有 dispatch,没有 reducer。完整购物车 store 如下:

cartStore.ts · 购物车 store:state 与 action 一体
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: [] }),
}));

五个值得停留的点:

  1. create<CartState>()(...) 双括号:先标泛型再调用,TypeScript 才能正确推断 Hook 类型——v4/v5 标准写法,照抄即可。
  2. state 与 action 一体items 是数据,add/remove/clear 是普通函数字段,改状态 = 调函数。
  3. set浅合并:只更新你给的字段,其余原样保留——像只 UPDATE 指定的列。传函数时参数保证是最新 state,连续调用也读不到旧值。
  4. get 用来最新 state,在 action 内部跨字段判断时用——上面做了「最多 20 件」的守卫。
  5. 不可变更新是硬纪律map/filter/展开 永远造新数组新对象,绝不 push/splice。为什么拧巴?回忆第 5 篇:React 靠 Object.is 引用比较判断「变没变」,原地改引用不变,等于「改了但没通知」,界面纹丝不动。
set 内部默默做了三件事:把新状态合并进单例、逐个跑 selector 并用 Object.is 比对结果、通知「结果变了」的订阅组件。你一行监听器代码都没写——观察者的接线,create 已经焊死在 store 里了。
后端类比:单例 Bean 的读写接口

对照一个 Spring 单例 Service 看:items 是它的字段,add/remove/clear 是三个 public 方法,set/get 就是方法里的读写。唯一多出的纪律是「换新对象而非原地改」——每次变更留下一版完整快照,而非改内存指针。快照式变更正是引用比较与回放的地基,第 19 篇会把它推到极致。

选择器订阅:Context 是广播,Zustand 是点播

store 写好了,组件怎么「用」它?关键动作是给 selector——一个从整个 state 里挑出你关心的那一小块的函数:

components.tsx · 用 selector 精准订阅
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 是普通模块对象
适合的状态低频、近乎只读的全局配置高频交互的业务状态
后端类比:全量广播 vs 精准推送

Context 像「全量推送」的配置中心:任何字段变了,整个 value 打包推给所有订阅方,谁拿到都得重算一遍。Zustand 的 selector 像带过滤条件的消息订阅(JMS 的 MessageSelector、MQTT 的 topic):订阅方声明「我只关心 items 的引用」,比对没变就不投递。广播是「你猜要不要处理」,点播是「没点单不上菜」。

selector 的返回值有个高频坑:返回「现造的对象/数组」,比如 useCartStore((s) => ({ count: s.items.length, empty: !s.items.length }))——每次都是新引用,Object.is 永远不等,重渲染比不订阅还勤。返回原始值(数字、字符串、布尔)或 store 里的原引用都安全;一次要拿多个字段,用 useShallowzustand/react/shallow)做浅比较。

组件外也要读写?store 是普通模块对象

表格里「组件外读写:行」这一条不是虚言——store 就是一个导出的模块对象,没有 Hook 也能碰它:

checkout.ts · 路由守卫 / 埋点 / 工具函数里的 store 用法
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 外包一层,业务代码零改动。

cartStore.ts · persist + devtools,业务零改动
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 切面

这两个中间件对你毫无新意——它们就是 AOP。persist 是「写后通知」切面:业务只管改内存状态,切面在变更后把快照落盘(write-behind 缓存的思路),业务代码对存储零感知。devtools 是审计日志切面:每次变更自动记一条流水。换切面不改业务代码,正是「切面」的定义。

localStorage 只有约 5MB、同步 IO、仅本标签页生效。数据量大、跨标签页同步、要和后端账号打通时就得升级——persist 的 version/migrate 迁移与多端同步,第 22 篇展开。

什么规模该上,什么规模不必

工具到手,最后一件事是判断:什么时候该请它出场。先给一张速查表:

场景建议一句话理由
单组件内的开关、输入值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 · 评论