第 12 章

Zustand:极简状态管理的设计哲学

第12章:Zustand:极简状态管理的设计哲学

Zustand 的核心创见是一个看似简单的问题:为什么状态必须活在 React 里?

本章核心问题:Zustand 如何实现精准订阅?它与 Context 的性能差距有多大? 读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

Store 即闭包,而非 Context

Zustand 的核心创见是一个看似简单的问题:为什么状态必须活在 React 里?

Redux 把状态放在 Store 对象中,通过 react-reduxProvider 注入 React 树。MobX 同样需要通过某种方式把 observable 状态注入组件。早期的 Context 方案更是把状态直接嵌入 React 的渲染机制。这些方案都有一个隐含假设:状态是 React 的东西。

Zustand 拒绝这个假设。它的 store 是一个普通的 JavaScript 闭包——一个模块级别的对象,完全独立于 React 存在。React 组件通过 useSyncExternalStore(React 18+)订阅这个外部存储,当状态改变时,只有真正订阅了变化的部分的组件才会重新渲染。

import { create } from 'zustand';

// Store 是一个普通函数调用的返回值,不依赖任何 React API
const useBearStore = create<{
  bears: number;
  increase: () => void;
  reset: () => void;
}>()(set => ({
  bears: 0,
  increase: () => set(state => ({ bears: state.bears + 1 })),
  reset: () => set({ bears: 0 }),
}));

// 使用时像普通 Hook 一样调用
function BearCounter() {
  const bears = useBearStore(state => state.bears);
  return <h1>{bears} bears around here...</h1>;
}

function Controls() {
  const increase = useBearStore(state => state.increase);
  return <button onClick={increase}>Add a bear</button>;
}

注意 useBearStore 的调用方式:传入一个 selector 函数,只订阅需要的那部分状态。Controls 组件只订阅 increase 函数——函数引用不变,所以 Controls 永远不会因为 bears 数量变化而重新渲染。这正是 Context 做不到的。

Selector:派生状态与性能

Selector 是 Zustand 最重要的性能机制。每次调用 useStore(selector),Zustand 都会用 Object.is 比较 selector 的返回值与上次的返回值,只有不相等时才触发重渲染。

// 粒度控制:只订阅需要的字段
const firstName = useUserStore(state => state.user?.firstName);
const isAdmin = useUserStore(state => state.user?.role === 'admin');

// 派生状态计算
const useCartStore = create<CartStore>()(set => ({
  items: [] as CartItem[],
  addItem: (item) => set(state => ({
    items: [...state.items, item],
  })),
  removeItem: (id) => set(state => ({
    items: state.items.filter(i => i.id !== id),
  })),
}));

// 派生状态通过 selector 实现
function CartSummary() {
  // 这个计算只在 items 数组变化时重新执行
  const total = useCartStore(state =>
    state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
  );
  const itemCount = useCartStore(state => state.items.length);

  return (
    <div>
      <span>{itemCount} items</span>
      <span>Total: ${total.toFixed(2)}</span>
    </div>
  );
}

对于复杂的派生状态计算,建议用 useShallow 处理返回对象/数组的 selector:

import { useShallow } from 'zustand/react/shallow';

// 没有 useShallow:每次都返回新对象,导致无限重渲染
const { bears, increase } = useBearStore(state => ({
  bears: state.bears,
  increase: state.increase,
})); // 危险!每次渲染返回新对象,Object.is 始终为 false

// 有 useShallow:浅比较对象,只在内容变化时重渲染
const { bears, increase } = useBearStore(
  useShallow(state => ({ bears: state.bears, increase: state.increase }))
);

中间件系统:immer、devtools、persist

Zustand 的中间件是真正展示其设计美学的地方。中间件是函数组合,不是配置对象——这意味着你可以任意组合,且每个中间件的职责清晰。

import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import { devtools } from 'zustand/middleware';
import { persist } from 'zustand/middleware';

// 组合多个中间件
const useTaskStore = create<TaskStore>()(
  devtools(
    persist(
      immer((set) => ({
        tasks: [] as Task[],
        addTask: (task: Task) => set(state => {
          // immer 允许直接 mutate,内部用 Proxy 追踪变更
          state.tasks.push(task);
        }),
        toggleTask: (id: string) => set(state => {
          const task = state.tasks.find(t => t.id === id);
          if (task) task.completed = !task.completed;
        }),
        deleteTask: (id: string) => set(state => {
          state.tasks = state.tasks.filter(t => t.id !== id);
        }),
      })),
      {
        name: 'task-storage', // localStorage key
        // 只持久化部分状态
        partialize: (state) => ({ tasks: state.tasks }),
      }
    ),
    { name: 'TaskStore' } // DevTools 中显示的名称
  )
);

immer 中间件:告别手动不可变更新

没有 immer 时,更新嵌套状态极其繁琐:

// 没有 immer:手动扩展每一层
set(state => ({
  user: {
    ...state.user,
    profile: {
      ...state.user.profile,
      address: {
        ...state.user.profile.address,
        city: 'Shanghai',
      },
    },
  },
}));

// 有 immer:直接赋值,Proxy 追踪变更并生成不可变更新
set(state => {
  state.user.profile.address.city = 'Shanghai';
});

immer 使用 Proxy 追踪对 draft 对象的所有修改,在 producer 函数执行结束后生成一个新的不可变对象。零运行时 overhead 的心智模型,极大降低了复杂状态管理的出错率。

persist 中间件:状态持久化

import { persist, createJSONStorage } from 'zustand/middleware';

const useSettingsStore = create<SettingsStore>()(
  persist(
    (set) => ({
      theme: 'light' as 'light' | 'dark',
      language: 'zh-CN',
      setTheme: (theme) => set({ theme }),
      setLanguage: (language) => set({ language }),
    }),
    {
      name: 'user-settings',
      // 默认使用 localStorage,可以替换为 sessionStorage 或自定义
      storage: createJSONStorage(() => sessionStorage),
      // 版本迁移:当 store 结构改变时自动迁移旧数据
      version: 2,
      migrate: (persistedState: unknown, version: number) => {
        if (version === 1) {
          // 将旧格式迁移到新格式
          const old = persistedState as { darkMode: boolean };
          return { theme: old.darkMode ? 'dark' : 'light', language: 'zh-CN' };
        }
        return persistedState as SettingsStore;
      },
    }
  )
);

Slices 模式:大型应用的状态拆分

当应用规模增长,把所有状态放在一个 store 里会变得难以维护。Zustand 的 "slices" 模式解决了这个问题,同时保留了访问跨域状态的能力:

// authSlice.ts
export interface AuthSlice {
  user: User | null;
  login: (credentials: Credentials) => Promise<void>;
  logout: () => void;
}

export const createAuthSlice = (
  set: SetState<RootStore>,
  get: GetState<RootStore>
): AuthSlice => ({
  user: null,
  login: async (credentials) => {
    const user = await authAPI.login(credentials);
    set({ user });
  },
  logout: () => {
    set({ user: null });
    // 注意:通过 get() 可以访问其他 slice 的状态和方法
    get().clearCart(); // 清空购物车(来自 cartSlice)
  },
});

// cartSlice.ts
export interface CartSlice {
  cart: CartItem[];
  addToCart: (item: CartItem) => void;
  clearCart: () => void;
}

export const createCartSlice = (
  set: SetState<RootStore>
): CartSlice => ({
  cart: [],
  addToCart: (item) => set(state => ({ cart: [...state.cart, item] })),
  clearCart: () => set({ cart: [] }),
});

// store.ts:组合所有 slice
type RootStore = AuthSlice & CartSlice;

const useStore = create<RootStore>()((...args) => ({
  ...createAuthSlice(...args),
  ...createCartSlice(...args),
}));

// 按需导出特定 slice 的 selector
export const useAuth = () => useStore(useShallow(state => ({
  user: state.user,
  login: state.login,
  logout: state.logout,
})));

export const useCart = () => useStore(useShallow(state => ({
  cart: state.cart,
  addToCart: state.addToCart,
})));

Level 2 · 它是怎么运行的(3-5年经验)

Zustand 内部实现:useSyncExternalStore

理解 Zustand 的性能优势,需要深入它的内部实现。Zustand 的核心是 createStore 函数,它创建一个符合"外部存储"(external store)接口的对象:

// 简化版的 Zustand 核心实现(概念演示)
function createStore<T>(initializer: StateCreator<T>) {
  let state: T;
  const listeners = new Set<() => void>();

  const setState = (partial: Partial<T> | ((state: T) => Partial<T>)) => {
    const nextState = typeof partial === 'function'
      ? { ...state, ...partial(state) }
      : { ...state, ...partial };

    if (!Object.is(state, nextState)) {
      state = nextState;
      // 通知所有订阅者:状态改变了
      listeners.forEach(listener => listener());
    }
  };

  const getState = () => state;

  const subscribe = (listener: () => void) => {
    listeners.add(listener);
    return () => listeners.delete(listener); // 返回取消订阅函数
  };

  state = initializer(setState, getState, { setState, getState, subscribe });

  return { setState, getState, subscribe };
}

// React Hook 层:使用 useSyncExternalStore 桥接外部存储与 React
function useStore<T, S>(store: Store<T>, selector: (state: T) => S): S {
  return useSyncExternalStore(
    store.subscribe,           // 订阅函数
    () => selector(store.getState()), // 获取当前值
    () => selector(store.getState()), // 服务端渲染的初始值
  );
}

useSyncExternalStore 是 React 18 专门为状态管理库提供的 API。它解决了 React Concurrent Mode 下的 "tearing"(撕裂)问题——即在同一次渲染中,组件可能读取到不同版本的外部状态。useSyncExternalStore 保证了在并发渲染过程中,订阅同一外部存储的所有组件看到的状态是一致的。


Level 3 · 规范怎么定义的(资深)

create() 的类型系统与 API 设计

Zustand 的 TypeScript 集成是精心设计的,但有一个让初学者困惑的地方:create() 需要"柯里化调用":

// 正确的 TypeScript 写法(注意双括号)
const useStore = create<StoreState>()(/* initializer */);

// 这是 Zustand 绕过 TypeScript 类型推断限制的解决方案:
// TypeScript 不支持部分类型参数应用(partial type argument application)
// 柯里化让 TS 能先接收显式类型参数,再接收运行时参数

// 完整的 Store 类型定义
interface UserStore {
  user: User | null;
  isLoggedIn: boolean;
  setUser: (user: User) => void;
  logout: () => void;
}

const useUserStore = create<UserStore>()(set => ({
  user: null,
  isLoggedIn: false,
  setUser: (user) => set({ user, isLoggedIn: true }),
  logout: () => set({ user: null, isLoggedIn: false }),
}));

与 Context 的性能对比

回到上一章的基准场景,用代码完整展示差距:

// 场景:50 个子组件,共享计数器,每 100ms 更新一次

// Context 方案
function runContextBenchmark() {
  // 所有 50 个消费者在每次更新时都重新渲染
  // React DevTools Profiler: 每次更新约 50 次渲染,总计 ~12ms
}

// Zustand 方案
const useCountStore = create<{ count: number; noise: string }>()(set => ({
  count: 0,
  noise: 'irrelevant',
  increment: () => set(state => ({ count: state.count + 1 })),
  updateNoise: () => set({ noise: Math.random().toString() }),
}));

// 只订阅 count 的组件
function Counter() {
  const count = useCountStore(state => state.count); // 精准订阅
  return <div>{count}</div>;
}

// updateNoise 不会导致 Counter 重渲染——Object.is(oldCount, newCount) 为 true
// React DevTools Profiler: 每次 count 更新约 1 次渲染(该组件),总计 ~2ms

Zustand 在大型应用中的性能优势不仅来自于减少不必要的重渲染,还来自于它将状态更新决策从 React 的渲染调度器移到了纯 JavaScript 层——这让状态读写的性能接近普通对象访问的速度。

Zustand 的设计哲学证明:最好的抽象往往不是功能最多的,而是概念最少、但每个概念都恰到好处的。一个 store,一个 Hook,一个 selector——就这些。


Level 4 · 边界与陷阱(所有人)

生产环境常见问题

在实际项目中,本章涵盖的概念最常见的问题包括:

  1. 忽视边界条件:在正常路径下工作正常的代码,在异常路径(网络失败、用户快速操作、组件卸载)下可能产生 bug。始终考虑清理和取消逻辑。

  2. 过早优化:在没有测量的情况下添加优化代码,增加了复杂度但不一定提升性能。先用 Profiler 确认问题存在,再实施优化。

  3. 文档与实际行为的差异:React 的行为在不同版本间可能有微妙差异,尤其是并发模式相关的特性。始终以实际测试结果为准。

本章评分
4.6  / 5  (26 评分)

💬 留言讨论