
一句话: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;
},
八、面试延伸
- 「手写一个 Zustand,核心是什么?」
三块:① store 本体——用一个闭包变量保存 state、一个Set存监听器,setState做「默认浅合并 +replace可整体替换」,变化后通知所有监听器(用Object.is短路避免无谓通知);② React 集成——用useSyncExternalStore订阅,并配一个「带相等性比较与结果缓存」的选择器封装(⭐ 这是最容易写错的地方:getSnapshot必须返回稳定的引用,否则 React 会报 “should be cached” 并无限重渲染);③ 中间件——签名是「config → config」((config) => (set, get, api) => state),从而可以嵌套组合,最常见的模式是「包装set」来插入日志/持久化/上报。 -
「为什么用
useSyncExternalStore,而不是useState + useEffect订阅?」
因为useState + useEffect会「撕裂(tearing)」:并发渲染下,一次渲染可以被中断并分帧,期间外部 store 可能变化——于是「A 组件读到了旧快照、B 组件读到了新快照」,同一个界面里用了两个版本的数据。useSyncExternalStore的保证是「同一次提交里所有组件读到同一个快照」——如果发现「渲染中快照变了」,React 会丢弃本次渲染并同步重来。⭐ 所以它解决的是「正确性」,不只是「方便」。另有一个附带好处:它天然处理了「订阅时机与 hydration」的问题。 -
「为什么
getSnapshot必须返回缓存过的引用?」
因为useSyncExternalStore的判断逻辑是「比较前后两次getSnapshot()的返回值」——如果每次调用都返回新对象,React 会认为「快照一直在变」→ 无限重渲染(开发期会直接报 “The result of getSnapshot should be cached to avoid an infinite loop”)。所以实现里必须:① 用ref缓存「上一次的选择结果」;② 加上一个「版本号」在setState时自增,版本没变就跳过重算;③ 用相等性比较(Object.is或shallow)判断「新结果是否等同于旧结果」,相等就返回旧引用。 -
「选择器返回对象/数组时怎么办?」
三种做法:① 拆成多个原子选择器——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」是一件更要紧的事。 -
「
persist中间件有哪些坑?」
四个:① 函数被序列化丢弃(JSON.stringify会丢函数,无害但浪费)→ 用partialize只存数据;② 「新增字段丢失」——新版本给 state 加了字段,但用户 storage 里是旧结构,浅合并后旧对象覆盖初始值、新字段没了(✅ 解法:用version+migrate,或让 merge 只覆盖「持久化数据里真的存在的键」);③ 异步 storage 的「水合时机」——IndexedDB/AsyncStorage 无法在「首次渲染前」拿到数据 → 首屏会闪现默认值(✅ 加hydrated标志,水合前渲染骨架);④ 数据不匹配的诡异 bug——你改了数据结构但没版本迁移 → state 缺字段。⭐ 所以「version不是可选项」。 -
「zustand 为什么能『细粒度订阅』,而 Context 不行?」
因为订阅的机制完全不同:① Context 是「值传递」——Provider的 value 一变,所有消费该 Context 的组件都会重渲染(粒度是「整棵子树」);② Zustand 是「外部 store + 选择器订阅」——每个组件用选择器只订阅自己需要的切片,store 变化时只有「选择器结果发生变化」的组件才重渲染(粒度到「单个组件 + 单个切片」)。⭐ 所以「高频更新 + 多消费方」的场景(表单、拖拽、大表格)里,Zustand 的优势非常明显;而「低频配置」(主题、i18n)用 Context 完全够(见 5.9)。 -
「中间件的组合顺序怎么理解?」
中间件是「config → config」的包装,组合顺序决定「谁在外面」。以create(devtools(persist(logger(config))))为例:① 最外层是devtools——它包装的set会最先看到你的调用,最后把结果发给 DevTools;②persist在中间——它的set在「logger 之后」执行,负责写盘;③ 最内层是logger——离「真正的 set」最近,它打印的是「合并后的最终状态」。⭐ 一个实用判据是「如果你想让某层拦截『最原始的调用』,就把它放在外面」;而「想看到『最终结果』的层(如 logger 打印 state)放在里面更清晰」。⚠️ 另外注意:create<T>()(...)(带类型参数的柯里化形式)是为了 TypeScript 上下文推断,与运行时无关。 -
「如果让你在项目里选状态管理方案,你怎么决定?」
四个问题:① 状态归谁——只有一个组件用 →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 或返回新对象时,它同样会到处重渲染。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!