Refine 数据获取实战:useList Hook 的选项、模式与边界场景全解析
【免费下载链接】refineA React Framework for building internal tools, admin panels, dashboards & B2B apps with unmatched flexibility.项目地址: https://gitcode.com/GitHub_Trending/re/refine
useList是 Refine 核心包(@refinedev/core)中最常用的数据获取 Hook,它建立在 TanStack Query 的useQuery之上,为列表类资源的分页、排序、过滤、实时更新与超时反馈提供了开箱即用的能力。本文以 Refine 仓库中 useList 官方文档 为主体,结合 useList 源码、单元测试 与 simple-rest 数据提供器 的实现细节,带你从 API 用法一路深入到查询键(query key)生成、客户端分页切片、通知处理与实时订阅的底层原理,帮助你写出可扩展、可维护的列表页面。
useList 是什么:useQuery 的“列表化”扩展
从定义上看,useList是 TanStack QueryuseQuery的扩展版本——它完整继承了useQuery的全部能力(缓存、重试、enabled、select等),并在此基础上增加了 Refine 特有的列表语义:
- 查询函数(query function):内部调用
dataProvider的getList方法作为查询函数。getList接收resource、pagination、sorters、filters、meta等参数,返回{ data, total }。 - 查询键(query key):由 Hook 传入的属性自动生成,用于缓存数据。你可以借助 TanStack Query Devtools 直接观察到这个键的完整结构。
从源码看,useList的返回结构被设计为{ query, result, overtime }三部分(见 useList.ts):
export type UseListReturnType<TData, TError> = { query: QueryObserverResult<GetListResponse<TData>, TError>; result: { data: TData[]; total: number | undefined; [key: string]: any; }; } & UseLoadingOvertimeReturnType;其中query就是原生的useQuery返回值(包含isLoading、isError、isFetching等状态),result则提供了便捷的data(无数据时为空数组)与total访问方式,overtime用于追踪请求耗时。
基本用法:从零渲染一个产品列表
最基础的用法只需传入resource即可。以下示例来自仓库中的 _basic-usage-live-preview.md,完整展示了 Hook 的接入方式:
import { useList, HttpError } from "@refinedev/core"; interface IProduct { id: number; name: string; material: string; } const ProductList: React.FC = () => { const { result, query } = useList<IProduct, HttpError>({ resource: "products", }); const products = result.data ?? []; if (query.isLoading) { return <div>Loading...</div>; } if (query.isError) { return <div>Something went wrong!</div>; } return ( <ul> {products.map((product) => ( <li key={product.id}> <h4> {product.name} - ({product.material}) </h4> </li> ))} </ul> ); };几个值得注意的细节:
- 泛型参数:
useList<IProduct, HttpError>中的第一个类型参数是查询函数返回的记录类型,第二个是自定义错误类型(需继承HttpError)。它们与 TanStack Query 的TQueryFnData、TError一一对应。 result.data兜底:源码中result.data在没有数据时会回退到EMPTY_ARRAY(一个被冻结的空数组),因此products.map(...)无需担心undefined。- 状态判定:
query.isLoading/query.isError均来自 TanStack Query 的useQuery返回值,用法与原生一致。
分页(Pagination):三种模式与 total 的来源
useList通过pagination属性启用分页,并原样透传给getList。动态修改pagination属性会触发新的请求。其类型为:
pagination?: { currentPage?: number; pageSize?: number; mode?: "off" | "client" | "server"; };默认值与参数归一化
当你不传pagination时,内部会通过handlePaginationParams补齐默认值(见 handlePaginationParams/index.ts):
mode默认为"server";currentPage默认为1;pageSize默认为10。
这意味着即使你完全省略pagination属性,getList收到的仍是一份完整的{ currentPage: 1, pageSize: 10, mode: "server" }。
mode 三种取值的语义
| 取值 | 含义 | 分页行为 |
|---|---|---|
"server" | 服务端分页(默认) | pagination作为查询参数传给getList,并由其拼接到 API 请求中 |
"client" | 客户端分页 | 一次性拉取全量数据,在浏览器端对返回数组做切片 |
"off" | 关闭分页 | 不分页,一次取回所有数据 |
客户端分页的实现值得展开:在 useList.ts 中,memoizedSelect会在mode === "client"时对data.data做本地切片:
if (prefferedPagination.mode === "client") { data = { ...data, data: data.data.slice( (prefferedPagination.currentPage - 1) * prefferedPagination.pageSize, prefferedPagination.currentPage * prefferedPagination.pageSize, ), total: data.total, }; }也就是说,客户端分页是借助useQuery的select机制实现的,切片逻辑发生在查询结果被消费之前。
total 的检索方式与 rowCount 约定
当getList被调用时,Refine 期望返回结果中包含总行数(total/rowCount)。不同数据提供器的获取方式各不相同:
- REST 类提供器:通常读取响应头中的
x-total-count。 - GraphQL 类提供器:通常从特定字段读取,例如
pageInfo.total。 - 其他提供器:遵循各自约定的方式。
- 兜底策略:如果后端没有提供总数,
getList可以回退为返回数组的长度作为total。
这一点在仓库中有直接证据。以 simple-rest 的getList为例:
getList: async ({ resource, pagination, filters, sorters, meta }) => { const url = `${apiUrl}/${resource}`; const { currentPage = 1, pageSize = 10, mode = "server" } = pagination ?? {}; // ... if (mode === "server") { query._start = (currentPage - 1) * pageSize; query._end = currentPage * pageSize; } // ... const { data, headers } = await httpClientrequestMethod; const total = +headers["x-total-count"]; return { data, total: total || data.length, // 无 header 时回退为数组长度 }; },可以看到:服务端分页模式下,simple-rest 会把分页参数转换为 JSON Server 风格的_start/_end查询参数;总数优先取x-total-count响应头,缺失时退化为data.length。这一约定与 getList 文档 中给出的参考实现完全一致:
getList: async ({ resource, pagination, sorters, filters, meta }) => { const { currentPage, pageSize } = pagination ?? {}; const response = await apiClient.get(`/${resource}`, { params: { _page: currentPage, _limit: pageSize }, }); const total = response.headers["x-total-count"] ?? response.data.length; return { data: response.data, total }; };一个完整的分页交互示例
来自 _pagination-live-preview.md 的示例展示了如何用 React 状态驱动currentPage与pageSize,改动任一状态都会触发getList重新请求:
import { useState } from "react"; import { useList, HttpError } from "@refinedev/core"; interface IProduct { id: number; name: string; material: string; } const ProductList: React.FC = () => { const [currentPage, setCurrentPage] = useState(1); const [pageSize, setPageSize] = useState(5); const { result, query } = useList<IProduct, HttpError>({ resource: "products", pagination: { currentPage, pageSize, }, }); const products = result.data ?? []; if (query.isLoading) { return <div>Loading...</div>; } if (query.isError) { return <div>Something went wrong!</div>; } return ( <div> <button onClick={() => setCurrentPage((prev) => prev - 1)}>{"<"}</button> <span> page: {currentPage} </span> <button onClick={() => setCurrentPage((prev) => prev + 1)}>{">"}</button> <span> per page: </span> <select value={pageSize} onChange={(e) => setPageSize(Number(e.target.value))}> {[5, 10, 20].map((size) => ( <option key={size} value={size}> {size} </option> ))} </select> <ul> {products.map((product) => ( <li key={product.id}> <h4> {product.name} - ({product.material}) </h4> </li> ))} </ul> </div> ); };查询键如何受分页模式影响
useList的查询键由useKeys生成(见 useList.ts),结构大致为:
["data", dataProviderName, resourceName, "list", { meta, filters, pagination?, sorters? }]一个关键行为是:只有服务端分页(mode === "server")才会把pagination写进查询键。这在 useList.spec.tsx 的测试中有明确验证:
- 当
mode为"server"(或未指定,默认即 server)时,getList收到的meta.queryKey中包含pagination: { currentPage, mode, pageSize }; - 当
mode为"client"或"off"时,meta.queryKey中不包含pagination,因为服务端不需要按页码请求,改动分页不应产生新的网络请求。
排序(Sorting):sorters 属性
useList支持通过sorters属性排序,并原样透传给getList。动态修改sorters会触发新的请求。其类型为CrudSort[],即{ field: string; order: "asc" | "desc" }的数组。
useList({ sorters: [ { field: "title", order: "asc", }, ], });来自 _sorting-live-preview.md 的交互示例展示了通过按钮在asc/desc之间切换排序:
import { useState } from "react"; import { useList, HttpError } from "@refinedev/core"; interface IProduct { id: number; name: string; material: string; } const ProductList: React.FC = () => { const [order, setOrder] = useState<"asc" | "desc">("asc"); const { result, query } = useList<IProduct, HttpError>({ resource: "products", sorters: [ { field: "name", order, }, ], }); const products = result.data ?? []; if (query.isLoading) { return <div>Loading...</div>; } if (query.isError) { return <div>Something went wrong!</div>; } return ( <div> <button onClick={() => setOrder((prev) => (prev === "asc" ? "desc" : "asc"))}> toggle sort </button> <ul> {products.map((product) => ( <li key={product.id}> <h4> {product.name} - ({product.material}) </h4> </li> ))} </ul> </div> ); };在 simple-rest 提供器中,sorters会被generateSort转换成 JSON Server 风格的_sort/_order查询参数(见 provider.ts)。更详细的字段语义可参考 CrudSorting 接口定义。
过滤(Filtering):filters 属性
useList通过filters属性支持过滤,并原样透传给getList。动态修改filters会触发新的请求。其类型为CrudFilter[],每个过滤条件形如{ field, operator, value }:
useList({ filters: [ { field: "title", operator: "contains", value: "Foo", }, ], });来自 _filtering-live-preview.md 的示例演示了通过下拉框切换过滤值(material等于Cotton/Bronze/Plastic):
import { useState } from "react"; import { useList, HttpError } from "@refinedev/core"; interface IProduct { id: number; name: string; material: string; } const ProductList: React.FC = () => { const [value, setValue] = useState("Cotton"); const { result, query } = useList<IProduct, HttpError>({ resource: "products", filters: [ { field: "material", operator: "eq", value, }, ], }); const products = result.data ?? []; if (query.isLoading) { return <div>Loading...</div>; } if (query.isError) { return <div>Something went wrong!</div>; } return ( <div> <span> material: </span> <select value={value} onChange={(e) => setValue(e.target.value)}> {["Cotton", "Bronze", "Plastic"].map((material) => ( <option key={material} value={material}> {material} </option> ))} </select> <ul> {products.map((product) => ( <li key={product.id}> <h4> {product.name} - ({product.material}) </h4> </li> ))} </ul> </div> ); };operator支持eq、ne、contains、gt、gte、lt、lte、between、in、and、or等多种语义,具体字段定义参见 CrudFilters 接口。在 simple-rest 提供器中,filters由generateFilter转换为 URL 查询参数后与分页、排序参数合并(见 provider.ts)。
实时更新(Realtime Updates):订阅与三种 live 属性
该功能仅在配置了 Live Provider 时可用。
当useList挂载时,它内部会调用liveProvider的subscribe方法,传入channel、resource等参数,用于订阅实时事件。从源码看(useList.ts),订阅通过useResourceSubscription完成:
channel为resources/${resource?.name};- 订阅事件类型为
"*"(全部事件); - 订阅参数包含
meta、pagination、hasPagination、sorters、filters,以及标识为subscriptionType: "useList",用户传入的liveParams也会被合并进去。
文档中还提到了一个值得注意的派生关系:useTable、useSelect、useInfiniteList等 Hook 内部都基于useList实现,因此它们会订阅相同的事件通道(参见 live-provider 文档 中“派生 Hook 订阅同一事件”的说明)。
useList提供以下与实时相关的属性:
| 属性 | 说明 | 示例 |
|---|---|---|
liveMode | 收到相关实时事件后,是自动更新数据("auto")还是手动处理("manual") | liveMode: "auto" |
onLiveEvent | 收到订阅事件时的回调函数 | onLiveEvent: (event) => console.log(event) |
liveParams | 透传给liveProvider.subscribe方法的额外参数 | 可自定义订阅所需的上下文 |
useList({ liveMode: "auto", onLiveEvent: (event) => { console.log(event); }, });属性详解:从 resource 到 overtimeOptions
resource(必填)
resource会被作为参数传给getList。它通常对应 API 端点路径,但具体如何解析完全取决于getList的实现:
useList({ resource: "categories", });当多个资源同名时,可以改用identifier来区分。identifier仅作为资源匹配的主键,数据提供器方法内部仍然使用<Refine>组件中定义的name工作。有关identifier的完整说明参见 Refine 组件文档。
dataProviderName
当你的应用中配置了多个数据提供器时,通过该属性指定使用哪一个:
useList({ dataProviderName: "second-data-provider", });源码中通过pickDataProvider(identifier, dataProviderName, resources)完成选择(useList.ts),且该名称会进入查询键,保证不同提供器的缓存相互隔离。
queryOptions
queryOptions用于把额外选项透传给 TanStack Query 的useQuery,例如重试次数、enabled、select、staleTime等:
useList({ queryOptions: { retry: 3, }, });注意两点实现细节:
enabled的默认逻辑:当用户未显式设置enabled时,Hook 会以!!resource?.name作为默认值,即资源名缺失时查询自动禁用(useList.ts)。select的合并顺序:用户传入的select会在客户端分页切片之后执行,因此select接收到的已是切片后的数据(useList.ts)。源码注释同时提醒:如果select未被useCallback记忆化,它会在每次渲染时重新执行。
meta
meta是 Refine 中用于向数据提供器传递额外信息的特殊属性,常见用途有两种:
- 针对特定场景定制数据提供器的行为;
- 用纯 JavaScript 对象(JSON)生成 GraphQL 查询。
下面的示例在meta中传递自定义请求头,并在自定义getList中取出使用:
useList({ meta: { headers: { "x-meta-data": "true" }, }, }); const myDataProvider = { //... getList: async ({ resource, pagination, sorters, filters, meta }) => { const headers = meta?.headers ?? {}; const url = `${apiUrl}/${resource}`; //... const { data } = await httpClient.get(`${url}`, { headers }); return { data, }; }, //... };在 simple-rest 提供器中,meta.headers会直接作为 axios 请求头传入(见 provider.ts),这也是“携带认证令牌/自定义头”最常见的做法。关于 meta 的合并规则(来自 resource 定义、Hook 调用与上下文三处的 meta 最终会合并为一份),可参考 General Concepts 文档的 Meta Concept 章节。
successNotification 与 errorNotification
这两个属性需要配合 NotificationProvider 使用。
successNotification:数据获取成功后,useList会调用NotificationProvider.open展示成功通知,该属性用于定制通知内容(默认值为false,即默认不弹成功通知)。errorNotification:数据获取失败后,useList会调用open展示错误通知,该属性用于定制错误内容(默认消息为"Error (status code: {statusCode})")。
两个属性都支持传入对象或回调函数:
useList({ successNotification: (data, values, resource) => { return { message: `${data.title} Successfully fetched.`, description: "Success with no errors", type: "success", }; }, }); useList({ errorNotification: (data, values, resource) => { return { message: `Something went wrong when getting ${data.id}`, description: "Error", type: "error", }; }, });源码中的处理逻辑位于两个useEffect中(useList.ts):成功时根据successNotification配置调用handleNotification;失败时先通过useOnError的checkError触发鉴权错误处理,再以key: ${identifier}-useList-notification作为通知标识调用handleNotification,并用translate生成默认错误文案。
overtimeOptions
当请求耗时过长时,可以用overtimeOptions开启加载超时反馈,便于展示“请求比预期更久”的提示。其中interval是回调触发的时间间隔(毫秒),onInterval是每个间隔触发的回调。Hook 返回的overtime对象中的elapsedTime表示已耗时(毫秒),请求完成时变为undefined:
const { overtime } = useList({ //... overtimeOptions: { interval: 1000, onInterval(elapsedInterval) { console.log(elapsedInterval); }, }, }); console.log(overtime.elapsedTime); // undefined, 1000, 2000, 3000 4000, ... // 使用示例: { elapsedTime >= 4000 && <div>this takes a bit longer than expected</div>; }实现上,overtime由useLoadingOvertimeHook 基于queryResponse.isFetching驱动(useList.ts)。
返回值(Return Values)
useList返回 TanStack QueryuseQuery的全部返回值,外加两个扩展字段:
| 字段 | 类型 | 说明 |
|---|---|---|
query | QueryObserverResult<{ data: TData[]; total: number }, TError> | 原生useQuery返回值,含isLoading、isError、isFetching、refetch等 |
result | { data: TData[]; total: number; [key: string]: any } | 便捷结构:data为记录数组(空时为空数组),total为总数 |
overtime | { elapsedTime?: number } | 请求已耗时(毫秒),完成时为undefined |
const { overtime } = useList(); console.log(overtime.elapsedTime); // undefined, 1000, 2000, 3000 4000, ...值得注意的是result通过展开queryResponse.data并覆盖data/total构建(useList.ts),因此在getList返回了其他字段时,result也会一并透出,保持了灵活性。
类型参数(Type Parameters)
useList支持三个泛型参数,用于保障类型安全:
| 类型参数 | 说明 | 默认值 |
|---|---|---|
TQueryFnData | 查询函数返回的记录类型,需继承BaseRecord | BaseRecord |
TError | 自定义错误类型,需继承HttpError | HttpError |
TData | select处理后返回的记录类型,同样继承BaseRecord | TQueryFnData |
边界场景与最佳实践小结
- 默认分页是服务端模式:不传
pagination时默认{ currentPage: 1, pageSize: 10, mode: "server" },API 会收到_start/_end这类范围参数(simple-rest 风格)。 - 客户端分页不走网络:
mode: "client"时数据在本地切片,且pagination不会进入查询键,因此翻页不会触发新请求,适合数据量小、后端不支持分页的场景。 - total 的可靠性依赖提供器:
getList返回的total可能来自x-total-count响应头、GraphQL 的pageInfo.total,或退化为数组长度,编写自定义提供器时要保证格式统一为{ data, total }。 - 实时订阅与派生 Hook:
useList挂载即订阅resources/${resource}通道;useTable、useSelect、useInfiniteList等基于它实现,会继承相同的实时行为。 - 缓存键结构化:查询键为
["data", dataProviderName, resource, "list", { meta, filters, pagination?, sorters? }],善用 TanStack Query Devtools 观察它,有助于理解缓存失效与重新请求的时机。
延伸阅读
- Data Provider 与 getList 方法:了解
getList的参数、返回值与游标分页支持 - Refine 组件与 identifier:资源匹配键的完整语义
- 接口参考(CrudFilters / CrudSorting / BaseRecord / HttpError):过滤器、排序器与基础类型的字段定义
- Live Provider 文档:实时订阅的协议与派生 Hook 的订阅行为
- Notification Provider 文档:成功/失败通知的接入方式
- General Concepts 的 Meta 概念:meta 的三处来源与合并规则
- useList 单元测试:查询键、客户端切片等行为的可运行验证用例
【免费下载链接】refineA React Framework for building internal tools, admin panels, dashboards & B2B apps with unmatched flexibility.项目地址: https://gitcode.com/GitHub_Trending/re/refine
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考