无限查询

渲染可以“加载更多”数据到现有数据集或“无限滚动”的列表也是一种非常常见的 UI 模式。TanStack Query 为查询这类列表提供了一个实用的 useQuery 版本,称为 useInfiniteQuery

使用 useInfiniteQuery 时,你会注意到一些不同之处:

  • data 现在是一个包含无限查询数据的对象
  • data.pages 数组,包含已获取的页面
  • data.pageParams 数组,包含用于获取页面的页面参数
  • 现在提供了 fetchNextPagefetchPreviousPage 函数(fetchNextPage 是必需的)
  • initialPageParam 选项现在可用(且必需),用于指定初始页面参数
  • 提供了 getNextPageParamgetPreviousPageParam 选项,用于确定是否有更多数据可加载以及加载数据的相关信息。这些信息将作为附加参数提供给查询函数。
  • 现在提供了一个 hasNextPage 布尔值,如果 getNextPageParam 返回的值不是 nullundefined,则该值为 true
  • 现在提供了一个 hasPreviousPage 布尔值,如果 getPreviousPageParam 返回的值不是 nullundefined,则该值为 true
  • 现在提供了 isFetchingNextPageisFetchingPreviousPage 布尔值,以区分后台刷新状态和加载更多状态。

注意:initialDataplaceholderData 选项需要符合相同的结构,即一个具有 data.pagesdata.pageParams 属性的对象。

示例

假设我们有一个 API,它根据 cursor 索引每次返回 3 页 projects,以及一个可用于获取下一组项目的游标。

tsx
fetch('/api/projects?cursor=0')
// { data: [...], nextCursor: 3}
fetch('/api/projects?cursor=3')
// { data: [...], nextCursor: 6}
fetch('/api/projects?cursor=6')
// { data: [...], nextCursor: 9}
fetch('/api/projects?cursor=9')
// { data: [...] }
fetch('/api/projects?cursor=0')
// { data: [...], nextCursor: 3}
fetch('/api/projects?cursor=3')
// { data: [...], nextCursor: 6}
fetch('/api/projects?cursor=6')
// { data: [...], nextCursor: 9}
fetch('/api/projects?cursor=9')
// { data: [...] }

有了这些信息,我们可以通过以下方式创建一个“加载更多”UI:

  • 等待 useInfiniteQuery 默认请求第一组数据
  • getNextPageParam 中返回下一个查询的信息
  • 调用 fetchNextPage 函数
vue
<script setup>
import { useInfiniteQuery } from '@tanstack/vue-query'

const fetchProjects = async ({ pageParam = 0 }) => {
  const res = await fetch('/api/projects?cursor=' + pageParam)
  return res.json()
}

const {
  data,
  error,
  fetchNextPage,
  hasNextPage,
  isFetching,
  isFetchingNextPage,
  isPending,
  isError,
} = useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
})
</script>

<template>
  <span v-if="isPending">Loading...</span>
  <span v-else-if="isError">Error: {{ error.message }}</span>
  <div v-else-if="data">
    <span v-if="isFetching && !isFetchingNextPage">Fetching...</span>
    <ul v-for="(group, index) in data.pages" :key="index">
      <li v-for="project in group.projects" :key="project.id">
        {{ project.name }}
      </li>
    </ul>
    <button
      @click="() => fetchNextPage()"
      :disabled="!hasNextPage || isFetchingNextPage"
    >
      <span v-if="isFetchingNextPage">Loading more...</span>
      <span v-else-if="hasNextPage">Load More</span>
      <span v-else>Nothing more to load</span>
    </button>
  </div>
</template>
<script setup>
import { useInfiniteQuery } from '@tanstack/vue-query'

const fetchProjects = async ({ pageParam = 0 }) => {
  const res = await fetch('/api/projects?cursor=' + pageParam)
  return res.json()
}

const {
  data,
  error,
  fetchNextPage,
  hasNextPage,
  isFetching,
  isFetchingNextPage,
  isPending,
  isError,
} = useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
})
</script>

<template>
  <span v-if="isPending">Loading...</span>
  <span v-else-if="isError">Error: {{ error.message }}</span>
  <div v-else-if="data">
    <span v-if="isFetching && !isFetchingNextPage">Fetching...</span>
    <ul v-for="(group, index) in data.pages" :key="index">
      <li v-for="project in group.projects" :key="project.id">
        {{ project.name }}
      </li>
    </ul>
    <button
      @click="() => fetchNextPage()"
      :disabled="!hasNextPage || isFetchingNextPage"
    >
      <span v-if="isFetchingNextPage">Loading more...</span>
      <span v-else-if="hasNextPage">Load More</span>
      <span v-else>Nothing more to load</span>
    </button>
  </div>
</template>

理解在进行中的 fetch 过程中调用 fetchNextPage 有覆盖后台数据刷新的风险至关重要。当渲染列表并同时触发 fetchNextPage 时,这种情况尤其关键。

请记住,InfiniteQuery 只能有一个正在进行的 fetch。所有页面共享一个缓存条目,同时尝试两次 fetch 可能会导致数据覆盖。

如果您打算启用并行 fetch,可以在 fetchNextPage 中使用 { cancelRefetch: false } 选项(默认值为 true)。

为确保无冲突的无缝查询过程,强烈建议验证查询是否处于 isFetching 状态,特别是当用户不直接控制该调用时。

jsx
<List onEndReached={() => hasNextPage && !isFetching && fetchNextPage()} />
<List onEndReached={() => hasNextPage && !isFetching && fetchNextPage()} />

当无限查询需要重新获取时会发生什么?

当一个无限查询变得 stale 并需要重新获取时,每个组将 sequentially(顺序地)获取,从第一个开始。这确保了即使底层数据被修改,我们也不会使用陈旧的游标并可能导致重复或跳过记录。如果一个无限查询的结果从 queryCache 中被移除,分页将从初始状态重新开始,只请求初始组。

如果我想实现一个双向无限列表怎么办?

可以通过使用 getPreviousPageParamfetchPreviousPagehasPreviousPageisFetchingPreviousPage 属性和函数来实现双向列表。

tsx
useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  initialPageParam: 0,
  getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
  getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
})
useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  initialPageParam: 0,
  getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
  getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
})

如果我想以相反的顺序显示页面怎么办?

有时你可能希望以相反的顺序显示页面。如果是这种情况,你可以使用 select 选项。

tsx
useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  select: (data) => ({
    pages: [...data.pages].reverse(),
    pageParams: [...data.pageParams].reverse(),
  }),
})
useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  select: (data) => ({
    pages: [...data.pages].reverse(),
    pageParams: [...data.pageParams].reverse(),
  }),
})

如果我想手动更新无限查询怎么办?

手动移除第一页:

tsx
queryClient.setQueryData(['projects'], (data) => ({
  pages: data.pages.slice(1),
  pageParams: data.pageParams.slice(1),
}))
queryClient.setQueryData(['projects'], (data) => ({
  pages: data.pages.slice(1),
  pageParams: data.pageParams.slice(1),
}))

手动从单个页面中删除单个值

tsx
const newPagesArray =
  oldPagesArray?.pages.map((page) =>
    page.filter((val) => val.id !== updatedId),
  ) ?? []

queryClient.setQueryData(['projects'], (data) => ({
  pages: newPagesArray,
  pageParams: data.pageParams,
}))
const newPagesArray =
  oldPagesArray?.pages.map((page) =>
    page.filter((val) => val.id !== updatedId),
  ) ?? []

queryClient.setQueryData(['projects'], (data) => ({
  pages: newPagesArray,
  pageParams: data.pageParams,
}))

只保留第一页

tsx
queryClient.setQueryData(['projects'], (data) => ({
  pages: data.pages.slice(0, 1),
  pageParams: data.pageParams.slice(0, 1),
}))
queryClient.setQueryData(['projects'], (data) => ({
  pages: data.pages.slice(0, 1),
  pageParams: data.pageParams.slice(0, 1),
}))

请务必始终保持页面和页面参数的相同数据结构!

如果我想限制页面数量怎么办?

在某些用例中,你可能希望限制查询数据中存储的页面数量,以提高性能和用户体验

  • 当用户可以加载大量页面时(内存使用)
  • 当您必须重新获取包含数十页的无限查询时(网络使用:所有页面都按顺序获取)

解决方案是使用“有限的无限查询”。通过使用 maxPages 选项,并结合 getNextPageParamgetPreviousPageParam,可以实现在两个方向上按需获取页面。

在以下示例中,查询数据页面数组中只保留 3 个页面。如果需要重新获取,将只按顺序重新获取 3 个页面。

tsx
useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  initialPageParam: 0,
  getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
  getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
  maxPages: 3,
})
useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  initialPageParam: 0,
  getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
  getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
  maxPages: 3,
})

如果我的 API 不返回游标怎么办?

如果您的 API 不返回游标,您可以使用 pageParam 作为游标。因为 getNextPageParamgetPreviousPageParam 也会获取当前页的 pageParam,您可以使用它来计算下一个/上一个 pageParam。

tsx
return useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  initialPageParam: 0,
  getNextPageParam: (lastPage, allPages, lastPageParam) => {
    if (lastPage.length === 0) {
      return undefined
    }
    return lastPageParam + 1
  },
  getPreviousPageParam: (firstPage, allPages, firstPageParam) => {
    if (firstPageParam <= 1) {
      return undefined
    }
    return firstPageParam - 1
  },
})
return useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  initialPageParam: 0,
  getNextPageParam: (lastPage, allPages, lastPageParam) => {
    if (lastPage.length === 0) {
      return undefined
    }
    return lastPageParam + 1
  },
  getPreviousPageParam: (firstPage, allPages, firstPageParam) => {
    if (firstPageParam <= 1) {
      return undefined
    }
    return firstPageParam - 1
  },
})

延伸阅读

要更好地理解无限查询的底层工作原理,请阅读社区资源中的 “无限查询的工作原理”