陌上人如玉
公子世无双

11.13 手写状态管理(mini Zustand)

11.13 手写状态管理(mini Zustand)

一句话:Zustand 的最小实现只有「一个闭包保存 state + 一个 Set 存监听器 + setState 做浅合并并通知」,React 侧靠 useSyncExternalStore + 选择器(带相等性比较与结果缓存) 订阅——关键难点不是 store 本身,而是「选择器返回新对象时的引用稳定」与「并发渲染下的撕裂」。

一、先看它要提供什么

// ✅ 目标 API(与 Zustand 一致)
const useCounterStore = create((set, get) => ({
  count: 0,
  step: 1,
  increment: () => set((s) => ({ count: s.count + s.step })),
  setStep: (step) => set({ step }),
  reset: () => set({ count: 0 }, true),        // ⭐ 第二参 replace:整体替换
}));

// ① 在组件里用「选择器」订阅(只订阅需要的切片)
function Counter() {
  const count = useCounterStore((s) => s.count);             // ⭐ 只订阅 count
  const increment = useCounterStore((s) => s.increment);     // 函数引用稳定 → 不会重渲染
  return <button onClick={increment}>{count}</button>;
}

// ② 在 React 之外也能用(这是它比 Context/Redux 更方便的一点)
useCounterStore.getState().increment();
useCounterStore.setState({ count: 100 });
const unsub = useCounterStore.subscribe((state, prev) => console.log(state, prev));

二、核心实现(1):store 本体(约 60 行)

// mini-zustand.ts —— 第 1 部分:store 本体(零依赖,可直接跑)

export type StateCreator<S> = (
  set: (
    partial: Partial<S> | ((state: S) => Partial<S> | S),
    replace?: boolean
  ) => void,
  get: () => S,
  api: StoreApi<S>
) => S;

export interface StoreApi<S> {
  getState: () => S;
  setState: (
    partial: Partial<S> | ((state: S) => Partial<S> | S),
    replace?: boolean
  ) => void;
  subscribe: (listener: (state: S, prevState: S) => void) => () => void;
  getInitialState: () => S;
  /** ⭐ 内部的「版本号」:让选择器缓存能判断「是否需要重算」 */
  __version: () => number;
}

function createStore<S extends object>(createState: StateCreator<S>): StoreApi<S> {
  let state: S;
  let version = 0;                                // ⭐ 每次真正变化就 +1
  const listeners = new Set<(state: S, prevState: S) => void>();

  const setState: StoreApi<S>['setState'] = (partial, replace) => {
    // ① 支持「函数式更新」(能读到最新 state,避免闭包陷阱)
    const nextPartial =
      typeof partial === 'function' ? (partial as (s: S) => Partial<S> | S)(state) : partial;

    // ② 用 Object.is 短路:完全相同的引用 → 不做任何事(避免无谓通知)
    if (Object.is(nextPartial, state)) return;
    if (nextPartial === null || typeof nextPartial !== 'object') return;

    const previousState = state;

    // ③ ⭐ 默认「浅合并」;replace = true 时「整体替换」
    //    注意判据:只有当「结果是对象且没显式要求 replace」时才合并
    state = (
      replace ?? (typeof nextPartial !== 'object' || nextPartial === null)
        ? nextPartial
        : Object.assign({}, state, nextPartial)
    ) as S;

    version++;
    // ④ 通知所有监听器(⭐ 注意是「复制一份数组」以防监听器里又增删监听器)
    [...listeners].forEach((listener) => listener(state, previousState));
  };

  const getState = () => state;
  const getInitialState = () => initialState;
  const subscribe = (listener: (state: S, prevState: S) => void) => {
    listeners.add(listener);
    return () => { listeners.delete(listener); };
  };

  const api: StoreApi<S> = { setState, getState, getInitialState, subscribe, __version: () => version };

  // ⭐ 初始化:把 set/get/api 交给用户,返回值就是初始 state
  const initialState = createState(setState, getState, api);
  state = initialState;

  return api;
}
⭐⭐ 这段实现里有四个「设计决定」,每个都对应一个真实问题:

① 【为什么默认「浅合并」而不是「替换」?】
   因为「只改一个字段」是最常见的操作:
     set({ count: 1 })   →  期望保留其它字段
   如果默认替换,用户每次都得写 `set({ ...get(), count: 1 })`。
   ⭐ 但同时保留 `replace: true` 给「需要整体替换」的场景
     (如「切换用户时把整个 store 换成新对象」)。

② 【为什么用 `Object.is` 短路?】
   避免「`set(get())` 这种『看起来变了但其实没变』的操作」触发整棵订阅树重渲染。

③ 【为什么要判 `typeof nextPartial !== 'object'`?】
   因为「部分更新」必须能安全地与旧 state 合并。
   如果用户 `set(s => null)`(返回非对象),合并会得到 `{}`(丢数据)→
   ⭐ 这种情况应该走「替换」语义(这也是上面三元表达式的用意)。

④ 【为什么通知时用 `[...listeners]`?】
   因为监听器内部可能「再订阅/取消订阅」(如组件在回调里卸载),
   直接遍历原 Set 会在「迭代中修改集合」时行为不确定。

三、核心实现(2):React 集成(约 90 行)

// mini-zustand.ts —— 第 2 部分:React 集成
import { useRef, useCallback, useSyncExternalStore } from 'react';

/** ⭐ 浅比较(默认的「相等性判断」之外的另一种选择) */
export function shallow<T>(a: T, b: T): boolean {
  if (Object.is(a, b)) return true;
  if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) return false;

  const keysA = Object.keys(a as object);
  const keysB = Object.keys(b as object);
  if (keysA.length !== keysB.length) return false;

  for (const key of keysA) {
    if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
    if (!Object.is((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) return false;
  }
  return true;
}

/**
 * ⭐⭐ 带「选择器 + 相等性比较」的订阅 Hook。
 *
 * 最容易被忽略的难点:**选择器返回新对象时,必须缓存「上一次的结果」**,
 * 否则每次 getSnapshot 都返回新引用 → useSyncExternalStore 认为「一直在变」
 * → 无限重渲染(React 会直接抛 "The result of getSnapshot should be cached")。
 */
function useStoreWithSelector<S extends object, U>(
  api: StoreApi<S>,
  selector: (state: S) => U,
  equalityFn: (a: U, b: U) => boolean = Object.is
): U {
  // ⭐ 用 ref 保存「上一次的选择结果」(跨渲染持久)
  const cacheRef = useRef<{ hasValue: boolean; value: U; version: number }>({
    hasValue: false,
    value: undefined as unknown as U,
    version: -1,
  });

  // ⭐ 注意:这里用 ref 保存「最新的 selector」,避免把它放进 useCallback 依赖
  //    (否则用户传内联箭头函数时,getSnapshot 每帧都变)
  const selectorRef = useRef(selector);
  selectorRef.current = selector;

  const getSelection = useCallback(() => {
    const cache = cacheRef.current;
    const state = api.getState();
    const currentVersion = api.__version();

    // ⭐ ① 「state 没变过」→ 直接返回缓存(避免重算选择器)
    if (cache.hasValue && cache.version === currentVersion) {
      return cache.value;
    }

    const next = selectorRef.current(state);
    cache.version = currentVersion;

    // ⭐⭐ ② 「这次的切片与上次『相等』」→ 返回【上一次的引用】
    //    这是「防止无限重渲染」与「避免无谓重渲染」的关键
    if (cache.hasValue && equalityFn(cache.value, next)) {
      return cache.value;
    }

    cache.hasValue = true;
    cache.value = next;
    return next;
  }, [api, equalityFn]);

  // ⭐⭐ useSyncExternalStore:React 官方的「订阅外部 store」入口
  //    它解决的是「并发渲染下的撕裂(tearing)」问题——见下方说明
  return useSyncExternalStore(
    api.subscribe as (onStoreChange: () => void) => () => void,
    getSelection,
    getSelection                 // 服务端渲染的快照(这里用同一函数)
  );
}
// mini-zustand.ts —— 第 3 部分:create 与选择器糖

export type UseBoundStore<S extends object> = {
  (): S;
  <U>(selector: (state: S) => U, equalityFn?: (a: U, b: U) => boolean): U;
  getState: StoreApi<S>['getState'];
  setState: StoreApi<S>['setState'];
  subscribe: StoreApi<S>['subscribe'];
  getInitialState: StoreApi<S>['getInitialState'];
};

/** ⭐ 主入口:create 返回「既可以当 Hook 用、又挂着 store 方法」的函数 */
export function create<S extends object>(createState: StateCreator<S>): UseBoundStore<S> {
  const api = createStore(createState);

  const useBoundStore = ((selector?: (state: S) => U, equalityFn?: (a: U, b: U) => boolean) => {
    // ⭐ 不传选择器 → 订阅整个 state(⚠️ 每次任意字段变化都会重渲染)
    return useStoreWithSelector(api, selector ?? ((s: S) => s), equalityFn);
  }) as UseBoundStore<S>;

  // ⭐ 把 store 方法挂到「同一个函数对象」上
  Object.assign(useBoundStore, {
    getState: api.getState,
    setState: api.setState,
    subscribe: api.subscribe,
    getInitialState: api.getInitialState,
  });

  return useBoundStore;
}

/** ⭐ useShallow:让「返回新对象的选择器」也能引用稳定 */
export function useShallow<S, U>(selector: (state: S) => U) {
  const lastRef = useRef<{ value: U } | null>(null);
  return (state: S) => {
    const next = selector(state);
    if (lastRef.current && shallow(lastRef.current.value, next)) {
      return lastRef.current.value;               // ⭐ 相等 → 复用旧引用
    }
    lastRef.current = { value: next };
    return next;
  };
}
⭐⭐⭐ 「`useSyncExternalStore` 到底解决了什么」(这是本篇最重要的一段):

   问题:**【撕裂(tearing)】**。
   并发渲染下,一次渲染可以被「中断并分帧」,期间**外部 store 可能变化**。
   如果组件用 `useState + useEffect + subscribe` 自己订阅:
     ① 在「渲染 A 组件」时读到的快照是 S1
     ② 渲染被让出(此时 store 变成 S2)
     ③ 继续渲染 B 组件时读到 S2
     → ⭐ **同一个界面里 A 与 B 用了不同版本的数据**(撕裂)

   `useSyncExternalStore` 的解法:**React 会保证「同一次提交里所有组件读到同一个快照」**——
   如果发现「渲染中被读到的快照已经变了」,就**丢弃这次渲染并同步重来**。
   ⭐ 所以它不是为了「方便」,而是为了「**正确性**」。

   ⚠️ 推论:**`getSnapshot` 必须返回「缓存过的稳定引用」**——
   否则 React 会认为「永远在变」→ 无限重渲染
   (开发期会直接报错:"The result of getSnapshot should be cached to avoid an infinite loop")。
   → 这就是上面 `useStoreWithSelector` 里那套 `cacheRef` 存在的原因。

四、完整实现:中间件(约 80 行)

// mini-zustand-middleware.ts —— 中间件(签名与 Zustand 一致)

type Middleware<S> = (
  config: StateCreator<S>
) => (set: StoreApi<S>['setState'], get: () => S, api: StoreApi<S>) => S;

/** ============ ① logger:打印每一步状态变化 ============ */
export const logger =
  <S extends object>(config: StateCreator<S>): StateCreator<S> =>
  (set, get, api) =>
    config(
      (partial, replace) => {
        const prev = get();
        set(partial, replace);
        const next = get();
        console.groupCollapsed('[zustand] 状态更新');
        console.log('%c prev', 'color:#999', prev);
        console.log('%c next', 'color:#0a0', next);
        console.groupEnd();
      },
      get,
      api
    );
/** ============ ② persist:持久化到 storage(含版本迁移) ============ */
export interface PersistOptions<S> {
  key: string;
  storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
  /** ⭐ 只持久化一部分字段(避免把「临时 UI 状态」也存进去) */
  partialize?: (state: S) => Partial<S>;
  /** ⭐ 版本号 + 迁移函数(数据结构变化时把旧数据转成新结构) */
  version?: number;
  migrate?: (persisted: unknown, version: number) => Partial<S>;
  /** ⭐ 重新水合时是否「合并」而不是「替换」 */
  merge?: (persisted: Partial<S>, current: S) => S;
}

interface PersistedShape {
  state: unknown;
  version: number;
}

export const persist =
  <S extends object>(options: PersistOptions<S>): Middleware<S> =>
  (config: StateCreator<S>): StateCreator<S> =>
  (set, get, api) => {
    const storage = options.storage ?? (typeof localStorage !== 'undefined' ? localStorage : undefined);
    const currentVersion = options.version ?? 0;

    // ⭐ ① 启动时「读盘 + 迁移 + 与初始 state 合并」
    let rehydrated: Partial<S> | undefined;
    if (storage) {
      try {
        const raw = storage.getItem(options.key);
        if (raw) {
          const parsed = JSON.parse(raw) as PersistedShape | Partial<S>;

          // 兼容「带版本号」与「裸 state」两种历史格式
          const isVersioned = parsed && typeof parsed === 'object' && 'state' in parsed && 'version' in parsed;
          const persistedState = (isVersioned ? (parsed as PersistedShape).state : parsed) as Partial<S>;
          const persistedVersion = isVersioned ? (parsed as PersistedShape).version : 0;

          if (persistedVersion !== currentVersion && options.migrate) {
            rehydrated = options.migrate(persistedState, persistedVersion);   // ⭐ 迁移
          } else {
            rehydrated = persistedState;
          }
        }
      } catch (err) {
        console.warn('[persist] 读取失败,忽略持久化数据', err);
      }
    }

    // ⭐ ② 先按「用户 config」建初始 state,再用持久化的数据覆盖
    const initialState = config(set, get, api);

    const merged = rehydrated
      ? (options.merge
          ? options.merge(rehydrated, initialState)
          : // ⭐ 默认「浅合并」:持久化数据覆盖初始值,未持久化的字段保留初始值
            ({ ...initialState, ...rehydrated } as S))
      : initialState;

    if (rehydrated) {
      // ⭐ 用 replace 保证「不会把 initialState 的旧值混进来」
      api.setState(merged as Partial<S>, true);
    }

    // ⭐ ③ 每次更新后写盘(只写 partialize 选出的字段)
    api.subscribe((state) => {
      if (!storage) return;
      try {
        const toPersist = options.partialize ? options.partialize(state) : state;
        storage.setItem(options.key, JSON.stringify({ state: toPersist, version: currentVersion }));
      } catch (err) {
        console.warn('[persist] 写入失败(可能是配额满)', err);
      }
    });

    return merged;
  };
/** ============ ③ devtools:把动作名带到 Redux DevTools ============ */
export const devtools =
  <S extends object>(name = 'zustand'): Middleware<S> =>
  (config: StateCreator<S>): StateCreator<S> =>
  (set, get, api) => {
    const devtoolsApi = (globalThis as { __REDUX_DEVTOOLS_EXTENSION__?: { connect: (o: unknown) => { send: (a: unknown, s: unknown) => void; init: (s: unknown) => void } } })
      .__REDUX_DEVTOOLS_EXTENSION__;

    if (!devtoolsApi) return config(set, get, api);      // ⭐ 没装扩展就「透明跳过」

    const connection = devtoolsApi.connect({ name });

    return config(
      (partial, replace) => {
        set(partial, replace);
        // ⭐ 这里的「动作名」只能用一个固定字符串(真正的 Zustand 支持传 action name)
        connection.send({ type: `${name}/setState`, payload: partial }, get());
      },
      get,
      api
    );
  };
⭐⭐ 中间件的三个「设计要点」:

① 【中间件的签名是「config → config」】
   `(config) => (set, get, api) => state`
   → ⭐ 它包装的是「state creator」而不是「store 本体」,
     所以多个中间件可以**嵌套组合**:
     create(devtools(persist(logger(config))))
   ⭐ 组合顺序决定「谁先看到更新」(从内到外/从外到内要分清)。

② 【中间件通过「包装 set」来拦截更新】
   这是最有用的模式:想要「日志 / 持久化 / 校验 / 上报」,
   就包一层 set,在前后插入逻辑。
   ⚠️ 但套多层时要注意「每层都调 get()」会多读几次(可接受)。

③ 【`persist` 的「水合时机」是个真问题】
   ⭐ 同步 storage(localStorage)可以在初始化时水合;
      但如果是异步 storage(IndexedDB / AsyncStorage),
      就**无法在「首次渲染前」拿到数据** → 需要「先渲染默认值,水合后再更新」
      (这也是「水合闪烁」的来源)。
   ⭐ 另一个真问题:**「持久化数据」与「初始 state 的字段不匹配」**
      (你删了字段、改了结构)→ 必须靠 `version + migrate` 处理,
      否则会出现「state 缺字段」的诡异 bug。

五、与其它方案的对比

方案 心智模型 订阅粒度 并发安全 适用
Zustand 「一个外部可变对象 + 选择器订阅」 ⭐ 选择器级(细) ✅ useSyncExternalStore 大多数中小型应用、需要「React 外读写」
Redux 「纯函数 reducer + 单一 action 流」 选择器级(useSelector) ✅(v8 起用 uSES) 大团队、需要严格可追溯/中间件生态
Jotai 「原子(atom)组合」 ⭐ 原子级(最细) ✅ 派生值多、细粒度依赖
Valtio 「Proxy 可变对象 + 自动追踪」 属性级(自动) ⚠️ 需要 useSnapshot 喜欢「直接改对象」的写法
Context 「树上的值传递」 ❌ 整棵子树 ✅(但性能差) 主题、i18n 等「低频变化」的配置
useState 提升 「状态放最近公共祖先」 ❌ 父组件子树 ✅ 局部状态
⭐⭐ 一个「选型」的判断清单:

① 【状态「归谁」?】
   · 只有一个组件/一小块用 → `useState`(别急着上库)
   · 跨越多个不相邻的组件 → store(Zustand/Jotai)或 Context
   · 需要「URL 可分享」→ 放 URL(nuqs / 路由参数)

② 【更新频率高吗?】
   · 高频(鼠标位置、输入框、拖拽)→ 必须「细粒度订阅」
     → Zustand 选择器 / Jotai 原子 / 局部 state + 引用
     ❌ Context 在这类场景会「整棵子树重渲染」

③ 【需要「React 外读写」吗?】
   · 需要(在事件总线、非 React 模块、定时器里读写)→ Zustand/Valtio
   · 不需要 → Context / Jotai 也够

④ 【团队规模与「可追溯性」要求?】
   · 大团队、要求「每次变更都可回溯」→ Redux(DevTools 时间旅行 + 中间件生态)
   · 小团队、追求简单 → Zustand

六、使用示例(可跑)

// ✅ 一个带 persist 与 logger 的 store
import { create } from './mini-zustand';
import { persist, logger, devtools } from './mini-zustand-middleware';

interface CartState {
  items: Array<{ id: string; name: string; price: number; qty: number }>;
  coupon: string | null;
  /** ⭐ 临时 UI 状态(不持久化) */
  drawerOpen: boolean;
  addItem: (item: { id: string; name: string; price: number }) => void;
  removeItem: (id: string) => void;
  setCoupon: (code: string | null) => void;
  toggleDrawer: () => void;
  total: () => number;
}

export const useCart = create<CartState>()(
  devtools(
    logger(
      persist(
        (set, get) => ({
          items: [],
          coupon: null,
          drawerOpen: false,

          addItem: (item) =>
            set((s) => {
              const existing = s.items.find((i) => i.id === item.id);
              // ⭐ 函数式更新:读到「最新」的 items,避免闭包陷阱
              return existing
                ? { items: s.items.map((i) => (i.id === item.id ? { ...i, qty: i.qty + 1 } : i)) }
                : { items: [...s.items, { ...item, qty: 1 }] };
            }),

          removeItem: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
          setCoupon: (coupon) => set({ coupon }),
          toggleDrawer: () => set((s) => ({ drawerOpen: !s.drawerOpen })),

          // ⭐ 派生值做成「方法」而不是「存进 state」
          //    理由:存派生值需要在每次更新时同步维护它(容易不一致)
          total: () => {
            const { items, coupon } = get();
            const sum = items.reduce((acc, i) => acc + i.price * i.qty, 0);
            return coupon === 'HALF' ? sum / 2 : sum;
          },
        }),
        {
          key: 'cart-v1',
          version: 1,
          // ⭐ 只持久化「数据」,不持久化「临时 UI 状态」与「方法」
          partialize: (s) => ({ items: s.items, coupon: s.coupon }),
          migrate: (persisted, version) => {
            if (version === 0) {
              // 旧版本把 items 存成对象({ id: qty })→ 转成数组
              const old = persisted as { cart?: Record<string, number> } | undefined;
              const cart = old?.cart ?? {};
              return {
                items: Object.entries(cart).map(([id, qty]) => ({
                  id, name: id, price: 0, qty,
                })),
                coupon: null,
              };
            }
            return persisted as Partial<CartState>;
          },
        }
      )
    ),
    'cart'
  )
);
/** ============ 组件里使用(观察「只订阅需要的切片」的效果) ============ */
function CartBadge() {
  // ⭐ 只订阅「数量」→ 购物车条目增删时重渲染,改 coupon 时【不】重渲染
  const count = useCart((s) => s.items.reduce((acc, i) => acc + i.qty, 0));
  return <span className="badge">{count}</span>;
}

function CartTotal() {
  // ⚠️ 注意:这里传的是「内联箭头函数」,每次渲染都是新函数
  //    但 useStoreWithSelector 里用 ref 保存了最新 selector,所以不会因此无限渲染
  const total = useCart((s) => s.items.reduce((acc, i) => acc + i.price * i.qty, 0));
  return <strong>¥{total.toFixed(2)}</strong>;
}

function CartList() {
  // ⭐ 返回「数组」时会每次都是新引用 → ⚠️ 必须用 useShallow
  const names = useCart(useShallow((s) => s.items.map((i) => i.name)));
  return <ul>{names.map((n) => <li key={n}>{n}</li>)}</ul>;
}
⭐⭐ 两个「性能」观察(这是 Zustand 相比 Context 的核心优势):

① 【`CartBadge` 只订阅「数量」】
   改 `coupon` 时它**不重渲染**——而如果用 Context,整棵子树都会重渲染。
   ⭐ 因为选择器返回的是「数字」(基本类型),比较 O(1)。

② 【`CartList` 必须用 `useShallow`】
   因为 `s.items.map(...)` 每次都是**新数组**:
   · 不用 `useShallow` → `Object.is` 比较失败 → 每次 store 变化都重渲染
   · 用 `useShallow` → 逐项浅比较,内容相同就**复用旧引用** → 不重渲染
   ⭐ 这是「选择器返回派生集合」时最容易漏掉的一步。

七、本篇特有的坑

// ① 选择器返回「新对象/新数组」但没做相等性比较(无限重渲染或每次都渲染)
const { a, b } = useStore((s) => ({ a: s.a, b: s.b }));      // ❌ 每次新对象
// ✅ const a = useStore((s) => s.a); const b = useStore((s) => s.b);
// ✅ 或 useShallow((s) => ({ a: s.a, b: s.b }))

// ② 把「内联 selector」放进 useCallback 依赖(导致 getSnapshot 每帧都变)
const getSelection = useCallback(() => selector(state), [selector]);   // ⚠️ 内联函数 → 每帧变
// ✅ 用 ref 保存最新 selector(见实现里的 selectorRef)

// ③ getSnapshot 返回新引用(React 直接报错)
// ⭐ 开发期错误:"The result of getSnapshot should be cached to avoid an infinite loop"
// ✅ 在 getSelection 里缓存「上一次的结果 + 版本号」

// ④ 用 useState + useEffect 自己订阅(并发渲染下会「撕裂」)
const [state, setState] = useState(store.getState());
useEffect(() => store.subscribe(setState), []);               // ❌ 可能撕裂 + 丢更新
// ✅ useSyncExternalStore

// ⑤ subscribe 返回的是「取消订阅函数」被当成「Promise」忘记调用
store.subscribe(fn);                                          // ❌ 没保存返回值 → 泄漏
useEffect(() => store.subscribe(fn), []);                     // ✅ 返回它 = 卸载时取消

// ⑥ setState 里直接改「旧 state 的字段」(就地修改)
setState((s) => { s.count++; return s; });                    // ❌ 引用没变 → 不通知
// ✅ setState((s) => ({ count: s.count + 1 }))

// ⑦ 用「深层嵌套的 set 路径」期望自动合并
set({ user: { name: 'x' } });        // ⚠️ 覆盖整个 user(浅合并!)
// ✅ set({ user: { ...get().user, name: 'x' } }) 或用 immer 中间件

// ⑧ 把「派生值」存进 state(需要每次手动维护)
set({ total: computeTotal(next) });                           // ⚠️ 容易忘、容易不一致
// ✅ 派生值用「选择器」或「方法」实时算

// ⑨ `persist` 把「函数」也序列化(JSON.stringify 会丢掉它们)
storage.setItem(key, JSON.stringify(state));                  // ⚠️ 函数被丢弃(无害但浪费)
// ✅ 用 partialize 只存数据

// ⑩ `persist` 的「水合」覆盖了新代码的默认值(新增字段丢失)
// ⭐ 场景:你在新版本里给 state 加了一个字段,但用户 localStorage 里的是旧结构
//    → 浅合并后「旧对象覆盖初始值」,新字段丢了
// ✅ 用 version + migrate;或用「merge:先初始值、再持久化值,但只覆盖『持久化过的键』」

// ⑪ 异步 storage(IndexedDB/AsyncStorage)导致「首屏闪烁出默认值」
// ⭐ 这是真问题:水合前只能渲染默认 state
// ✅ 加「hydrated 标志」,在水合完成前渲染骨架(或阻塞渲染)

// ⑫ 在「render 期间」调用 `store.setState`(触发「渲染中更新另一个组件」)
function Bad() {
  useStore.setState({ x: 1 });                                // ❌ 可能死循环
  return null;
}
// ✅ 只允许在「事件处理/Effect/外部」里 setState

// ⑬ 「订阅整个 store」当作默认用法
const state = useStore();                                     // ⚠️ 任何字段变化都重渲染
// ✅ 永远用选择器

// ⑭ 多个 store 之间「互相依赖」(A 的更新触发 B 的更新)
// ⚠️ 容易形成环 + 难以追踪
// ✅ 尽量「单向依赖」;必要时用一个「组合选择器」或「在 action 里显式调另一个 store」

// ⑮ 忘记「服务端渲染」时 useSyncExternalStore 的第三参(getServerSnapshot)
// ⚠️ SSR 下不提供会直接报错
// ✅ 传 `getSelection`(或一个「基于服务端数据的选择器」)

// ⑯ 认为「store 里的 state 能直接依赖 props」
// ⚠️ store 是「全局」的,不该知道任何组件的 props
// ✅ 需要「每个组件一份」的数据就不要放全局 store(那是 Context 或 props 的活)

// ⑰ 在 store 里做「异步请求」但不管竞态
actions: {
  async fetchUser(id) {
    const user = await api.getUser(id);
    set({ user });                                            // ⚠️ 快速切 id 时会「后发先至」
  }
}
// ✅ 记录「请求序号/id」,只在「最后一次请求」时 set

// ⑱ 用 `Object.assign` 合并时「原型链污染」(__proto__ 之类的键)
set(JSON.parse(untrustedInput));                              // ⚠️ 反序列化后直接 set
// ✅ 校验/白名单字段

// ⑲ 认为「Zustand 比 Context 快」是绝对的
// ⚠️ 如果「每次都订阅整个 store」或「选择器返回新对象」,它同样会到处重渲染
// ✅ 优势来自「细粒度选择器」,而不是库本身

// ⑳ 忘了「React 外读写」时「不会触发 React 更新」
// ⚠️ 在 setTimeout 里 `useStore.setState(...)`:
//    组件会重渲染(因为 uSES 订阅了)——这点是对的;
//    但如果是在「非 React 的事件系统」里,注意「不在 transition 内」→ 可能是同步更新
// ⑰ 的完整处理(在 store 内处理异步竞态)
interface UserState {
  user: User | null;
  loading: boolean;
  fetchUser: (id: string) => Promise<void>;
}

export const useUser = create<UserState>((set, get) => ({
  user: null,
  loading: false,

  fetchUser: async (id) => {
    set({ loading: true });
    const requestId = `${id}-${Date.now()}`;
    currentRequestId = requestId;                              // ⭐ 记录最新请求

    try {
      const user = await api.getUser(id);
      // ⭐ 只有当「这还是最新的那个请求」时才写入
      if (currentRequestId === requestId) set({ user, loading: false });
    } catch (err) {
      if (currentRequestId === requestId) set({ loading: false });
    }
  },
}));

let currentRequestId = '';
// ⑩ 的完整处理(「新字段不丢」的 merge 策略)
merge: (persisted, current) => {
  const persistedKeys = new Set(Object.keys(persisted as object));
  const merged: Record<string, unknown> = { ...current };
  // ⭐ 只覆盖「持久化数据里真的有的键」→ 新版本新增的字段保留初始值
  for (const key of persistedKeys) {
    merged[key] = (persisted as Record<string, unknown>)[key];
  }
  return merged as CartState;
},

八、面试延伸

  1. 「手写一个 Zustand,核心是什么?」
    三块:① store 本体——用一个闭包变量保存 state、一个 Set 存监听器,setState 做「默认浅合并 + replace 可整体替换」,变化后通知所有监听器(用 Object.is 短路避免无谓通知);② React 集成——用 useSyncExternalStore 订阅,并配一个「带相等性比较与结果缓存」的选择器封装(⭐ 这是最容易写错的地方:getSnapshot 必须返回稳定的引用,否则 React 会报 “should be cached” 并无限重渲染);③ 中间件——签名是「config → config」((config) => (set, get, api) => state),从而可以嵌套组合,最常见的模式是「包装 set」来插入日志/持久化/上报。

  2. 「为什么用 useSyncExternalStore,而不是 useState + useEffect 订阅?」
    因为 useState + useEffect 会「撕裂(tearing)」:并发渲染下,一次渲染可以被中断并分帧,期间外部 store 可能变化——于是「A 组件读到了旧快照、B 组件读到了新快照」,同一个界面里用了两个版本的数据。useSyncExternalStore 的保证是「同一次提交里所有组件读到同一个快照」——如果发现「渲染中快照变了」,React 会丢弃本次渲染并同步重来。⭐ 所以它解决的是「正确性」,不只是「方便」。另有一个附带好处:它天然处理了「订阅时机与 hydration」的问题。

  3. 「为什么 getSnapshot 必须返回缓存过的引用?」
    因为 useSyncExternalStore 的判断逻辑是「比较前后两次 getSnapshot() 的返回值」——如果每次调用都返回新对象,React 会认为「快照一直在变」→ 无限重渲染(开发期会直接报 “The result of getSnapshot should be cached to avoid an infinite loop”)。所以实现里必须:① 用 ref 缓存「上一次的选择结果」;② 加上一个「版本号」在 setState 时自增,版本没变就跳过重算;③ 用相等性比较(Object.is 或 shallow)判断「新结果是否等同于旧结果」,相等就返回旧引用。

  4. 「选择器返回对象/数组时怎么办?」
    三种做法:① 拆成多个原子选择器——const a = useStore(s => s.a)(⭐ 推荐,最简单也最不容易出错);② 用 useShallow——useStore(useShallow(s => ({ a: s.a, b: s.b }))),它做逐项浅比较,内容相同就复用旧引用;③ ⭐ 在 store 外部算派生值(store.getState() + 自己缓存)。为什么这是个真问题:s => s.items.map(...) 每次返回新数组,Object.is 永远失败 → 每次 store 变化都重渲染(甚至可能无限重渲染)。注意:Zustand v5 起移除了自动浅比较(v4 的默认行为变了),所以现在「返回新结构必须显式用 useShallow」是一件更要紧的事。

  5. 「persist 中间件有哪些坑?」
    四个:① 函数被序列化丢弃(JSON.stringify 会丢函数,无害但浪费)→ 用 partialize 只存数据;② 「新增字段丢失」——新版本给 state 加了字段,但用户 storage 里是旧结构,浅合并后旧对象覆盖初始值、新字段没了(✅ 解法:用 version + migrate,或让 merge 只覆盖「持久化数据里真的存在的键」);③ 异步 storage 的「水合时机」——IndexedDB/AsyncStorage 无法在「首次渲染前」拿到数据 → 首屏会闪现默认值(✅ 加 hydrated 标志,水合前渲染骨架);④ 数据不匹配的诡异 bug——你改了数据结构但没版本迁移 → state 缺字段。⭐ 所以「version 不是可选项」。

  6. 「zustand 为什么能『细粒度订阅』,而 Context 不行?」
    因为订阅的机制完全不同:① Context 是「值传递」——Provider 的 value 一变,所有消费该 Context 的组件都会重渲染(粒度是「整棵子树」);② Zustand 是「外部 store + 选择器订阅」——每个组件用选择器只订阅自己需要的切片,store 变化时只有「选择器结果发生变化」的组件才重渲染(粒度到「单个组件 + 单个切片」)。⭐ 所以「高频更新 + 多消费方」的场景(表单、拖拽、大表格)里,Zustand 的优势非常明显;而「低频配置」(主题、i18n)用 Context 完全够(见 5.9)。

  7. 「中间件的组合顺序怎么理解?」
    中间件是「config → config」的包装,组合顺序决定「谁在外面」。以 create(devtools(persist(logger(config)))) 为例:① 最外层是 devtools——它包装的 set 会最先看到你的调用,最后把结果发给 DevTools;② persist 在中间——它的 set 在「logger 之后」执行,负责写盘;③ 最内层是 logger——离「真正的 set」最近,它打印的是「合并后的最终状态」。⭐ 一个实用判据是「如果你想让某层拦截『最原始的调用』,就把它放在外面」;而「想看到『最终结果』的层(如 logger 打印 state)放在里面更清晰」。⚠️ 另外注意:create<T>()(...)(带类型参数的柯里化形式)是为了 TypeScript 上下文推断,与运行时无关。

  8. 「如果让你在项目里选状态管理方案,你怎么决定?」
    四个问题:① 状态归谁——只有一个组件用 → useState;跨不相邻组件 → store 或 Context;需要 URL 可分享 → 放 URL(nuqs / 路由参数);② 更新频率——高频(输入、拖拽、鼠标)→ 必须细粒度订阅(Zustand 选择器 / Jotai 原子),Context 在这种场景会整棵子树重渲染;③ 是否需要 React 外读写——需要(事件总线、非 React 模块、定时器)→ Zustand/Valtio;不需要 → Jotai/Context 也够;④ 团队与可追溯性——大团队、要求「每次变更可回溯 + 时间旅行」→ Redux(DevTools 生态成熟);追求简单 → Zustand。⭐ 默认建议:先用 useState/Context 撑到「真的痛」,再引入 store,避免「为了一个弹窗开关引入一个状态库」。

一句话速记

最小实现 =「闭包存 state + Set 存监听器 + setState 默认浅合并(replace 可整体替换)+ Object.is 短路 + 通知时复制监听器数组」;React 侧必须用 useSyncExternalStore(因为 useState + useEffect 订阅在并发渲染下会「撕裂」,而它保证「同一次提交里所有组件读到同一个快照」);⭐ 最大的坑是「getSnapshot 必须返回缓存过的引用」(用 ref 缓存结果 + 版本号 + Object.is/shallow 比较,否则 React 报 “should be cached” 并无限重渲染);选择器返回对象/数组时必须 useShallow(或拆成原子选择器);中间件签名是 config → config,靠「包装 set」实现,可嵌套组合(devtools(persist(logger(config)))),其中 persist 的 version+migrate 不是可选项(新字段丢失、结构变更、异步 storage 水合闪烁都是真问题);它相比 Context 的优势只来自「细粒度选择器」——订阅整个 store 或返回新对象时,它同样会到处重渲染。

赞(0) 打赏
未经允许不得转载:陌上寒 » 11.13 手写状态管理(mini Zustand)

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫