LobeHub 的 Zustand Store 数据组织范式:列表与详情分离、Map 缓存与 Reducer 乐观更新

📅 发布时间:2026/9/7 15:43:49
LobeHub 的 Zustand Store 数据组织范式:列表与详情分离、Map 缓存与 Reducer 乐观更新
LobeHub 的 Zustand Store 数据组织范式列表与详情分离、Map 缓存与 Reducer 乐观更新【免费下载链接】lobehub LobeHub is your Chief Agent Operator, organizing your agents into 7×24 operations by hiring, scheduling, and reporting on your entire AI team.项目地址: https://gitcode.com/GitHub_Trending/lo/lobehub本文基于 LobeHub 仓库内的技能文档 SKILL.md 及其配套参考 types.md、reducer.md系统讲解该项目为 Zustand store 确立的数据结构范式列表用数组、详情用RecordMap、Detail/ListItem 双类型拆分、基于 Immer 的类型化 Reducer 与internal_dispatch*派发机制。读完本文你可以按照同一套模式为任何新实体Benchmark、Dataset、Run……设计可缓存、可乐观更新、可测试的 store 状态并知道每个模式在仓库中的真实实现位置。一、核心原则DO / DONT 清单技能文档将 store 数据组织的核心原则浓缩为两组清单这是整个范式的设计出发点✅ DO必须遵守Separate List and Detail— 列表页和详情页使用不同的数据结构Use Map for Details— 用Recordstring, Detail缓存多个详情页Use Array for Lists— 列表展示使用简单数组Types fromlobechat/types— 类型一律取自lobechat/types包绝不在 store 中直接使用lobechat/database的类型Distinguish List and Detail types— List 类型可以携带为 UI 计算的派生字段如计数、展示用时间戳。❌ DONT禁止事项不要用单个 detail 对象 —— 无法缓存多个页面不要混用 List 与 Detail 类型 —— 二者职责不同不要使用数据库层类型 —— 使用lobechat/types不要对列表用 Map —— 简单数组就足够了。这些规则背后的动机在 types.md 中解释得很直白列表页一次渲染很多行把重型字段大文本、复杂对象、大数组拉进列表会把 payload 撑大、拖慢渲染而详情页只渲染一个实体携带完整 payload 没有问题。为什么坚持类型只来自lobechat/types从源码结构看packages/types是独立于packages/database的前端共享类型包。以 benchmark 为例benchmark.ts 中定义的AgentEvalBenchmark与AgentEvalBenchmarkListItem使用Date等前端友好的形态而非数据库行映射类型。store 直接消费lobechat/types可以把「数据库 schema 变更」与「前端状态形状」解耦数据库层类型的增删不会直接击穿前端 store 的编译。二、类型定义Detail 全量 ListItem 子集而非 extends每个实体在lobechat/types/下拥有独立文件每个文件导出两个类型Detail 类型— 完整实体包含所有重型字段rubrics、content、editor state 等ListItem 类型— Detail 的子集剔除重型字段可以添加为 UI 计算的派生字段计数、展示用时间戳等。关键点List 类型是子集subset而不是extendsDetail。extends会把重型字段重新拉回来。真实案例Benchmark 的双类型定义仓库中 packages/types/src/eval/benchmark.ts 完整体现了这一模式/** * Full benchmark entity (for detail pages) * Contains all fields including heavy data */ export interface AgentEvalBenchmark { createdAt: Date; description?: string | null; id: string; identifier: string; isSystem: boolean; metadata?: Recordstring, unknown | null; name: string; referenceUrl?: string | null; rubrics: EvalBenchmarkRubric[]; // 重型字段 updatedAt: Date; } /** * Lightweight benchmark item (for list display) * Excludes heavy fields, may include computed statistics */ export interface AgentEvalBenchmarkListItem { createdAt: Date; // 为 UI 计算的统计字段 datasetCount?: number; description?: string | null; id: string; identifier: string; isSystem: boolean; name: string; runCount?: number; testCaseCount?: number; // 注意rubrics 未包含重型字段 }对比可以看到AgentEvalBenchmarkListItem逐字段重写了AgentEvalBenchmark中的轻量字段createdAt、description、id、identifier、isSystem、name刻意没有继承它同时追加了datasetCount、runCount、testCaseCount三个可选的 UI 统计字段并剔除了rubrics评测维度数组属于重型数据。这正是文档中“NOTextendsDetail”规则在真实代码里的落点。文件组织一实体一文件types.md 要求类型按实体分文件存放eval 域的实际目录结构与之完全吻合见 packages/types/src/evalbenchmark.ts、agentEvalDataset.ts、agentEvalRun.ts、rubric.ts、index.tsre-exports等各自独立避免了把所有实体塞进一个agentEvalEntities.ts的“大杂烿”写法。重型字段排除清单文档给出的「不应出现在 ListItem 中」的字段类别大文本内容content、editorData、fullDescription复杂对象rubrics、config、metrics二进制数据image、file大数组messages、items。文档中还给了一个带重型内容的典型示例 —— Document 实体Document含content: string完整 markdown与editorData编辑器状态而DocumentListItem只保留id、title、description、时间戳外加wordCount、lastEditedBy等计算字段。按此清单任何新实体在写 ListItem 时都应逐项核对重型字段是否被剔除并在注释中说明「哪些字段被排除、为什么」——仓库里的 benchmark.ts 正是用// Note: rubrics NOT included (heavy field)这类注释做到的。三、Map vs Array数据结构的选型决策技能文档给出一棵决策树原文保留Need to store data? │ ├─ Is it a LIST for display? │ └─ ✅ Use simple array: xxxList: XxxListItem[] │ - May include computed fields │ - Refreshed as a whole │ - No optimistic updates needed │ └─ Is it DETAIL page data? └─ ✅ Use Map: xxxDetailMap: Recordstring, Xxx - Cache multiple details - Support optimistic updates - Per-item loading states - Requires reducer for mutations详情数据Map Reducer适用场景详情页数据缓存同时缓存多个详情页、乐观更新API 未返回前先更新 UI、逐项 loading 状态、多页面间导航无需重新拉取。命名形态为benchmarkDetailMap: Recordstring, AgentEvalBenchmark;典型用例benchmark 详情页、dataset 详情页、用户资料页。列表数据简单数组适用场景列表/表格/卡片展示、整体刷新列表作为整体一起刷新、无需逐行更新、数据流更简单。命名形态为benchmarkList: AgentEvalBenchmarkListItem[];典型用例benchmark 列表、dataset 列表、用户列表。四、状态结构模式Slice State 的完整形态技能文档给出的标准 slice 状态含 list、detail、loading 与 mutation 状态// src/store/eval/slices/benchmark/initialState.ts import type { AgentEvalBenchmark, AgentEvalBenchmarkListItem } from lobechat/types; export interface BenchmarkSliceState { // List — 简单数组 benchmarkList: AgentEvalBenchmarkListItem[]; benchmarkListInit: boolean; // Detail — Map 用于多实体缓存 benchmarkDetailMap: Recordstring, AgentEvalBenchmark; loadingBenchmarkDetailIds: string[]; // 逐项 loading // Mutation states驱动表单级 UI isCreatingBenchmark: boolean; isUpdatingBenchmark: boolean; isDeletingBenchmark: boolean; } export const benchmarkInitialState: BenchmarkSliceState { benchmarkList: [], benchmarkListInit: false, benchmarkDetailMap: {}, loadingBenchmarkDetailIds: [], isCreatingBenchmark: false, isUpdatingBenchmark: false, isDeletingBenchmark: false, };对照仓库真实实现 src/store/eval/slices/benchmark/initialState.ts字段完全一致且额外增加了一个列表级 loading 位isLoadingBenchmarkList: boolean初始为true用于在列表首次拉取完成前驱动整页 loading。这印证了文档 Best Practices 中的第 8 条「Loading states — per-item for details, global for mutations」——详情侧用loadingBenchmarkDetailIds: string[]做逐项跟踪列表与创建/更新/删除等 mutation 侧则用单个布尔位。同样的目录结构action.tsinitialState.tsreducer.tsselectors.ts在 eval store 的其他 slice 中重复出现见 src/store/eval/slices 下的dataset/、run/三者均带reducer.ts与experiment/、testCase/仅action.tsinitialState.ts。从源码结构看「是否带 reducer」与该实体是否需要乐观更新/逐项 loading 直接相关只有 detail-map 型 slice 才配有 reducer 文件这与文档中「Reducer for detail map if optimistic updates needed」的规则一一对应。错误示例单 detail 对象 vs 正确形态文档用同一实体演示了两种结构的差距// ❌ WRONG — 单 detail 对象 interface BenchmarkSliceState { benchmarkDetail: AgentEvalBenchmark | null; isLoadingBenchmarkDetail: boolean; }问题同一时刻只能缓存一个详情页在详情间切换必须重新拉取无法做乐观更新没有逐项 loading 状态。// ✅ CORRECT — 列表/详情分离 interface BenchmarkSliceState { benchmarkList: AgentEvalBenchmarkListItem[]; benchmarkListInit: boolean; benchmarkDetailMap: Recordstring, AgentEvalBenchmark; loadingBenchmarkDetailIds: string[]; isCreatingBenchmark: boolean; isUpdatingBenchmark: boolean; isDeletingBenchmark: boolean; }收益多详情缓存、详情间快速导航、基于 reducer 的乐观更新、逐项 loading、职责清晰分离。五、Reducer 模式让 Detail Map 的变更可测试、可复用当 Detail Map 需要乐观更新用户编辑某行UI 在服务端确认前先行反映时技能文档要求接入一个类型化 reducer而不是在 slice 里内联set调用——这让变更可单元测试、派发面保持最小。完整模式见 references/reducer.md要点如下。用 Reducer 的四个理由不可变更新—— Immer 让不可变写法变得轻松类型安全的 action—— 判别联合discriminated union杜绝字段拼写错误可测试—— 纯函数单元测试成本极低可复用—— 同一个 reducer 同时驱动乐观更新与服务端数据落库。真实实现benchmarkDetailReducer仓库中的 src/store/eval/slices/benchmark/reducer.ts 与参考文档几乎逐行对应import { type AgentEvalBenchmark } from lobechat/types; import { produce } from immer; type SetBenchmarkDetailAction { id: string; type: setBenchmarkDetail; value: AgentEvalBenchmark; }; type UpdateBenchmarkDetailAction { id: string; type: updateBenchmarkDetail; value: PartialAgentEvalBenchmark; }; type DeleteBenchmarkDetailAction { id: string; type: deleteBenchmarkDetail; }; export type BenchmarkDetailDispatch | SetBenchmarkDetailAction | UpdateBenchmarkDetailAction | DeleteBenchmarkDetailAction; export const benchmarkDetailReducer ( state: Recordstring, AgentEvalBenchmark {}, payload: BenchmarkDetailDispatch, ): Recordstring, AgentEvalBenchmark { switch (payload.type) { case setBenchmarkDetail: return produce(state, (draft) { draft[payload.id] payload.value; }); case updateBenchmarkDetail: return produce(state, (draft) { if (draft[payload.id]) { draft[payload.id] { ...draft[payload.id], ...payload.value }; } }); case deleteBenchmarkDetail: return produce(state, (draft) { delete draft[payload.id]; }); default: return state; } };三个 action 语义清晰setBenchmarkDetail整条写入服务端数据落地/首载、updateBenchmarkDetail浅合并局部字段乐观更新、deleteBenchmarkDetail移除缓存项。注意updateBenchmarkDetail在目标 key 不存在时静默跳过避免乐观更新先于首载时凭空创建残缺条目。internal_dispatch*把 reducer 接进 Zustand参考文档约定 slice 对外暴露两个internal_*方法将 reducer 与 loading 状态封装在稳定契约之后。真实实现见 src/store/eval/slices/benchmark/action.tsinternal_dispatchBenchmarkDetail (payload: BenchmarkDetailDispatch): void { const currentMap this.#get().benchmarkDetailMap; const nextMap benchmarkDetailReducer(currentMap, payload); // 无变化则跳过 set —— 避免无谓的重渲染 if (isEqual(nextMap, currentMap)) return; this.#set({ benchmarkDetailMap: nextMap }, false, dispatchBenchmarkDetail/${payload.type}); }; internal_updateBenchmarkDetailLoading (id: string, loading: boolean): void { this.#set( (state) ({ loadingBenchmarkDetailIds: loading ? [...state.loadingBenchmarkDetailIds, id] : state.loadingBenchmarkDetailIds.filter((i) i ! id), }), false, updateBenchmarkDetailLoading, ); };两个工程细节值得注意深比较短路用fast-deep-equalaction.ts 第 1 行 引入的isEqual比较新旧 Map完全相同则不调set从源头消除重复派发引发的重渲染internal_前缀是契约约定UI 组件不应直接调用internal_dispatch*而应调用公开 mutation 方法如updateBenchmark由后者转调内部派发让 reducer 的 action 形状不出现在组件层。乐观更新的完整调用链同文件中 updateBenchmark 展示了这套模式的运行时形态一次更新经历的完整链路internal_dispatchBenchmarkDetail({ type: updateBenchmarkDetail, value: params })—— 乐观写入UI 立即反映新值internal_updateBenchmarkDetailLoading(id, true)—— 将该项加入loadingBenchmarkDetailIdsawait agentEvalService.updateBenchmark(...)—— 调用服务端refreshBenchmarks()refreshBenchmarkDetail(id)—— 通过 SWR 的mutateevalKeys.benchmarks()/evalKeys.benchmarkDetail(id)使列表与详情缓存重新对齐服务端真值finally中internal_updateBenchmarkDetailLoading(id, false)—— 无论成功失败都移除 loading 位。数据加载侧同样收敛到同一入口useFetchBenchmarkDetail 在 SWRonSuccess中派发setBenchmarkDetail并清除 loading 位而useFetchBenchmarks成功后直接整体写回benchmarkListbenchmarkListInit: true—— 列表整体刷新、详情逐项缓存两种数据流各走其最优路径。六、组件侧消费hook 与 Selectors访问列表数据const BenchmarkList () { const benchmarks useEvalStore((s) s.benchmarkList); const isInit useEvalStore((s) s.benchmarkListInit); if (!isInit) return Loading /; return ( div {benchmarks.map((b) ( BenchmarkCard key{b.id} name{b.name} testCaseCount{b.testCaseCount} / ))} /div ); };注意benchmarkListInit与isLoadingBenchmarkList的分工前者表示「至少成功加载过一次」防止刷新时闪空白后者表示「正在请求中」。卡片上直接消费 ListItem 的计算字段testCaseCount而不需要触达重型数据。访问详情数据const BenchmarkDetail () { const { benchmarkId } useParams{ benchmarkId: string }(); const benchmark useEvalStore((s) benchmarkId ? s.benchmarkDetailMap[benchmarkId] : undefined, ); const isLoading useEvalStore((s) benchmarkId ? s.loadingBenchmarkDetailIds.includes(benchmarkId) : false, ); if (!benchmark) return Loading /; return ( div h1{benchmark.name}/h1 {isLoading Spinner /} /div ); };组件按路由参数从 Map 中取单条详情并同步读取该项的 loading 位——这正是loadingXxxDetailIds: string[]相对单一布尔位的价值同时打开/切换多个详情时各项 loading 互不干扰。Selectors封装访问模式推荐但可选// src/store/eval/slices/benchmark/selectors.ts export const benchmarkSelectors { getBenchmarkDetail: (id: string) (s: EvalStore) s.benchmarkDetailMap[id], isLoadingBenchmarkDetail: (id: string) (s: EvalStore) s.loadingBenchmarkDetailIds.includes(id), }; // 组件中 const benchmark useEvalStore(benchmarkSelectors.getBenchmarkDetail(benchmarkId!)); const isLoading useEvalStore(benchmarkSelectors.isLoadingBenchmarkDetail(benchmarkId!));仓库当前的 src/store/eval/slices/benchmark/selectors.ts 已按此模式导出了benchmarkList、isBenchmarkListInit、isLoadingBenchmarkList、isCreatingBenchmark与getBenchmarkById等选择器。细节上现有实现的getBenchmarkById是从列表数组中find而文档给出的理想 selector 面向detail Map取值从源码结构看两者可并存——列表态查询走数组详情态查询走 Map按需选择即可。值得留意的是路由组件如 benchmark 详情页 index.tsx/eval/bench/[benchmarkId]/index.tsx)也在直接消费benchmarkDetailMap与相关状态说明 selector 属于「推荐但非强制」层而 store 状态形状本身才是强制契约。七、设计 Checklist 与最佳实践技能文档为「设计新 store 状态」给出了一份逐项核对清单可视为评审 checklist类型按实体分文件如benchmark.ts、agentEvalDataset.ts创建Detail类型含全部字段包括重型字段创建ListItem类型是 Detail 的子集剔除重型字段可包含 UI 用的计算统计不extendsDetail列表用数组xxxList: XxxListItem[]详情用MapxxxDetailMap: Recordstring, Xxx逐项 loadingloadingXxxDetailIds: string[]需要乐观更新时detail map 配Reducer见 references/reducer.md提供internal dispatch与loading方法Selectors封装访问可选但推荐注释说明哪些字段被排除在 List 之外、以及为什么。文档的九条 Best Practices 汇总为文件组织 —— 一实体一文件不混放List 是子集 —— ListItem 剔除重型字段不用extends命名清晰 —— 数组用xxxListMap 用xxxDetailMap模式一致 —— 所有 detail map 遵循同一形状类型安全 —— 永不用any一律使用正确类型注释排除项 —— 写明被排除的字段及原因Selectors —— 封装访问模式Loading 状态 —— 详情逐项、mutation 全局不可变 —— reducer 中使用 Immer。两个高频错误对照❌ 在 List 中 extends Detail// 错误 —— 会把重型字段拉回来 export interface BenchmarkListItem extends Benchmark { testCaseCount?: number; }✅ 独立创建子集export interface BenchmarkListItem { id: string; name: string; // ...仅保留必要字段 testCaseCount?: number; // 计算字段 }❌ 多实体混在一个文件// 错误 —— 所有实体挤在 agentEvalEntities.ts✅ 按实体拆分// 正确 —— 独立文件 // benchmark.ts // agentEvalDataset.ts // agentEvalRun.tspackages/types/src/eval 的实际目录agentEval.ts、agentEvalDataset.ts、agentEvalRun.ts、benchmark.ts、rubric.ts、dataset.ts各自独立 index.ts统一 re-export就是该规则的直接产物。八、模式落点小结eval store 全景把本文各节拼回仓库真实结构LobeHub 中该范式的落点可以一表看清以 benchmark slice 为例关注点文档规则仓库实现Detail/ListItem 双类型子集而非 extendspackages/types/src/eval/benchmark.tsSlice 状态形状list 数组 detail Map 逐项 loading mutation 布尔位src/store/eval/slices/benchmark/initialState.ts类型化 ReducerImmer判别联合 action producesrc/store/eval/slices/benchmark/reducer.tsinternal_dispatch*与 SWR 接线深比较短路、乐观更新链路src/store/eval/slices/benchmark/action.tsSelectors工厂式选择器src/store/eval/slices/benchmark/selectors.ts同构复用同一形状复制到兄弟 slicesrc/store/eval/slicesdataset/run 均带 reducer这套范式的适用前提也值得说明它面向「前端 Zustand store 持有服务端实体数据、需要多详情缓存与乐观更新」的场景与 SWR 负责网络缓存、store 负责 UI 状态分工配合参见 action.ts 中useClientDataSWRonSuccess落库的写法。技能文档的「Related Skills」中提到的data-fetching-architecture数据如何获取与更新与zustandZustand 通用模式两个技能分别对应本文的互补面而本文聚焦的正是「数据在 store 中应以何种形状存在、如何变更、如何被组件消费」这一数据结构层问题。【免费下载链接】lobehub LobeHub is your Chief Agent Operator, organizing your agents into 7×24 operations by hiring, scheduling, and reporting on your entire AI team.项目地址: https://gitcode.com/GitHub_Trending/lo/lobehub创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考