mcpField
D
data-fetching

data-fetching

github.com/lobehub开发83.6k812 次安装今天更新
代码仓库

关于 data-fetching

面向 LobeHub 开发者的数据获取技能,说明 React 应用中的分层实现方式。它要求通过服务层和 Zustand Store 的 SWR Hook 管理请求,并规范缓存刷新与变更操作。

主要功能

  1. 01通过服务层调用 API
  2. 02Zustand Store 的 SWR Hook
  3. 03缓存失效约定
  4. 04列表与详情状态模式
  5. 05分页与条件获取

适用场景

  1. 01添加列表和详情数据流
  2. 02将获取逻辑从 useEffect 迁出
  3. 03变更后刷新相关缓存

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/lobehub/lobehub --skill data-fetching

安装后在 Claude Code 中输入/data-fetching确认已加载。

附带文件references/walkthrough.md

详细介绍

LobeHub 数据获取架构

相关内容: store-data-structures 介绍了列表与详情数据结构的设计依据(Map 与 Array)。

架构概览

┌─────────────┐
│  Component  │
└──────┬──────┘
       │ 1. Call useFetchXxx hook from store
       ↓
┌──────────────────┐
│  Zustand Store   │
│  (State + Hook)  │
└──────┬───────────┘
       │ 2. useClientDataSWR calls service
       ↓
┌──────────────────┐
│  Service Layer   │
│  (xxxService)    │
└──────┬───────────┘
       │ 3. Call lambdaClient
       ↓
┌──────────────────┐
│  lambdaClient    │
│  (TRPC Client)   │
└──────────────────┘

核心原则

✅ 应该这样做
  1. 使用服务层处理所有 API 调用
  2. 使用 Store SWR Hooks 获取数据(不要使用 useEffect)
  3. 使用合适的数据结构 — 列表与详情模式请参阅 store-data-structures skill
  4. 使用 lambdaClient.mutate 执行写入操作(创建/更新/删除)
  5. 仅在服务方法中使用 lambdaClient.query
  6. 命名约定 — 读取 Hooks 使用 useFetchXxx,缓存失效辅助函数使用 refreshXxx(例如 useFetchBenchmarks/refreshBenchmarks)。变更操作应在调用服务后接着调用 refreshXxx()。
❌ 不要这样做
  1. 切勿使用 useEffect 获取数据
  2. 切勿在组件或 Store 中直接调用 lambdaClient
  3. 切勿使用 useState 存储服务端数据
  4. 切勿混用数据结构模式 — 请遵循 store-data-structures skill

第 1 层:服务层

目的
  • 封装所有对 lambdaClient 的 API 调用
  • 提供简洁、类型明确的接口
  • 作为 API 操作的唯一事实来源
服务结构
// src/services/agentEval.ts
class AgentEvalService {
  // Query methods - READ operations
  async listBenchmarks() {
    return lambdaClient.agentEval.listBenchmarks.query();
  }

  async getBenchmark(id: string) {
    return lambdaClient.agentEval.getBenchmark.query({ id });
  }

  // Mutation methods - WRITE operations
  async createBenchmark(params: CreateBenchmarkParams) {
    return lambdaClient.agentEval.createBenchmark.mutate(params);
  }

  async updateBenchmark(params: UpdateBenchmarkParams) {
    return lambdaClient.agentEval.updateBenchmark.mutate(params);
  }

  async deleteBenchmark(id: string) {
    return lambdaClient.agentEval.deleteBenchmark.mutate({ id });
  }
}

export const agentEvalService = new AgentEvalService();
服务指南
  1. 每个领域使用一个服务(例如,agentEval、ragEval、aiAgent)
  2. 导出单例实例(export const xxxService = new XxxService())
  3. 方法名与操作相对应(list、get、create、update、delete)
  4. 明确参数类型(复杂参数使用接口)

第 2 层:带 SWR Hooks 的 Store

目的
  • 管理客户端状态
  • 提供用于数据获取的 SWR Hooks
  • 处理缓存失效
状态结构
// src/store/eval/slices/benchmark/initialState.ts
export interface BenchmarkSliceState {
  // List data - simple array
  benchmarkList: AgentEvalBenchmarkListItem[];
  benchmarkListInit: boolean;

  // Detail data - map for caching
  benchmarkDetailMap: Record<string, AgentEvalBenchmark>;
  loadingBenchmarkDetailIds: string[];

  // Mutation states
  isCreatingBenchmark: boolean;
  isUpdatingBenchmark: boolean;
  isDeletingBenchmark: boolean;
}

关于完整的 initialState、reducer 和内部 dispatch 模式,请参阅 store-data-structures skill。

Actions
// src/store/eval/slices/benchmark/action.ts
const FETCH_BENCHMARKS_KEY = 'FETCH_BENCHMARKS';
const FETCH_BENCHMARK_DETAIL_KEY = 'FETCH_BENCHMARK_DETAIL';

export interface BenchmarkAction {
  // SWR Hooks - for data fetching
  useFetchBenchmarks: () => SWRResponse;
  useFetchBenchmarkDetail: (id?: string) => SWRResponse;

  // Refresh methods - for cache invalidation
  refreshBenchmarks: () => Promise<void>;
  refreshBenchmarkDetail: (id: string) => Promise<void>;

  // Mutation actions
  createBenchmark: (params: CreateParams) => Promise<any>;
  updateBenchmark: (params: UpdateParams) => Promise<void>;
  deleteBenchmark: (id: string) => Promise<void>;

  // Internal methods - not for direct UI use
  internal_dispatchBenchmarkDetail: (payload: BenchmarkDetailDispatch) => void;
  internal_updateBenchmarkDetailLoading: (id: string, loading: boolean) => void;
}

export const createBenchmarkSlice: StateCreator<EvalStore, any, [], BenchmarkAction> = (
  set,
  get,
) => ({
  // Fetch list — simple array stored in benchmarkList
  useFetchBenchmarks: () =>
    useClientDataSWR(FETCH_BENCHMARKS_KEY, () => agentEvalService.listBenchmarks(), {
      onSuccess: (data) => {
        set({ benchmarkList: data, benchmarkListInit: true }, false, 'useFetchBenchmarks/success');
      },
    }),

  // Fetch detail — null key disables the request when id is missing
  useFetchBenchmarkDetail: (id) =>
    useClientDataSWR(
      id ? [FETCH_BENCHMARK_DETAIL_KEY, id] : null,
      () => agentEvalService.getBenchmark(id!),
      {
        onSuccess: (data) => {
          get().internal_dispatchBenchmarkDetail({
            type: 'setBenchmarkDetail',
            id: id!,
            value: data,
          });
          get().internal_updateBenchmarkDetailLoading(id!, false);
        },
      },
    ),

  // Refresh methods
  refreshBenchmarks: () => mutate(FETCH_BENCHMARKS_KEY),
  refreshBenchmarkDetail: (id) => mutate([FETCH_BENCHMARK_DETAIL_KEY, id]),

  // CREATE — refresh list after creation
  createBenchmark: async (params) => {
    set({ isCreatingBenchmark: true }, false, 'createBenchmark/start');
    try {
      const result = await agentEvalService.createBenchmark(params);
      await get().refreshBenchmarks();
      return result;
    } finally {
      set({ isCreatingBenchmark: false }, false, 'createBenchmark/end');
    }
  },

  // UPDATE — optimistic update + refresh
  updateBenchmark: async (params) => {
    const { id } = params;

    // 1. Optimistic update
    get().internal_dispatchBenchmarkDetail({
      type: 'updateBenchmarkDetail',
      id,
      value: params,
    });
    // 2. Set loading
    get().internal_updateBenchmarkDetailLoading(id, true);

    try {
      // 3. Call service
      await agentEvalService.updateBenchmark(params);
      // 4. Refresh from server
      await get().refreshBenchmarks();
      await get().refreshBenchmarkDetail(id);
    } finally {
      get().internal_updateBenchmarkDetailLoading(id, false);
    }
  },

  // DELETE — optimistic update + refresh
  deleteBenchmark: async (id) => {
    get().internal_dispatchBenchmarkDetail({ type: 'deleteBenchmarkDetail', id });
    get().internal_updateBenchmarkDetailLoading(id, true);

    try {
      await agentEvalService.deleteBenchmark(id);
      await get().refreshBenchmarks();
    } finally {
      get().internal_updateBenchmarkDetailLoading(id, false);
    }
  },

  // Internal — dispatch to reducer (for detail map)
  internal_dispatchBenchmarkDetail: (payload) => {
    const currentMap = get().benchmarkDetailMap;
    const nextMap = benchmarkDetailReducer(currentMap, payload);

    // Skip set when nothing changed — avoids unnecessary re-renders
    if (isEqual(nextMap, currentMap)) return;
    set({ benchmarkDetailMap: nextMap }, false, `dispatchBenchmarkDetail/${payload.type}`);
  },

  // Internal — update loading state for specific detail
  internal_updateBenchmarkDetailLoading: (id, loading) => {
    set(
      (state) => ({
        loadingBenchmarkDetailIds: loading
          ? [...state.loadingBenchmarkDetailIds, id]
          : state.loadingBenchmarkDetailIds.filter((i) => i !== id),
      }),
      false,
      'updateBenchmarkDetailLoading',
    );
  },
});
Store 指南
  1. 将 SWR 键定义为常量,放在文件顶部
  2. 所有数据获取都使用 useClientDataSWR(绝不使用 useEffect)
  3. onSuccess 回调更新 Store 状态
  4. 刷新方法使用 mutate() 使缓存失效
  5. 在 initialState 中定义加载状态,并在 onSuccess 中更新
  6. 变更操作调用服务,然后刷新相关缓存

第 3 层:组件用法

获取列表数据
// ✅ CORRECT
const BenchmarkList = () => {
  // 1. Get the hook from store
  const useFetchBenchmarks = useEvalStore((s) => s.useFetchBenchmarks);

  // 2. Get list data
  const benchmarks = useEvalStore((s) => s.benchmarkList);
  const isInit = useEvalStore((s) => s.benchmarkListInit);

  // 3. Call the hook (SWR handles the data fetching)
  useFetchBenchmarks();

  // 4. Use the data
  if (!isInit) return <Loading />;
  return (
    <div>
      <h2>Total: {benchmarks.length}</h2>
      {benchmarks.map((b) => (
        <BenchmarkCard key={b.id} {...b} />
      ))}
    </div>
  );
};
获取详情数据
// ✅ CORRECT
const BenchmarkDetail = () => {
  const { benchmarkId } = useParams<{ benchmarkId: string }>();

  const useFetchBenchmarkDetail = useEvalStore((s) => s.useFetchBenchmarkDetail);

  // Detail from map
  const benchmark = useEvalStore((s) =>
    benchmarkId ? s.benchmarkDetailMap[benchmarkId] : undefined,
  );

  // Per-item loading
  const isLoading = useEvalStore((s) =>
    benchmarkId ? s.loadingBenchmarkDetailIds.includes(benchmarkId) : false,
  );

  useFetchBenchmarkDetail(benchmarkId);

  if (!benchmark) return <Loading />;
  return (
    <div>
      <h1>{benchmark.name}</h1>
      <p>{benchmark.description}</p>
      {isLoading && <Spinner />}
    </div>
  );
};
使用 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),
};

// Component with selectors
const BenchmarkDetail = () => {
  const { benchmarkId } = useParams();
  const useFetchBenchmarkDetail = useEvalStore((s) => s.useFetchBenchmarkDetail);
  const benchmark = useEvalStore(benchmarkSelectors.getBenchmarkDetail(benchmarkId!));

  useFetchBenchmarkDetail(benchmarkId);

  return <div>{benchmark && <h1>{benchmark.name}</h1>}</div>;
};
反模式
// ❌ WRONG — Don't use useEffect for data fetching
const BenchmarkList = () => {
  const [data, setData] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    lambdaClient.agentEval.listBenchmarks
      .query()
      .then(setData)
      .finally(() => setLoading(false));
  }, []);

  return <div>...</div>;
};
在组件中执行变更操作
// Create — global mutation flag drives form loading
const CreateBenchmarkModal = () => {
  const createBenchmark = useEvalStore((s) => s.createBenchmark);
  const isCreating = useEvalStore((s) => s.isCreatingBenchmark);

  const handleSubmit = async (values) => {
    try {
      // Optimistic update + refresh happen inside createBenchmark
      await createBenchmark(values);
      message.success('Created successfully');
      onClose();
    } catch (error) {
      message.error('Failed to create');
    }
  };

  return (
    <Form onSubmit={handleSubmit} loading={isCreating}>
      ...
    </Form>
  );
};

// Update / delete — per-item loading so only the row being mutated spins
const BenchmarkItem = ({ id }: { id: string }) => {
  const updateBenchmark = useEvalStore((s) => s.updateBenchmark);
  const deleteBenchmark = useEvalStore((s) => s.deleteBenchmark);
  const isLoading = useEvalStore(benchmarkSelectors.isLoadingBenchmarkDetail(id));

  const handleUpdate = async (data) => {
    await updateBenchmark({ id, ...data });
  };

  const handleDelete = async () => {
    await deleteBenchmark(id);
  };

  return (
    <div>
      {isLoading && <Spinner />}
      <button onClick={handleUpdate}>Update</button>
      <button onClick={handleDelete}>Delete</button>
    </div>
  );
};

为什么采用两种模式: create 尚无 id,因此一个 isCreatingXxx 标记就足够了。update/delete 则针对特定行;全局标记会导致无关行也被冻结,因此应将每项状态保存在 loadingXxxIds 中。


需要更完整的示例吗?

上面的标准 Benchmark 示例可用于实现扁平列表与详情 Map。如果你需要维护按父级 id 索引的列表(例如 datasetMap[benchmarkId],因为相同的数据结构会出现在多个父级下),请阅读 references/walkthrough.md — 其中通过完整的 6 个步骤(服务 → reducer → slice → Store 接线 → selectors → 组件)介绍了这一变体。


常见模式

模式 1:分页

缓存键数组必须包含所有应触发重新获取的参数。

useFetchTestCases: (params: { datasetId: string; limit: number; offset: number }) =>
  useClientDataSWR(
    params.datasetId ? [FETCH_TEST_CASES_KEY, params.datasetId, params.limit, params.offset] : null,
    () => agentEvalService.listTestCases(params),
    {
      onSuccess: (data) =>
        set({
          testCaseList: data.data,
          testCaseTotal: data.total,
          isLoadingTestCases: false,
        }),
    },
  );
模式 2:依赖式获取

两个 Hooks 会并行运行 — SWR 会进行去重,无需手动排序。

const BenchmarkDetail = () => {
  const { benchmarkId } = useParams();
  const useFetchBenchmarkDetail = useEvalStore((s) => s.useFetchBenchmarkDetail);
  const useFetchDatasets = useEvalStore((s) => s.useFetchDatasets);

  useFetchBenchmarkDetail(benchmarkId);
  useFetchDatasets(benchmarkId);

  return <div>...</div>;
};
模式 3:条件式获取

传入 undefined 即可完全禁用该 Hook。

// only fetch when modal is open AND id present
useFetchDatasetDetail(open && datasetId ? datasetId : undefined);
模式 4:跨领域刷新
deleteBenchmark: async (id) => {
  await agentEvalService.deleteBenchmark(id);
  await get().refreshBenchmarks();
  await get().refreshDatasets(id); // related cache invalidated too
};

迁移指南:从 useEffect 迁移到 Store SWR

之前(❌ 错误)
const TestCaseList = ({ datasetId }: Props) => {
  const [data, setData] = useState<any[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    lambdaClient.agentEval.listTestCases
      .query({ datasetId })
      .then((r) => setData(r.data))
      .finally(() => setLoading(false));
  }, [datasetId]);

  return <Table data={data} loading={loading} />;
};
之后(✅ 正确)
// 1. Add service method
class AgentEvalService {
  async listTestCases(params: { datasetId: string }) {
    return lambdaClient.agentEval.listTestCases.query(params);
  }
}

// 2. Add store slice hook
export const createTestCaseSlice: StateCreator<...> = (set) => ({
  useFetchTestCases: (params) =>
    useClientDataSWR(
      params.datasetId ? [FETCH_TEST_CASES_KEY, params.datasetId] : null,
      () => agentEvalService.listTestCases(params),
      {
        onSuccess: (data) =>
          set({ testCaseList: data.data, isLoadingTestCases: false }),
      },
    ),
});

// 3. Component reads from store
const TestCaseList = ({ datasetId }: Props) => {
  const useFetchTestCases = useEvalStore((s) => s.useFetchTestCases);
  const data = useEvalStore((s) => s.testCaseList);
  const loading = useEvalStore((s) => s.isLoadingTestCases);

  useFetchTestCases({ datasetId });

  return <Table data={data} loading={loading} />;
};

故障排查

症状检查
数据始终无法加载是否调用了 Hook?Key 是否不是 null/undefined?网络面板是否显示请求?
修改后数据过时是否运行了 refreshXxx?缓存 Key 是否与 Hook 使用的 Key 一致?
加载状态一直为 trueonSuccess 是否将 loading 设置为 false?Promise 是否被静默拒绝?
详情映射中缺少条目是否执行了 Reducer dispatch?isEqual 是否因数据过时而提前返回?

总结清单

添加新的数据获取功能时:

第 1 步:类型与状态

详情请参阅 store-data-structures。

  • 在 @lobechat/types 中定义类型:详情类型 + 列表项类型
  • 状态结构:xxxList: XxxListItem[]、xxxDetailMap: Record<string, Xxx>、loadingXxxDetailIds: string[]
  • 如果需要乐观更新,则添加 Reducer
第 2 步:服务层
  • 在 src/services/xxxService.ts 中创建服务
  • 方法:listXxx()、getXxx(id)、createXxx()、updateXxx()、deleteXxx()
第 3 步:Store Actions
  • initialState.ts 中包含状态结构
  • action.ts 中包含:
    • useFetchXxxList()、useFetchXxxDetail(id) — SWR Hooks
    • refreshXxxList()、refreshXxxDetail(id) — 使缓存失效
    • 调用服务的 CRUD 方法
    • 如果使用 Reducer,则包含 internal_dispatch、internal_updateLoading
  • selectors.ts(可选,但建议添加)
  • 将 Slice 集成到主 Store 和 initialState 中
第 4 步:组件用法
  • 使用 Store Hooks(不要使用 useEffect)
  • 列表页面:访问 xxxList 数组
  • 详情页面:访问 xxxDetailMap[id]
  • 使用加载状态为 UI 提供反馈

思维模型: 类型 → 服务 → Reducer → Slice → 组件 🎯


相关技能

  • store-data-structures — 如何在 Store 中组织列表和详情数据
  • zustand — Zustand 的通用模式和最佳实践

评论

支持 Markdown