Zustand 是一个”普通对象 + setState”的状态库,没有内置状态机概念。但 MindGym 的 5 个训练模块共享同一套生命周期(idle → countdown → playing → paused → complete),单靠普通 Zustand store 复制粘贴会导致严重的样板代码与不一致。本页拆解
createTrainingStore工厂 + lifecycle hooks 如何把”phase 状态机”与”trial 状态机”分两层管理。
概述
MindGym 的训练模块(Schulte / Stroop / Flanker / CPT / N-Back)都有相同的 phase 生命周期。复制粘贴 5 份 phase 状态机的问题是:
- 不一致:每个 store 的 pause/resume 实现略有偏差
- 新需求满地改:M4.2 要”AppState 切后台自动暂停”,得改 5 个 store
- 每模块还有自己的 trial 状态机:CPT 的 pendingResponse / N-Back 的 outcome — 强塞进一个 store 会让 guard 嵌套到不可读
createTrainingStore 工厂把共享 phase 状态机抽出来,每个模块通过 moduleInitialState / moduleActions / hooks 注入差异。这是”既轻量又显式”的折中方案。
工作方式
工厂函数签名
export function createTrainingStore<
TModuleState extends object,
TModuleActions extends object,
>(config: TrainingStoreConfig<TModuleState, TModuleActions>)
泛型让 TypeScript 编译期得到完整合并类型 TrainingBaseState & TModuleState & TrainingBaseActions & TModuleActions。工厂提供共享的 startCountdown / startPlaying / pause / resume / reset,模块只写差异。
双层 guard
- Phase guard:工厂内部,控制”什么 phase 转什么 phase 合法”。例如
pause只允许从 playing 进入 paused - Trial guard:模块的 moduleActions,控制”trial-level 转换合法性”。例如 N-Back 的
recordResponse检查isTarget !== null && !responded
两层互不渗透:工厂只管 phase 合法性,模块只管 trial 内部约束。加新模块不动 phase 状态机,加新 phase 不动模块。
Lifecycle hooks
hooks?: (set, get) => {
onPause?: () => void
onResume?: () => void
onReset?: () => void
}
调用顺序:
pause:先set({ phase: 'paused' }),再onPause()resume:先恢复 base 状态,再onResume()reset:先onReset()让模块清理副作用(如 clearTimeout),再set(fullInitialState)
reset 顺序之所以反过来是因为 hook 需要从 state 读 timer id 才能 clear——先 set 清空 state,hook 看到 null,timer 泄漏。
模块顶层闭包共享 timer 引用
CPT/N-Back 的 setTimeout 需要在 moduleActions 和 hooks 两个工厂之间共享。模块顶层就是天然的共享 scope:
let advanceTimer: ReturnType<typeof setTimeout> | null = null
let clearAdvanceFn: (() => void) | null = null
// moduleActions assign clearAdvanceFn, hooks 调用 clearAdvanceFn
教学上最少 TypeScript 知识门槛,单 store 单实例无并发问题。
Module Reset Registry
M4.2 引入。结果页”再来一次”按钮需要根据 record.module 字符串 reset 对应 store。早期硬编码 useSchulteSession.getState().reset() 导致 N-Back 结果页点”返回首页”实际 reset Schulte。
解法:
const moduleResetRegistry = new Map<TrainingModuleId, () => void>()
export function resetTrainingStore(moduleId: TrainingModuleId): void {
moduleResetRegistry.get(moduleId)?.()
}
每次 createTrainingStore 调用注册自己的 reset 闭包。Map 的覆盖语义天然支持 Fast Refresh 重新注册。
与其他方案对比
| 方案 | Bundle | 学习曲线 | 状态机可见性 |
|---|---|---|---|
| createTrainingStore 工厂 | ~80 行 | 低 | 文档 + 类型 |
| XState + useMachine | +5KB | 中 | 极佳(可视化) |
| 手写 reducer | ~30 行 | 低 | 中 |
| 单 store 复制粘贴 | 0 | 0 | 低 |
教学项目首选工厂模式:bundle 几乎为零、TypeScript 完全约束、新模块只写差异。状态机的完整状态空间散落在 phase 字段 + 各模块 trial 状态里,靠文档维持心智模型。
面试问题
- 为什么不直接 5 个 store 复制粘贴?
- 为什么不用 XState?
- 双层状态机解耦的具体好处?
- Module Reset Registry 为什么用 Map?
- 工厂模式与 React hooks 的关系是?
参见
- zustand-vs-redux — Zustand 的设计哲学
- 工作记忆状态机抽象 — 同 milestone 产出,trial-level 状态机的设计
- xstate-vs-手写 — 显式状态机库的取舍
参考
- Zustand 官方文档 — Slices Pattern
- David Khourshid (XState 作者) — Statecharts: A visual formalism
- Sandi Metz — Practical Object-Oriented Design(第 7 章 “Sharing Role Behavior with Modules”)
- Bjarnason — Constraints Liberate, Liberties Constrain
- 项目源码:
shared/stores/createTrainingStore.ts:30-220、features/nback/store.ts:33-247