关于 zustand
面向 LobeHub 开发的 Zustand 状态管理技能,说明项目中的存储约定。内容涵盖仅存状态的 store、基于 replica 的服务端数据管理,以及编辑或迁移旧版 store 的规则。
主要功能
- 01定义仅存状态的 store 结构
- 02区分公开与私有 action
- 03使用 replica 管理服务端数据
- 04处理水合与乐观更新
- 05遵循旧版 store 约定
适用场景
- 01创建或迁移 Zustand store
- 02添加基于 replica 的列表和详情数据
- 03实现带回滚的乐观写入
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ lobehub/ lobehub --skill zustand安装后在 Claude Code 中输入/zustand确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/data-structures.mdlegacy-slices.mdreferences/data-structures/reducer.mdtypes.md详细介绍
LobeHub Zustand 状态管理
状态结构和类型
- 从
@lobechat/types导入共享的 store 类型,而不是从@lobechat/database导入。 - 将轻量列表项类型与完整详情类型分开;列表类型不得扩展较重的详情类型。
- 按 ID 缓存详情(
xxxDetailMap: Record<string, Detail>);不要持有单个currentDetail对象。 - 通过
xxxSelectors聚合选择器(selectors.ts)读取状态,不要在各个组件中重复编写临时 lambda。 - 在选择列表/详情结构或类型来源之前,请先阅读 数据结构。
仅含状态的 Store(新建和迁移的 Store 默认采用此方式)
操作不放在 Zustand 状态中。参考:src/store/page。
| 文件 | 负责内容 |
|---|---|
store.ts | useXStore = createWithEqualityFn<XState>()(devtools(() => initialState), shallow):仅含状态,不含方法 |
privateAction.ts | XPrivateAction:供公共操作共享的副本切片、实体链接和写入辅助方法。不从 index.ts 导出 |
action.ts | XActionImpl(公共 API)以及单例 xActions = new XActionImpl(setState, getState, new XPrivateAction(…)) |
- 组件使用
useXStore(selector)读取状态,并直接调用xActions.foo()。不要从 Store 中选择操作。 - 同步钩子放在公共类上,并通过
xActions.useFetchX(params)调用。 - 公共操作通过
this.foo()相互调用。通过this.#private访问私有辅助方法。不要在 Store 中添加internal_*方法来供不同类共享逻辑。 - 为每次写入标注名称:
this.#set(partial, false, n('actionName')),其中const n = setNamespace('x')。 - 在公共类上添加
reset(),并在src/store/utils/userDataStores.ts中将xActions注册到resetableActions。 - 测试通过
xActions执行操作,并断言useXStore.getState()。在组件测试中进行模拟:vi.mock('@/store/x', () => ({ xActions: { foo: vi.fn() }, useXStore: … }))。
副本驱动的服务端数据
UI 渲染的服务端列表和详情通过 @/libs/replica 处理,而不是手写 SWR onSuccess + reducer。引擎负责从 IndexedDB 中恢复数据、重新验证最新数据、分页、带回滚的乐观更新,以及作用域(用户 + 工作区)隔离。API 参考:local-replica README。引擎在 lobehub/local-replica 中维护:在那里修复、发布,然后在此处升级依赖。
- 在 store 的
projection.ts中定义资源(defineReplica/definePagedReplica),在边界处转换服务端行数据。 - 为每个副本添加两个状态字段:视图(
xxxMap: Record<key, TData>)及其簿记状态(xxxReplica: createReplicaState())。 - 在私有类中绑定:
createReplicaSlice(resource, { get, set, stateKey: 'xxxReplica', view: recordLens('xxxMap'), … })。同一实体存在于多个副本中(列表行 + 详情)时,使用linkReplicaEntity([list, detail])将它们关联起来。 - 读取:
slice.useSync(params)仅返回获取状态标志(isHydrated、isValidating、error、revalidate)。数据通过xxxMap上的选择器获取。“条目为undefined”表示正在加载——不要添加xxxInit标志或loadingXxxIds。 - 在没有挂载视图时读取(悬停预取、变更后刷新):
await slice.fetch(params)(写入后使用{ fresh: true })。绝不要手动调用服务并执行slice.replace(...),因为这样会跳过作用域、移除、清除和过时请求保护。每个响应的副作用(未找到映射、为另一个 store 写入初始数据)都应放在切片的effects(onApplied/onMissing/onPurge/onError)中,并使用isMissing,而不是放在某个 hook 的onSuccess中。 - 无骨架屏的冷启动:
useSync仅在组件挂载后才会恢复数据。对于必须从本地副本渲染的列表,添加一个preHydrateaction(ensureScope+hydrate),并通过惰性包装器(src/spa/router/pageListLoader.ts、agentChatTopicListLoader.ts)从路由的loader中调用它,再通过@/libs/replica/prePaint中的settleWithin(…, PRE_PAINT_HYDRATE_TIMEOUT)限制等待时间。@/libs/replica预设了缓存作用域、IndexedDB / localStorage 存储(@/libs/replica/storage中的AppIndexedDBStorage/AppLocalStorage)以及应用的 SWR 驱动;软件包 README 则保持与应用无关。 - 写入:使用
slice.optimistic(key, apply, serverCall)或entity.optimistic(id, fn | 'remove', serverCall)。调用失败时会回滚到已确认的值,因此删除也采用乐观更新。仅存在于客户端的行(临时 ID)通过isClientOnly声明,并使用update(key, fn, { persist: false })写入初始数据。
除测试数据初始化外,只能通过副本的切片写入其视图。直接使用 set 会绕过引擎的簿记逻辑(持久化、回滚基准值、实体关联)。
旧版 Store
仍将 action 类扁平化到状态中的 Store(flattenActions、create*Slice、internal_*、internal_dispatch* + reducers、通过 SWR onSuccess 同步)遵循旧版切片。只有在编辑这类 Store 时才使用该布局;将其数据迁移到副本时,改用上述布局。
评论
支持 Markdown