Refine 中如何用 useTable + useMany 处理关系型数据:关联表字段的完整实战指南
【免费下载链接】refineA React Framework for building internal tools, admin panels, dashboards & B2B apps with unmatched flexibility.项目地址: https://gitcode.com/GitHub_Trending/re/refine
在 Refine 构建后台管理界面时,最常见的场景之一就是列表页展示关系型字段——例如文章列表需要同时显示每篇文章所属的分类名称,而分类数据存储在另一个资源(resource)中。本文以 Refine 官方文档useTable的 "How can I handle relational data?" 章节及其 live preview 示例为骨架,结合packages/core中useTable与useMany的真实源码实现,完整讲解"列表 + 关联数据"的标准解法:用useTable拉取主表数据,用useMany按 ids 批量拉取关联资源,再在渲染层完成关联匹配。读完本文,你将掌握一套可直接复制的关联数据列表页写法,并理解其背后的请求合并与缓存原理。
一、问题背景:列表页中的关联字段从哪来
假设你的 API 返回的文章(posts)数据结构如下:文章对象中只携带了分类的外键category.id,而分类的完整信息(如title)存放在独立的categories资源中:
interface IPost { id: number; title: string; content: string; status: "published" | "draft" | "rejected"; createdAt: string; // 只存外键,不内嵌完整分类对象 category: { id: number; }; } interface ICategory { id: number; title: string; }如果直接在表格中渲染post.category,你只能得到{ id: 2 }这样的外键引用,无法展示分类名称。要解决这个问题,需要两步:
- 用
useTable获取当前页的文章列表; - 从文章列表中提取所有
category.id,用useMany一次性批量获取对应的分类记录; - 渲染时在分类结果中按
id找到每篇文章的分类标题。
这正是 Refine 官方文档中 FAQ 章节"如何处理关系型数据"推荐的做法(见 useTable 文档 与对应的 live preview 示例 _partial-relational-data-live-preview.md)。
二、核心解法:useTable + useMany 组合模式(完整可运行代码)
下面是官方示例的完整代码。它在一个 headless 列表页中完成"文章 + 分类名称"的关联渲染,无任何 UI 库依赖,可直接套用到自己的项目中:
import React from "react"; import { useTable, // 处理关系型数据的关键 hook useMany, HttpError, } from "@refinedev/core"; interface ICategory { id: number; title: string; } interface IPost { id: number; title: string; content: string; status: "published" | "draft" | "rejected"; createdAt: string; category: { id: number; }; } const PostList: React.FC = () => { const { result, tableQuery } = useTable<IPost, HttpError>(); const posts = result.data; // 核心逻辑:根据文章列表中的 category.id,批量拉取分类数据 const { result: categoryData, query: { isLoading: categoryIsLoading }, } = useMany<ICategory, HttpError>({ resource: "categories", // 从文章列表中提取所有分类 id,一次性请求 ids: posts.map((item) => item?.category?.id), queryOptions: { // 仅在文章列表非空时才发起请求,避免空数组请求 enabled: !!posts.length, }, }); if (tableQuery?.isLoading) { return <div>Loading...</div>; } return ( <div> <h1>Posts</h1> <table> <thead> <tr> <th>ID</th> <th>Title</th> <th>Status</th> <th>Created At</th> <th>Category</th> </tr> </thead> <tbody> {posts.map((post) => ( <tr key={post.id}> <td>{post.id}</td> <td>{post.title}</td> <td>{post.status}</td> <td>{new Date(post.createdAt).toDateString()}</td> <td> {categoryIsLoading ? "loading..." : // 在分类结果中按 id 找到当前文章的分类标题 categoryData?.data.find( (item) => item.id === post.category.id, )?.title || "-"} </td> </tr> ))} </tbody> </table> </div> ); };这段代码的三个关键点值得逐条拆解。
2.1 用 useTable 获取主表数据
useTable<IPost, HttpError>()负责根据当前路由的resource(默认从 URL 解析,参见 useTable 源码 中的useResourceParams调用)拉取文章列表。它返回两个核心对象:
tableQuery:TanStack Query 的查询结果对象,tableQuery.isLoading表示列表是否加载中;result:便捷的数据访问层,result.data是当前页记录数组,result.total是总记录数。在关联数据场景中,useMany只关心本页的 ids,因此分页信息无需额外处理。
从源码看,useTable内部把分页、排序、过滤状态统一合并后交给了useList处理:
const queryResult = useList<TQueryFnData, TError, TData>({ resource: identifier, pagination: { currentPage: currentPage, pageSize, mode: pagination?.mode }, filters: isServerSideFilteringEnabled ? unionFilters(preferredPermanentFilters, filters) : undefined, sorters: isServerSideSortingEnabled ? unionSorters(preferredPermanentSorters, sorters) : undefined, // ... });这意味着:表格每次翻页、排序或过滤后重新请求,result.data都会更新为当前页数据,而useMany的ids依赖posts.map(...)也会随之变化并自动重新请求,整个关联链路是响应式的。
2.2 用 useMany 批量拉取关联数据
useMany接受两个必填参数:resource(关联资源名)和ids(要获取的记录 id 数组)。这里ids来自对文章列表的映射:
ids: posts.map((item) => item?.category?.id),当posts变化(如翻页)时,ids变化,useMany会自动触发新的请求。这一点在 useMany 文档 中明确说明:"When these properties are changed, theuseManyhook will trigger a new request."
2.3 用 enabled 守卫避免空请求
queryOptions: { enabled: !!posts.length, },这是一个非常重要的细节:当文章列表为空(比如筛选无结果、第一帧数据未到达)时,ids是空数组,如果照常发起请求会白白浪费一次网络调用。通过 TanStack Query 的enabled: false让查询进入"禁用"状态,直到posts非空才自动启动。这也是示例注释中点名的 "Set to true only if the posts array is not empty"。
三、深入源码:useMany 底层是如何工作的
理解了用法之后,再看 useMany 源码 能帮你建立更扎实的心智模型。该 hook 是 TanStack QueryuseQuery的封装,它有四个核心行为:
1. 以getMany作为查询函数。useMany的 JSDoc 注释明确指出:"It usesgetManymethod as query function from thedataProviderwhich is passed to<Refine>." 也就是说,最终发出的请求由你的dataProvider的getMany方法决定——对 REST 类 provider,通常对应GET /categories?ids=1&ids=2这样的批量接口。
2. 存在 getMany 缺省时的降级策略。如果你的dataProvider没有实现getMany,useMany会退回使用getOne方法逐个请求每个 id。这虽然能保证功能可用,但会产生 N 次请求,性能较差。官方文档明确建议:"It is better to implement thegetManymethod in the data provider."(见 use-many/index.md)
3. 基于属性生成查询键实现缓存。查询键由传入的resource、ids、meta等属性组合生成,因此相同的 ids 组合会被 TanStack Query 缓存复用。翻回上一页时,同样的分类集合直接命中缓存,不会重复请求。
4. 返回query与result双结构。与useTable类似,useMany同时暴露:
query:完整的 TanStack Query 结果(含isLoading、isError、data等);result.data:本次批量获取到的记录数组,示例中的categoryData?.data即此数据。
对应到源码中的返回类型定义:
export type UseManyReturnType<TData, TError> = { query: QueryObserverResult<GetManyResponse<TData>, TError>; result: { data: TData[]; }; } & UseLoadingOvertimeReturnType;四、useMany 关键参数详解
关系型数据场景中,useMany的常用参数如下(完整列表见 use-many/index.md):
| 参数 | 必填 | 说明 |
|---|---|---|
resource | 是 | 关联资源名,会传给getMany方法,通常作为 API 端点路径 |
ids | 是 | 要获取的记录 id 数组,类型为BaseKey[] |
queryOptions | 否 | TanStack Query 的useQuery配置,如enabled、retry;示例中用于空列表守卫 |
dataProviderName | 否 | 存在多个 dataProvider 时指定使用哪一个 |
meta | 否 | 传递给 dataProvider 的附加信息,如自定义 headers、GraphQL 查询字段 |
liveMode/onLiveEvent | 否 | 配合 LiveProvider 实现实时更新 |
overtimeOptions | 否 | 请求超时提示,interval毫秒间隔回调onInterval |
特别说明meta的用途:它最常见的场景是给getMany传自定义请求头,或用于 GraphQL 数据源生成查询。例如:
useMany({ resource: "categories", ids: [1, 2, 3], meta: { headers: { "x-meta-data": "true" }, }, });对应到自定义dataProvider中,meta会作为getMany的参数之一被消费:
const myDataProvider = { // ... getMany: async ({ resource, ids, meta }) => { const headers = meta?.headers ?? {}; const url = `${apiUrl}/${resource}?${stringify({ id: ids })}`; const { data } = await httpClient.get(`${url}`, { headers }); return { data }; }, };五、最佳实践与注意事项
5.1 务必实现 dataProvider 的 getMany
如第三节所述,getMany缺失时useMany会退化为逐条getOne。在分类数量较大的列表页(每页 10~50 条记录)上,这会产生同样数量的请求。正确的做法是在 REST 类 dataProvider 中实现批量接口,例如 Refine 的@refinedev/simple-rest、@refinedev/rest等官方 provider 都原生支持getMany,直接使用即可。
5.2 给空列表加 enabled 守卫
queryOptions: { enabled: !!posts.length, },这行代码虽小,却能避免两类问题:首帧加载时posts为空导致的无效请求,以及筛选无结果时对空 ids 的请求。
5.3 渲染层的容错处理
示例中使用了"三保险"渲染策略:
{categoryIsLoading ? "loading..." : categoryData?.data.find((item) => item.id === post.category.id)?.title || "-"}categoryIsLoading:关联数据加载中显示占位文案;?.与|| "-":分类结果中找不到对应 id(如数据已被删除)时优雅降级,避免页面崩溃。
5.4 与客户端过滤/排序的组合
如果你把useTable的filters.mode或sorters.mode设为"off"(客户端模式),result.data仍是服务端返回的原始数组,此时useMany的 ids 应基于过滤/排序后的数组计算。建议结合useMemo对result.data做派生后再 map ids,确保关联请求只针对真正展示的记录。
5.5 关联数据也有超时提示
useMany与useTable都支持overtimeOptions。当关联资源响应较慢时,可以通过elapsedTime给出"加载时间过长"的提示:
const { overtime } = useMany({ resource: "categories", ids: [1, 2, 3], overtimeOptions: { interval: 1000, onInterval(elapsedInterval) { console.log(elapsedInterval); }, }, }); // 渲染层 { overtime.elapsedTime >= 4000 && <div>this takes a bit longer than expected</div> }六、总结
关系型数据是后台列表页的"标配需求"。Refine 给出的标准解法非常清晰:
useTable负责主列表:它基于排序、过滤、分页状态请求当前页数据(内部经useList调用getList);useMany负责关联数据:从主列表提取外键 ids,经getMany一次批量请求,并由查询键自动缓存复用;- 渲染层匹配:在分类结果中
find对应记录,配以加载态与兜底值。
这套模式在 useTable 文档的 FAQ 章节 有官方示例,在 packages/core/src/hooks/data/useMany.ts 与 packages/core/src/hooks/useTable/index.ts 中有完整实现可查。掌握它,你就能在任意 Refine 项目中高效、可维护地展示关联字段,同时避免 N+1 请求陷阱。
【免费下载链接】refineA React Framework for building internal tools, admin panels, dashboards & B2B apps with unmatched flexibility.项目地址: https://gitcode.com/GitHub_Trending/re/refine
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考