Zustand 状态管理规范:别让轻量状态变成隐形通知风暴

一个 set({page: 0}) 看起来很轻。

但如果它发生在首页轮播这样的高频场景里,又没有值变化守卫,就可能把 Dot 指示器、曝光打点、订单提醒轮询和跨 store 订阅全部唤醒一遍。

这就是 Zustand 使用规范要解决的问题:不是"怎么把状态存起来",而是"怎么让一次状态变化只影响真正需要响应的消费者"。

本文以首页 BigBanner 轮播为例,沿着一条完整的状态链路拆开看:

  • 业务首页 BigBanner 轮播
  • Dot 指示器
  • 曝光打点
  • 订单提醒卡片轮询

读完后,你应该能理解:

  • 为什么 set() 前必须自己判断值是否变化?
  • selector、subscribe()getState() 分别应该用在什么边界?
  • 如何避免高频状态把通知链、异步请求和模块级资源一起拖大?

规则

使用 Zustand 时,先记住这六条:

  1. set() 前必须由 action 自己判断值是否真的变化。
  2. selector 只取值,不做计算,不创建新的对象或数组。
  3. UI 渲染用 useStore(selector),副作用用 subscribe(),事件和定时器读取当前值用 getState()
  4. 跨 store 订阅必须能截断通知链,不能无条件调用另一个 store 的 action。
  5. 模块级订阅、定时器、缓存状态必须在最后一个消费者卸载时成对释放。
  6. 异步回包写入 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
);

它的核心行为是:

  1. store 每次 set 后,整个 state 对象引用都会变化。
  2. useSyncExternalStoreWithSelector 会重新执行 selector。
  3. selector 结果再通过 equality function 与上一次结果比较。
  4. 相等则复用旧 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。DotactiveIdxRef 属于后者:

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);
    }
});

这里有三层截断:

  1. state.bigBanners !== prevActiveBigBanners:桌面配置其它字段变化时不重置轮询。
  2. !isResetting:reset 过程中不重入。
  3. state.page !== prevBigBannerPagepage 未变时不重新解析 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 时清空 latestDeliveryDatacurrentOrderIdisSlideActive,避免下次 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 里是否有对象字面量、数组字面量、filtermapreduce
  • 用于 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 才能既保持简单,又不会在复杂页面里悄悄积累风险。

相关推荐
之歆1 小时前
Day03_ES6 深度解析与实战应用:运算符、Symbol、Class、集合与迭代协议
前端·ecmascript·es6
Carson带你学Android1 小时前
Kotlin放大招!官方 Skills 直接喂出「专家级」代码
android·前端·kotlin
之歆1 小时前
Day04_ES6完全指南:从入门到精通的现代化JavaScript开发
前端·javascript·es6
Coffeeee1 小时前
一个kotlin的Smart cast导致的编译问题
android·前端·kotlin
CodeSheep1 小时前
胡彦斌都开始苦修Vibe Coding,还上架App Store,都卷到编程来了吗?
前端·后端·程序员
触底反弹1 小时前
从数据结构到 Prompt 设计:前端工程师的 AI 时代进阶指南
javascript·人工智能·python
薄荷椰果抹茶1 小时前
前端技术之---打字机效果与流式输出
前端
Mintopia1 小时前
Tanstack为什么会火
前端
DongWook1 小时前
关于Harness Engineering的一次实践
前端·后端