LobeHub 数据获取架构实战:以父级 ID 为键的 Map 变体六步落地全流程(Dataset 示例)
本篇基于 LobeHub 仓库中的 data-fetching-architecture 技能文档及其完整示例 walkthrough.md,带你走通一条核心开发路径:当同一个数据结构出现在多个"父级"之下(例如每个 Benchmark 下都有一批 Dataset)时,如何用"Service → Reducer → Slice → Store → Selectors → Component"六步配方,构建一个以父级 ID 为键(datasetMap[benchmarkId])的数据获取与乐观更新体系。读完本篇,你可以直接复用这套模式处理任意"多父级共享列表"场景,并理解 LobeHub 前端数据层从 SWR 缓存到 Zustand 状态的完整调用链。
1. 这篇 Walkthrough 解决什么问题
LobeHub 的数据获取架构在 SKILL.md 中给出了标准六步配方,并以 Benchmark(扁平单数组列表 + 详情 Map)作为标准示例。而 walkthrough.md 解决的是它的变体问题:
当同一个数据形状出现在不同父级之下时(
datasetMap[benchmarkId]),如何为每个父级维护独立的列表缓存、加载状态与分页信息,同时保证乐观更新只作用于当前父级。
原文档的定位很明确:如果你只需要标准的单数组模式,读 SKILL.md 里的 Benchmark 示例即可;只有当你需要父级键控的 Map 变体,或者想要一份 checklist 式的逐步操作指南时,才需要读这篇 walkthrough。
整体架构遵循 SKILL.md 描述的四层链路:
Component ──1. 调用 store 里的 useFetchXxx hook──▶ Zustand Store (State + Hook)
Zustand Store ──2. useClientDataSWR 调用 service──▶ Service Layer (xxxService)
Service Layer ──3. 调用 lambdaClient──▶ lambdaClient (TRPC Client)
这条链路的约束由 SKILL.md 的核心原则规定:所有 API 调用必须走 Service 层;数据获取必须用 Store 中的 SWR Hook(禁止 useEffect + useState);写操作用 lambdaClient.mutate,读操作只在 service 方法内用 lambdaClient.query;读 Hook 命名 useFetchXxx,缓存失效辅助方法命名 refreshXxx。
下面以新增实体 Dataset(隶属于某个 benchmarkId)为例,逐步走完全流程。
2. Step 1:Service 层——封装所有 API 调用
Service 层是唯一允许接触 lambdaClient 的地方,它把 tRPC 调用收敛为类型清晰的方法。walkthrough 给出的 AgentEvalService 新增方法是:
class AgentEvalService {
async listDatasets(benchmarkId: string) {
return lambdaClient.agentEval.listDatasets.query({ benchmarkId });
}
async getDataset(id: string) {
return lambdaClient.agentEval.getDataset.query({ id });
}
async createDataset(params: CreateDatasetParams) {
return lambdaClient.agentEval.createDataset.mutate(params);
}
// updateDataset / deleteDataset follow the same shape
}
命名遵循 list / get / create / update / delete 的动词约定,每个领域(如 agentEval、ragEval)一个 service 并导出单例实例。
仓库实际实现印证:AgentEvalService 中确实存在这些方法——listDatasets(benchmarkId) 通过 lambdaClient.agentEval.listDatasets.query({ benchmarkId }) 发起查询,getDataset(id) 与 createDataset(params)(内部走 mutate)与文档描述完全一致。值得注意的是,实际代码还额外提供了一个不带父级参数的全量列表入口(lambdaClient.agentEval.listDatasets.query({})),用于获取"所有数据集";这一点在 store 层面体现为 useFetchAllDatasets Hook,注释里解释了动机:没有它,数据集只能通过其 benchmark 访问,归属任何 benchmark 的数据集"可以创建却永远找不到"。这提示我们在设计父级键控列表时,应同时评估是否需要一个"无父级"的全局视图。
3. Step 2:Reducer——列表的乐观更新原语
父级键控变体中,列表本身仍然是一个数组(只是这个数组被"存进"了 Map 的某个桶里),因此它的增删改乐观更新逻辑与单数组模式相同。walkthrough 给出的 reducer 基于 immer 的 produce 实现不可变更新:
// src/store/eval/slices/dataset/reducer.ts
export type DatasetDispatch =
| { type: 'addDataset'; value: Dataset }
| { type: 'updateDataset'; id: string; value: Partial<Dataset> }
| { type: 'deleteDataset'; id: string };
export const datasetReducer = (state: Dataset[] = [], payload: DatasetDispatch): Dataset[] =>
produce(state, (draft) => {
switch (payload.type) {
case 'addDataset':
draft.unshift(payload.value);
break;
case 'updateDataset': {
const i = draft.findIndex((item) => item.id === payload.id);
if (i !== -1) draft[i] = { ...draft[i], ...payload.value };
break;
}
case 'deleteDataset': {
const i = draft.findIndex((item) => item.id === payload.id);
if (i !== -1) draft.splice(i, 1);
break;
}
}
});
三个设计要点:
- dispatch 类型是联合类型:
type字段区分addDataset / updateDataset / deleteDataset,让 reducer 成为类型安全的纯函数,可单独测试; produce保证不可变性:即使找不到目标元素(i === -1)也直接跳过,reducer 不抛错、不产生副作用;unshift前置插入:新建项永远出现在列表头部,与后端返回顺序无关,创建后无需排序即可被用户看到。
仓库中该文件的现状是 datasetDetailReducer,它针对的是"详情 Map"而非"列表数组"(setDatasetDetail / updateDatasetDetail / deleteDatasetDetail 三种 dispatch),同样是 immer.produce + 联合类型的标准写法——可以看到 reducer 的形态随数据结构(数组 vs Map)而变,但模式不变。
4. Step 3:Store Slice——父级键控状态的完整形态
这是整篇 walkthrough 的核心。slice 分为初始状态与 action 两部分。
4.1 初始状态:Record<父级ID, 列表数据>
// src/store/eval/slices/dataset/initialState.ts
export interface DatasetData {
currentPage: number;
hasMore: boolean;
isLoading: boolean;
items: Dataset[];
pageSize: number;
total: number;
}
export interface DatasetSliceState {
// Map keyed by benchmarkId — multiple parent contexts share the slice
datasetMap: Record<string, DatasetData>;
// Single item for modal display
datasetDetail: Dataset | null;
isLoadingDatasetDetail: boolean;
loadingDatasetIds: string[];
}
export const datasetInitialState: DatasetSliceState = {
datasetMap: {},
datasetDetail: null,
isLoadingDatasetDetail: false,
loadingDatasetIds: [],
};
关键设计是 datasetMap: Record<string, DatasetData>:每个 benchmarkId 拥有自己独立的 DatasetData(含分页四件套 currentPage / hasMore / pageSize / total 与 isLoading)。这样切换到不同 benchmark 时,各自的列表缓存、加载态、分页进度互不干扰,切换回来时 SWR 缓存还能命中。
对照仓库当前的 DatasetSliceState,实际演进为 datasetDetailMap: Record<string, AgentEvalDataset>(详情按 ID 缓存)+ 扁平的 datasetList: AgentEvalDatasetListItem[] + 单值 isLoadingDatasets,类型统一收敛到 @lobechat/types 中的 AgentEvalDataset / AgentEvalDatasetListItem。可以推断:当"列表始终属于当前打开的那个父级"时,扁平数组加"按父级区分缓存键"已经足够,datasetMap 的分桶结构是更重但更通用的选择——两种形态都符合 SKILL.md 的 store-data-structures 原则(List 用数组、Detail 用 Map)。
4.2 Action:SWR Hook、刷新与乐观创建
// src/store/eval/slices/dataset/action.ts
const FETCH_DATASETS_KEY = 'FETCH_DATASETS';
const FETCH_DATASET_DETAIL_KEY = 'FETCH_DATASET_DETAIL';
export const createDatasetSlice: StateCreator<EvalStore, any, [], DatasetAction> = (set, get) => ({
// Cache key includes benchmarkId so each parent has its own SWR entry
useFetchDatasets: (benchmarkId) =>
useClientDataSWR(
benchmarkId ? [FETCH_DATASETS_KEY, benchmarkId] : null,
() => agentEvalService.listDatasets(benchmarkId!),
{
onSuccess: (data) => {
set({
datasetMap: {
...get().datasetMap,
[benchmarkId!]: {
currentPage: 1,
hasMore: false,
isLoading: false,
items: data,
pageSize: data.length,
total: data.length,
},
},
});
},
},
),
useFetchDatasetDetail: (id) =>
useClientDataSWR(
id ? [FETCH_DATASET_DETAIL_KEY, id] : null,
() => agentEvalService.getDataset(id!),
{
onSuccess: (data) => set({ datasetDetail: data, isLoadingDatasetDetail: false }),
},
),
refreshDatasets: (benchmarkId) => mutate([FETCH_DATASETS_KEY, benchmarkId]),
refreshDatasetDetail: (id) => mutate([FETCH_DATASET_DETAIL_KEY, id]),
// CREATE with optimistic update — note the temp id pattern
createDataset: async (params) => {
const tmpId = Date.now().toString();
const { benchmarkId } = params;
get().internal_dispatchDataset(
{ type: 'addDataset', value: { ...params, id: tmpId, createdAt: Date.now() } as any },
benchmarkId,
);
get().internal_updateDatasetLoading(tmpId, true);
try {
const result = await agentEvalService.createDataset(params);
await get().refreshDatasets(benchmarkId);
return result;
} finally {
get().internal_updateDatasetLoading(tmpId, false);
}
},
// UPDATE / DELETE follow the same optimistic + refresh pattern as BenchmarkSlice
// Internal — dispatch reducer scoped to a parent
internal_dispatchDataset: (payload, benchmarkId) => {
const currentData = get().datasetMap[benchmarkId];
const nextItems = datasetReducer(currentData?.items, payload);
// Skip set when nothing changed — avoids unnecessary re-renders
if (isEqual(nextItems, currentData?.items)) return;
set({
datasetMap: {
...get().datasetMap,
[benchmarkId]: {
...currentData,
currentPage: currentData?.currentPage ?? 1,
hasMore: currentData?.hasMore ?? false,
isLoading: false,
items: nextItems,
pageSize: currentData?.pageSize ?? nextItems.length,
total: currentData?.total ?? nextItems.length,
},
},
});
},
internal_updateDatasetLoading: (id, loading) => {
set((state) => ({
loadingDatasetIds: loading
? [...state.loadingDatasetIds, id]
: state.loadingDatasetIds.filter((i) => i !== id),
)));
},
});
这段代码浓缩了父级键控模式的四个关键技巧,逐一拆解:
(1)缓存键包含父级 ID,每个父级独立 SWR entry。 [FETCH_DATASETS_KEY, benchmarkId] 使得不同 benchmark 的列表在 SWR 缓存中是两条独立记录;onSuccess 时只覆写当前父级的桶({ ...get().datasetMap, [benchmarkId]: ... }),其他父级数据原样保留。同时 benchmarkId ? [...] : null 的三元写法实现了"条件禁用":父级 ID 缺失时 SWR 键为 null,请求根本不会发出——这与仓库当前实现一致,见 useFetchDatasets(实际代码改用 evalKeys.datasets(benchmarkId) 工厂函数生成同样的数组键,并配合 evalKeys 统一管理缓存键命名空间,refreshDatasets 通过 mutate(evalKeys.datasets(benchmarkId)) 精确失效单个父级的缓存)。
(2)乐观创建使用 temp id 模式。 创建时数据还没有服务端 ID,做法是:用 Date.now().toString() 造一个 tmpId,先把带临时 ID 的条目 unshift 进对应父级的桶,并把 tmpId 加入 loadingDatasetIds 让该行显示 loading;请求成功后不做本地 ID 回填,而是直接 refreshDatasets(benchmarkId) 让 SWR 重新拉取整个父级列表、以服务端数据为准(refresh 后的新数据天然替换掉临时项)。finally 里保证 loading 标记必定清除。这个"乐观插入 + 整桶刷新"的组合避免了手动把临时 ID 映射回真实 ID 的脆弱逻辑。
(3)internal dispatch 限定在父级作用域。 internal_dispatchDataset(payload, benchmarkId) 只取 datasetMap[benchmarkId] 这一个桶跑 reducer,再写回该桶——父级之间的乐观更新互不串扰。这是单数组模式所没有的额外维度。
(4)isEqual 短路避免无效重渲染。 fast-deep-equal 比较 reducer 产物与当前 items,无变化直接 return 跳过 set。配合 internal_updateDatasetLoading 中按 ID 增删 loadingDatasetIds,实现"只有正在变更的那一行转圈"的行级加载态(SKILL.md 对此有专门解释:create 尚无 ID 用全局 flag,update/delete 指向具体行必须用 per-item loadingXxxIds,否则会冻结无关行)。
5. Step 4:接入主 Store
slice 本身是纯函数工厂,需要挂到 eval 域的 store 上并合并初始状态:
// src/store/eval/store.ts
export type EvalStore = EvalStoreState & BenchmarkAction & DatasetAction & RunAction;
const createStore: StateCreator<EvalStore, [['zustand/devtools', never]]> = (set, get, store) => ({
...initialState,
...createBenchmarkSlice(set, get, store),
...createDatasetSlice(set, get, store),
...createRunSlice(set, get, store),
});
// src/store/eval/initialState.ts
export const initialState: EvalStoreState = {
...benchmarkInitialState,
...datasetInitialState,
...runInitialState,
};
仓库当前的 store.ts 在骨架上完全同构:EvalStore = EvalStoreState & EvalStoreAction,createStore 展开 ...initialState 后经 flattenActions 把 createBenchmarkSlice / createDatasetSlice / createExperimentSlice / createRunSlice / createTestCaseSlice 与一个 resetEvalStore 重置动作扁平化合并,最终用 createWithEqualityFn + shallow 比较函数创建 store(浅比较对这种"多 slice 展开"的大状态尤为重要,可避免无关字段变化触发整体重渲染),并接入 devtools。slice 从对象字面量演进为类(DatasetActionImpl 持有私有 #set / #get),但"工厂函数返回 action 集合 → 扁平化进主 store"的组合方式没有变。
6. Step 5:Selectors——按父级取数的推荐姿势
walkthrough 建议为父级键控状态提供 curried selector(先传父级 ID,再收 state):
export const datasetSelectors = {
getDatasetData: (benchmarkId: string) => (s: EvalStore) => s.datasetMap[benchmarkId],
getDatasets: (benchmarkId: string) => (s: EvalStore) => s.datasetMap[benchmarkId]?.items ?? [],
isLoadingDataset: (id: string) => (s: EvalStore) => s.loadingDatasetIds.includes(id),
};
selector 的价值在于:组件侧不需要手写 datasetMap[benchmarkId]?.items ?? [] 这类带兜底的链式取值;?? [] 兜底也统一收口,避免"桶不存在时崩溃/空态混乱"。SKILL.md 同样将 selectors 列为"可选但推荐"的标准步骤,且对详情类数据强调错误分支必须在 if (!map[id]) 空判断之前——首载失败时 Map 永远不会有该条目,把 error 分支放在 Map 空判断之后会导致它不可达,UI 卡死在骨架屏。这是使用任何 detail map 模式(含本变体的 datasetDetail)时的通用纪律。
7. Step 6:组件侧消费——父级作用域列表与条件拉取
// List scoped to a parent
const DatasetList = ({ benchmarkId }: { benchmarkId: string }) => {
const useFetchDatasets = useEvalStore((s) => s.useFetchDatasets);
const datasets = useEvalStore(datasetSelectors.getDatasets(benchmarkId));
const datasetData = useEvalStore(datasetSelectors.getDatasetData(benchmarkId));
useFetchDatasets(benchmarkId);
if (datasetData?.isLoading) return <Loading />;
return (
<div>
<h2>Total: {datasetData?.total ?? 0}</h2>
<List data={datasets} />
</div>
);
};
// Single item for modal — conditional fetching pattern
const DatasetImportModal = ({ open, datasetId }: Props) => {
const useFetchDatasetDetail = useEvalStore((s) => s.useFetchDatasetDetail);
const dataset = useEvalStore((s) => s.datasetDetail);
const isLoading = useEvalStore((s) => s.isLoadingDatasetDetail);
// Only fetch when modal is open AND id present
useFetchDatasetDetail(open && datasetId ? datasetId : undefined);
return <Modal open={open}>{isLoading ? <Loading /> : <div>{dataset?.name}</div>}</Modal>;
};
两个消费模式值得注意:
- 列表作用域绑定父级:
DatasetList组件接收benchmarkIdprop,同一组件实例在不同 benchmark 下渲染的是各自桶的数据与各自的分页/加载态; - 条件拉取(conditional fetching):详情 Hook 传入
open && datasetId ? datasetId : undefined——弹窗未打开或 ID 缺失时键为null,SWR 不发请求。弹窗详情场景下这能避免"页面一加载就为所有潜在弹窗预拉数据"。
另外,SKILL.md 对组件侧有一条容易被忽略的硬性契约:异步失败边界。每个读 Hook 都返回带 error 和 mutate 的 SWR 响应,消费方必须处理它们——只看 !isInit、!map[id]、data ?? [] 这类"成功才翻转"的标志是不够的:请求失败时这些标志往往永不翻转,UI 就会画出永久骨架屏、假空态或误报的 NotFound。规范做法是用 AsyncBoundary(常规 loading/error/empty/data 表面)或 AsyncError(自定义布局、详情页、加载更多失败、指标),且 error 判断必须先于 empty/NotFound/零值默认值,因为"错误不是空"。
8. 变体选型与排障:把六步配方用对地方
8.1 单数组 vs 父级键控 Map:怎么选
- 扁平列表 + 详情 Map(SKILL.md 的标准
Benchmark形态):实体只有一个全局列表,用xxxList: XxxListItem[]+xxxDetailMap: Record<string, Xxx>; - 父级键控 Map(本篇
datasetMap[benchmarkId]形态):同一列表形状在多个父级下重复出现、需要各自独立的分页与加载态; - 两者都不该混用(SKILL.md 明确列为 DON'T),且无论选哪种,缓存键都必须包含所有会触发重新请求的参数(父级 ID、limit、offset、筛选条件)。
8.2 常见问题排查表
SKILL.md 附带了一张排障表,同样适用于本篇的变体,列在此处供实操时对照:
| 现象 | 检查点 |
|---|---|
| 数据一直不加载 | Hook 是否被调用?键是否为 null/undefined(父级 ID 缺失会被有意禁用)?网络面板里有没有请求? |
| 变更后数据仍是旧的 | refreshXxx 是否执行?mutate 的缓存键是否与 Hook 使用的键完全一致(父级 ID 相同)? |
loading 卡死为 true |
onSuccess 是否写回了 loading=false?Promise 是否被静默 reject(缺 try/finally)? |
| 详情 Map 少条目 / 桶里没数据 | reducer dispatch 是否执行?isEqual 短路是否因陈旧数据提前返回? |
排障时还应注意父级变体特有的一个坑:refreshXxx(parentId) 与 useFetchXxx(parentId) 的键必须同源。仓库现状用 evalKeys 工厂统一生成两侧键,从机制上消灭了"两边拼法不一致导致失效打空"这类问题——新增实体时建议直接沿用键工厂而非手写字符串数组。
8.3 落地 Checklist(父级键控变体版)
综合 walkthrough 六步与 SKILL.md 的总结清单,新增一个父级键控实体时按序核对:
- 类型与状态:在
@lobechat/types定义 Detail 与 ListItem 类型;initialState.ts中声明Record<父级ID, XxxData>桶结构、单条详情、loadingXxxIds; - Service:在
src/services/xxxService.ts增加listXxx(parentId) / getXxx(id) / createXxx / updateXxx / deleteXxx,写操作走mutate;评估是否需要"无父级"的全量入口; - Reducer:列表桶(数组)与详情(Map)各自的 dispatch 联合类型 +
produce实现; - Slice:
useFetchXxx(parentId)(键含父级 ID、父级缺失时置null)、refreshXxx(parentId)(键与 Hook 同源)、temp-id 乐观创建 + 成功后整桶刷新、internal_dispatch(限定父级作用域 +isEqual短路)、internal_updateLoading(per-item 增删); - 接线:
store.ts中并入createXxxSlice与xxxInitialState; - Selectors 与组件:curried selector 带
?? []兜底;组件调用 Hook 而非useEffect,消费error/mutate,首载表面包AsyncBoundary; - 跨域失效:删除父级时顺手
refreshXxx(parentId),把关联桶的缓存一起打空(SKILL.md Pattern 4 的跨域刷新)。
9. 小结
walkthrough.md 的价值在于把 SKILL.md 的抽象配方实例化成"可直接照抄的六份代码",并标出了父级键控变体与标准模式的全部差异点:缓存键加父级维度、状态用 Record<parentId, XxxData> 分桶、乐观更新限定在单个父级桶内、temp-id 乐观创建后整桶刷新、以及父级作用域的 curried selector。
对照仓库当前源码(service 层、dataset slice、reducer、store 组装),可以看到这套模式仍在被严格执行,且随代码库演进而自然加固(缓存键工厂化、类型收敛到 @lobechat/types、slice 类化与 flattenActions 组装)。掌握本条路径后,无论实体是扁平列表还是多父级共享列表,你都能在 LobeHub 的数据层中按同一套架构落地新的数据功能。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00