一个
set({page: 0})看起来很轻。但如果它发生在首页轮播这样的高频场景里,又没有值变化守卫,就可能把 Dot 指示器、曝光打点、订单提醒轮询和跨 store 订阅全部唤醒一遍。
这就是 Zustand 使用规范要解决的问题:不是"怎么把状态存起来",而是"怎么让一次状态变化只影响真正需要响应的消费者"。
本文以首页 BigBanner 轮播为例,沿着一条完整的状态链路拆开看:
- 业务首页
BigBanner轮播 -
Dot指示器 - 曝光打点
- 订单提醒卡片轮询
读完后,你应该能理解:
- 为什么
set()前必须自己判断值是否变化? - selector、
subscribe()、getState()分别应该用在什么边界? - 如何避免高频状态把通知链、异步请求和模块级资源一起拖大?
规则
使用 Zustand 时,先记住这六条:
-
set()前必须由 action 自己判断值是否真的变化。 - selector 只取值,不做计算,不创建新的对象或数组。
- UI 渲染用
useStore(selector),副作用用subscribe(),事件和定时器读取当前值用getState()。 - 跨 store 订阅必须能截断通知链,不能无条件调用另一个 store 的 action。
- 模块级订阅、定时器、缓存状态必须在最后一个消费者卸载时成对释放。
- 异步回包写入 store 前,必须重新校验当前状态。
这些不是单纯的性能优化,而是为了避免高频场景下的重复通知、级联更新、旧数据回写和资源泄漏。
换句话说,Zustand 的核心风险不在"状态很多",而在"不必要的通知太多"。
1. 先看 createStore 的封装语义
版本前提:zustand@5.0.6。
示例中没有直接使用 zustand/create,而是基于 createWithEqualityFn 做了一层封装:
typescript
import {StateCreator, StoreMutatorIdentifier} from 'zustand';
import {shallow} from 'zustand/shallow';
import {createWithEqualityFn} from 'zustand/traditional';
export const createStore = <T, Mos extends Array<[StoreMutatorIdentifier, unknown]> = []>(
initalStore: StateCreator<T, [], Mos>
) => createWithEqualityFn(initalStore, shallow);
这意味着:
-
useXxxStore(selector)默认使用shallow作为 equality function。 - 这个默认值只影响 React hook selector 的结果比较。
- 它不影响
setState是否通知 listener。
需要区分两层比较:
| 层级 | 发生位置 | 比较对象 | 默认比较 |
|---|---|---|---|
setState 内部守卫 |
zustand/vanilla |
nextState 与当前整个 state |
Object.is |
| React hook selector | useSyncExternalStoreWithSelector |
上一次 selector 结果与本次 selector 结果 | 当前封装为 shallow |
结论:默认 shallow 不能阻止 set() 通知,只能在通知发生后减少一部分 React re-render。
这一点很关键。很多人以为"默认 shallow"能挡掉重复更新,但它其实只发生在 hook selector 这一层。真正的通知,从 setState 那一刻就已经发出去了。
2. set():通知风暴的起点
Zustand setState 的核心逻辑可以简化为:
ini
const setState = (partial, replace) => {
const nextState = typeof partial === 'function' ? partial(state) : partial;
if (!Object.is(nextState, state)) {
const previousState = state;
state = replace
? nextState
: Object.assign({}, state, nextState);
listeners.forEach(listener => listener(state, previousState));
}
};
注意这里唯一的内置守卫是:
vbnet
Object.is(nextState, state)
它比较的是"传给 set 的对象"和"当前整个 state 对象"。日常写法 set({page: 0}) 每次都会创建一个新的对象字面量,所以这个比较几乎一定为 false。
也就是说,下面两次调用都会产生新的 state 引用,并通知所有 listener:
scss
set({page: 0});
set({page: 0});
即使 page 的值没有变化,通知也已经发生。
规范写法
因此,action 中不要裸写 set({field}),必须先读取旧值并比较:
ini
setPage: (page) => {
if (get().page === page) {
return;
}
set({page});
}
首页轮播 store 的实际实现:
dart
export const useBigBannerStore = createStore<BigBannerProps>((set, get) => ({
page: 0,
setPage: (page) => {
if (get().page !== page) {
set({page});
}
},
}));
这里的守卫不是微优化。page 下游连着多个订阅者:
-
Dot指示器订阅page后命令式改样式。 -
useBannerPv订阅page后做曝光判断。 -
order-reminder订阅page后判断是否切到第三方配送订单卡片。
如果 setPage 没有守卫,重复设置当前页就会把这条链路完整通知一遍。状态值没变,但副作用已经跑起来了。
浅合并约束
set({partial}) 默认是浅合并:
ini
state = Object.assign({}, state, nextState);
因此嵌套对象不会深合并:
less
// 当前 state
{filter: {keyword: 'milk', page: 1}}
// 更新
set({filter: {page: 2}})
// 结果
{filter: {page: 2}}
对应规范:
- store state 尽量扁平。
- 必须更新嵌套对象时,显式 spread 旧对象。
- 不使用
set(partial, true)的 replace 模式。state 和 action 放在同一个对象里时,replace 会覆盖 action 函数。
3. selector:只负责订阅切片,不负责派生计算
createWithEqualityFn 最终会走到:
scss
useSyncExternalStoreWithSelector(
api.subscribe,
api.getState,
api.getInitialState,
selector,
equalityFn
);
它的核心行为是:
- store 每次
set后,整个 state 对象引用都会变化。 -
useSyncExternalStoreWithSelector会重新执行 selector。 - selector 结果再通过 equality function 与上一次结果比较。
- 相等则复用旧 selection,不触发组件 re-render;不相等则触发 re-render。
所以,selector 的执行频率由 store 更新频率决定,而不是由当前组件最终是否 re-render 决定。只要 store 被通知,selector 就要先跑一遍。
推荐写法
ini
const page = useBigBannerStore(s => s.page);
const setPage = useBigBannerStore(s => s.setPage);
const items = useSomeStore(s => s.items);
确实需要多个字段时,可以谨慎使用数组选择:
ini
const [a, b] = useSomeStore(s => [s.a, s.b], shallow);
禁止写法
ini
useSomeStore(s => ({a: s.a, b: s.b}));
useSomeStore(s => [s.a]);
useSomeStore(s => s.items.filter(item => item.active));
useSomeStore(s => ({list: s.items.map(normalize)}));
这些写法的问题分成三类:
| selector 写法 | 机制问题 | 后果 |
|---|---|---|
| 返回数组或对象字面量 | 每次 selector 执行都会创建新引用 | 即使 shallow 能判等,也有分配和逐项比较成本 |
filter / map / reduce |
每次 store 更新都重新计算 | 新数组通常会让浅比较失效 |
| 包装派生对象 | 外层对象是新引用,内层数组或对象也经常是新引用 | re-render 范围容易扩大 |
派生数据放在哪里
| 场景 | 推荐位置 |
|---|---|
| 只服务当前组件渲染 | 组件内 useMemo,依赖 selector 取出的原始字段 |
| 多组件共享派生结果 | action 中计算并写入 store,保持引用稳定 |
| 点击、提交、打点时才需要 | 事件回调里读 ref 或 useXxxStore.getState() |
不要把 selector 当 computed getter 使用。它的职责是"订阅切片",不是"派生状态"。
4. 三个 API 的边界:渲染、订阅、取快照
| API | 它在做什么 | 是否触发 React re-render | 典型用途 |
|---|---|---|---|
useStore(selector) |
订阅某个渲染切片 | selector 结果变化时触发 | UI 渲染 |
subscribe(listener) |
订阅 store 变化 | 不触发 | 副作用、打点、命令式更新、跨 store 同步 |
getState() |
读取当前快照 | 不触发 | 定时器、事件回调、异步回包读取当前值 |
UI 渲染用 useStore
值要参与 render 输出时,用 selector:
ini
const page = useBigBannerStore(s => s.page);
不要在 render 函数体内用 getState() 读取 UI 展示值。这样会绕开 React 订阅,store 更新后 UI 不会自动刷新。
副作用用 subscribe
Dot 指示器不需要因为 page 变化而重渲染整个组件树。它只需要在当前页变化时修改旧 dot 和新 dot 的 native 样式:
ini
useEffect(() => {
return useBigBannerStore.subscribe((state) => {
const newIdx = state.page;
const oldIdx = activeIdxRef.current;
if (newIdx === oldIdx) {
return;
}
activeIdxRef.current = newIdx;
itemRefs.current[oldIdx]?.setNativeProps?.({style: scaledNormalItemStyle});
textRefs.current[oldIdx]?.setNativeProps?.({style: scaledNormalTextStyle});
itemRefs.current[newIdx]?.setNativeProps?.({style: scaledActiveItemStyle});
textRefs.current[newIdx]?.setNativeProps?.({style: scaledActiveTextStyle});
});
}, [scaledNormalItemStyle, scaledActiveItemStyle, scaledNormalTextStyle, scaledActiveTextStyle]);
这种写法的边界很清楚:
-
subscribe写在 effect 中。 - 返回
unsubscribe作为 cleanup。 - 回调中先判断关心字段是否变化。
定时器用 getState
自动轮播定时器只需要在 tick 时读取当前页,不需要订阅 page:
ini
timerRef.current = setInterval(() => {
const current = useBigBannerStore.getState().page;
const next = (current + 1) % totalRef.current;
onChangePageRef.current(next);
}, intervalRef.current);
如果这里改成"render 阶段把 page 同步到 ref",父组件不 re-render 时就可能读到旧值。这个陷阱在高频优化场景里很常见,下一节单独展开。
5. 回到现场:BigBanner 的状态流
首页轮播的状态流可以概括为:
lua
SafePagerView onPageSelected
|
v
useBigBannerShell.handlePageSelected
|
v
useBigBannerStore.setPage
|
+--> Dot.subscribe: setNativeProps 更新指示器
|
+--> useBannerPv.subscribe: page 变化后检查曝光
|
+--> useOrderReminderStore.subscribe: page 变化后解析当前 banner
为什么 page 不放在大组件 useState
page 是高频状态:
- 用户滑动会更新。
- 点击 dot 会更新。
- 自动轮播定时器会更新。
- banner 数据变化时可能被重置为 0。
如果 page 是首页大组件的 useState,每次翻页都会驱动大组件 re-render。当前实现把 page 放在独立 store 中,让消费者按用途分开:
| 消费者 | 读取方式 | 原因 |
|---|---|---|
useBigBannerShell |
useBigBannerStore(s => s.setPage) |
只需要 action,不订阅 page |
useAutoplay |
useBigBannerStore.getState().page |
tick 时读取当前值 |
Dot |
subscribe |
只做命令式样式更新 |
useBannerPv |
subscribe |
只做曝光副作用 |
order-reminder |
subscribe |
只在页码变化时解析当前卡片 |
这个拆分的目标,是把更新范围限制到真正的消费者,而不是让轮播容器或整个首页跟着动。
6. ref 同步陷阱:旧值不是 ref 的错,是同步时机错了
下面这种写法很容易出问题:
ini
const page = useBigBannerStore(s => s.page);
const pageRef = useRef(page);
pageRef.current = page;
setInterval(() => {
onChange(pageRef.current);
}, 5000);
问题不在 useRef,而在同步时机。
ini
pageRef.current = page;
这句只在 render phase 执行。如果当前组件没有因为 page 变化而 re-render,ref 就不会更新。高频优化里,父组件通常故意不订阅 page,所以这种写法会把挂载时的旧值带进定时器。
正确边界
可以在 render 阶段同步 props:
ini
const totalRef = useRef(total);
totalRef.current = total;
const onChangePageRef = useRef(onChangePage);
onChangePageRef.current = onChangePage;
原因是 props 变化会触发组件 render,ref 同步有 React 数据流保证。
但不要用 render phase ref 同步 store 高频值。跨时间点读取 store 当前值时,用:
scss
useBigBannerStore.getState().page
或者在 subscribe 回调里更新 ref。Dot 的 activeIdxRef 属于后者:
ini
const activeIdxRef = useRef(useBigBannerStore.getState().page);
useEffect(() => {
return useBigBannerStore.subscribe((state) => {
const newIdx = state.page;
const oldIdx = activeIdxRef.current;
if (newIdx === oldIdx) {
return;
}
activeIdxRef.current = newIdx;
});
}, []);
7. 跨 store 订阅:每一跳都要能停下来
一次 set 会通知该 store 的所有 listener。如果 listener 无条件调用另一个 store 的 action,就会形成级联:
css
store A set
-> A listeners
-> store B action
-> B set
-> B listeners
如果每一跳都没有字段变化判断,任何无关更新都可能放大成跨 store 通知。
订单提醒同时订阅桌面配置 store 和 BigBanner store:
ini
prevActiveBigBanners = activeStore.getState().bigBanners;
activeStoreUnsubscribe = activeStore.subscribe((state) => {
if (state.bigBanners !== prevActiveBigBanners) {
prevActiveBigBanners = state.bigBanners as PageV2Resource[];
if (!useOrderReminderStore.getState().isResetting) {
void useOrderReminderStore.getState().resetPolling('activeBigBanners');
}
}
});
prevBigBannerPage = useBigBannerStore.getState().page;
syncBigBannerSlideInfo(prevBigBannerPage);
bigBannerUnsubscribe = useBigBannerStore.subscribe((state) => {
if (state.page !== prevBigBannerPage) {
prevBigBannerPage = state.page;
syncBigBannerSlideInfo(state.page);
}
});
这里有三层截断:
-
state.bigBanners !== prevActiveBigBanners:桌面配置其它字段变化时不重置轮询。 -
!isResetting:reset 过程中不重入。 -
state.page !== prevBigBannerPage:page未变时不重新解析 banner。
被调用的 action 也必须继续守卫:
ini
setIsSlideActive: (active) => {
const prev = get().isSlideActive;
if (prev === active) {
return;
}
set({isSlideActive: active});
},
setCurrentOrderId: (orderId) => {
if (get().currentOrderId === orderId) {
return;
}
set({currentOrderId: orderId, latestDeliveryData: null});
},
规范:跨 store 订阅不允许无条件调用另一个 store 的 action。回调必须先判断"本次通知是否真的影响我关心的字段"。只有每一跳都能停下来,通知链才不会越滚越大。
8. 生命周期:模块级资源不会自己消失
order-reminder store 中存在模块级资源:
ini
let pollingTimer: ReturnType<typeof setInterval> | null = null;
let mountedConsumers = 0;
let lastFetchTimestamp = 0;
let prevActiveBigBanners: PageV2Resource[] = [];
let prevBigBannerPage = useBigBannerStore.getState().page;
let activeStoreUnsubscribe: (() => void) | null = null;
let bigBannerUnsubscribe: (() => void) | null = null;
这些变量位于模块作用域,不受 React 组件生命周期管理。组件卸载时,它们不会自动释放,必须由 store action 显式清理。
引用计数
订单提醒使用 mountedConsumers 管理多个消费者:
scss
setMounted: (mounted) => {
if (mounted) {
mountedConsumers += 1;
if (mountedConsumers === 1) {
initStoreSubscriptions();
set({mounted: true});
}
} else {
mountedConsumers = Math.max(0, mountedConsumers - 1);
if (mountedConsumers === 0) {
get().stopPolling();
disposeStoreSubscriptions();
set({mounted: false, isSlideActive: false, latestDeliveryData: null, currentOrderId: null});
}
}
},
对应约束:
- 第一个消费者 mount 时初始化模块级订阅。
- 最后一个消费者 unmount 时停止 polling。
- 最后一个消费者 unmount 时调用所有
unsubscribe。 - unmount 时清空
latestDeliveryData、currentOrderId、isSlideActive,避免下次 mount 闪现旧数据。
释放函数需要把订阅和引用都清掉:
ini
const disposeStoreSubscriptions = () => {
activeStoreUnsubscribe?.();
bigBannerUnsubscribe?.();
activeStoreUnsubscribe = null;
bigBannerUnsubscribe = null;
};
9. 异步回包:回来时世界可能已经变了
订单提醒轮询请求发出时,当前订单和当前页面状态可能在回包前发生变化。因此,请求前需要固定本次订单:
ini
const requestOrderId = state.currentOrderId;
回包后重新读取 store 当前状态:
kotlin
if (!canRun(get())) {
logger.notice(LOG_TAG, '[fetch] result discarded: gate closed after fetch');
return;
}
if (get().currentOrderId !== requestOrderId) {
logger.notice(LOG_TAG, '[fetch] stale response dropped, requestOrderId=%d', requestOrderId);
return;
}
这两个检查分别处理:
- 组件卸载、页面不可见、功能关闭、当前不在目标 slide。
- 用户已经切到其它 banner 或订单,旧请求回包不能写入当前 store。
异步回包中不要依赖请求发出时闭包里的 state。请求发出时的 state 只能说明"当时可以发",不能说明"现在还可以写"。真正写入前,需要重新调用 get() 或 useXxxStore.getState()。
10. 订阅数量控制:订阅要贴着 UI 更新边界
Zustand hook 订阅不是免费的。每次 store 通知时,相关 selector 都要重新执行,并通过 equality function 判断是否需要 re-render。
规范:
- 同一区域超过 10 个组件订阅同一 store 时,优先考虑由父组件读取后通过 props 分发。
- 列表项禁止每个 item 各自
useStore订阅同一 store。 - 高扇出组件在 render 时只订阅必要原始字段,事件发生时再用 ref 或
getState()读取非渲染字段。 -
ReportButton这类页面内可能出现多实例的组件,不应订阅整块 store state。
判断标准:订阅应该跟 UI 更新边界一致。组件不需要因某个字段变化而重新 render,就不要用 useStore 订阅这个字段。
11. Code Review 检查清单
Review Zustand 相关代码时,可以按下面的顺序检查:
- action 是否在
set()前判断了值变化。 - 是否存在
set({field})这种无守卫写法。 - selector 是否只返回原始值或稳定引用。
- selector 里是否有对象字面量、数组字面量、
filter、map、reduce。 - 用于 UI 展示的值是否通过
useStore(selector)订阅。 - 副作用、打点、命令式更新是否放在
subscribe()中。 -
subscribe()是否写在 effect 中,并返回了 cleanup。 -
subscribe()回调是否先判断关心字段是否变化。 - 定时器、事件回调、异步回包是否用
getState()读取当前值。 - 是否存在 render phase ref 同步 store 高频值的写法。
- 跨 store 订阅是否有变化守卫,是否可能形成级联 set。
- 模块级变量、定时器、订阅是否能在最后一个消费者卸载时释放。
- 异步请求回包写入 store 前,是否重新校验当前页面和当前业务对象。
12. 反模式对照表
| 反模式 | 机制问题 | 后果 | 改法 |
|---|---|---|---|
action 中裸 set({field}) |
Object.is(nextState, state) 挡不住对象字面量 |
值没变也通知所有 listener | if (get().field === field) return |
| store state 深层嵌套 | Zustand 默认浅合并 | 更新时容易覆盖兄弟字段 | 扁平化,或显式 spread |
| selector 返回对象或数组字面量 | 每次执行创建新引用 | 分配成本、shallow 遍历成本、潜在 re-render |
返回原始值或稳定引用 |
selector 内 filter / map |
每次 store 更新都计算 | shallow 无法比较内层新引用 |
组件 useMemo 或 action 预计算 |
用 useStore 做打点 |
hook 订阅与 React render 绑定 | 副作用跟渲染耦合 | 使用 subscribe |
subscribe 无 cleanup |
listener 留在 Set 中 | 内存泄漏、卸载后执行 | effect 返回 unsubscribe |
subscribe 回调不判断字段 |
store 任意 set 都会通知 |
无关更新触发副作用 | 比较 state / prevState,或维护 prev 快照 |
| render phase 同步 store 值到 ref | ref 只在 render 时更新 | 父组件不 render 时读到旧值 | 定时器、回调用 getState() |
render 中直接 getState() 展示 UI |
绕开 React 订阅 | UI 不随 store 更新 | 使用 useStore(selector) |
| 跨 store 回调无条件调 action | 通知链无法截断 | 级联 set、重复轮询、重复打点 | 每一跳都加变化守卫 |
| unmount 不清 store 数据 | 模块级 state 保留 | 下次 mount 闪旧数据 | 最后消费者卸载时置空 |
总结
Zustand 的 API 很轻,但轻不等于没有成本。尤其在首页轮播这类高频、多人消费、跨 store 联动的场景里,一次没有守卫的 set() 可能会被放大成一串订阅、副作用和异步请求。
核心原则是:让状态更新只发生在值真的变化时,让订阅边界贴近真实 UI 更新边界,让副作用有明确的生命周期。做到这三点,Zustand 才能既保持简单,又不会在复杂页面里悄悄积累风险。