首页
/ LobeHub 数据获取架构实战:以父级 ID 为键的 Map 变体六步落地全流程(Dataset 示例)

LobeHub 数据获取架构实战:以父级 ID 为键的 Map 变体六步落地全流程(Dataset 示例)

2026-09-04 12:49:23作者:史锋燃Gardner

本篇基于 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 基于 immerproduce 实现不可变更新:

// 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 / totalisLoading)。这样切换到不同 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 & EvalStoreActioncreateStore 展开 ...initialState 后经 flattenActionscreateBenchmarkSlice / 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 组件接收 benchmarkId prop,同一组件实例在不同 benchmark 下渲染的是各自桶的数据与各自的分页/加载态;
  • 条件拉取(conditional fetching):详情 Hook 传入 open && datasetId ? datasetId : undefined——弹窗未打开或 ID 缺失时键为 null,SWR 不发请求。弹窗详情场景下这能避免"页面一加载就为所有潜在弹窗预拉数据"。

另外,SKILL.md 对组件侧有一条容易被忽略的硬性契约:异步失败边界。每个读 Hook 都返回带 errormutate 的 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 的总结清单,新增一个父级键控实体时按序核对:

  1. 类型与状态:在 @lobechat/types 定义 Detail 与 ListItem 类型;initialState.ts 中声明 Record<父级ID, XxxData> 桶结构、单条详情、loadingXxxIds
  2. Service:在 src/services/xxxService.ts 增加 listXxx(parentId) / getXxx(id) / createXxx / updateXxx / deleteXxx,写操作走 mutate;评估是否需要"无父级"的全量入口;
  3. Reducer:列表桶(数组)与详情(Map)各自的 dispatch 联合类型 + produce 实现;
  4. SliceuseFetchXxx(parentId)(键含父级 ID、父级缺失时置 null)、refreshXxx(parentId)(键与 Hook 同源)、temp-id 乐观创建 + 成功后整桶刷新、internal_dispatch(限定父级作用域 + isEqual 短路)、internal_updateLoading(per-item 增删);
  5. 接线store.ts 中并入 createXxxSlicexxxInitialState
  6. Selectors 与组件:curried selector 带 ?? [] 兜底;组件调用 Hook 而非 useEffect,消费 error/mutate,首载表面包 AsyncBoundary
  7. 跨域失效:删除父级时顺手 refreshXxx(parentId),把关联桶的缓存一起打空(SKILL.md Pattern 4 的跨域刷新)。

9. 小结

walkthrough.md 的价值在于把 SKILL.md 的抽象配方实例化成"可直接照抄的六份代码",并标出了父级键控变体与标准模式的全部差异点:缓存键加父级维度、状态用 Record<parentId, XxxData> 分桶、乐观更新限定在单个父级桶内、temp-id 乐观创建后整桶刷新、以及父级作用域的 curried selector。

对照仓库当前源码(service 层dataset slicereducerstore 组装),可以看到这套模式仍在被严格执行,且随代码库演进而自然加固(缓存键工厂化、类型收敛到 @lobechat/types、slice 类化与 flattenActions 组装)。掌握本条路径后,无论实体是扁平列表还是多父级共享列表,你都能在 LobeHub 的数据层中按同一套架构落地新的数据功能。

登录后查看全文
热门项目推荐
相关项目推荐