能夠以增量方式「載入更多」資料到現有資料集或實現「無限滾動」的列表渲染,是非常常見的 UI 模式。TanStack Query 支援一個名為 injectInfiniteQuery 的 injectQuery 變體,專門用於查詢這類型的列表。
使用 injectInfiniteQuery 時,你會注意到一些不同之處:
注意:選項 initialData 或 placeholderData 需要符合具有 data.pages 和 data.pageParams 屬性的物件結構。
假設我們有一個 API,基於 cursor 索引每次返回 3 個 projects 頁面,以及可用於獲取下一組專案的游標:
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:
import { Component, computed, inject } from '@angular/core'
import { injectInfiniteQuery } from '@tanstack/angular-query-experimental'
import { lastValueFrom } from 'rxjs'
import { ProjectsService } from './projects-service'
@Component({
selector: 'example',
templateUrl: './example.component.html',
})
export class Example {
projectsService = inject(ProjectsService)
query = injectInfiniteQuery(() => ({
queryKey: ['projects'],
queryFn: async ({ pageParam }) => {
return lastValueFrom(this.projectsService.getProjects(pageParam))
},
initialPageParam: 0,
getPreviousPageParam: (firstPage) => firstPage.previousId ?? undefined,
getNextPageParam: (lastPage) => lastPage.nextId ?? undefined,
maxPages: 3,
}))
nextButtonDisabled = computed(
() => !this.#hasNextPage() || this.#isFetchingNextPage(),
)
nextButtonText = computed(() =>
this.#isFetchingNextPage()
? '載入更多中...'
: this.#hasNextPage()
? '載入新資料'
: '已無更多資料',
)
#hasNextPage = this.query.hasNextPage
#isFetchingNextPage = this.query.isFetchingNextPage
}
import { Component, computed, inject } from '@angular/core'
import { injectInfiniteQuery } from '@tanstack/angular-query-experimental'
import { lastValueFrom } from 'rxjs'
import { ProjectsService } from './projects-service'
@Component({
selector: 'example',
templateUrl: './example.component.html',
})
export class Example {
projectsService = inject(ProjectsService)
query = injectInfiniteQuery(() => ({
queryKey: ['projects'],
queryFn: async ({ pageParam }) => {
return lastValueFrom(this.projectsService.getProjects(pageParam))
},
initialPageParam: 0,
getPreviousPageParam: (firstPage) => firstPage.previousId ?? undefined,
getNextPageParam: (lastPage) => lastPage.nextId ?? undefined,
maxPages: 3,
}))
nextButtonDisabled = computed(
() => !this.#hasNextPage() || this.#isFetchingNextPage(),
)
nextButtonText = computed(() =>
this.#isFetchingNextPage()
? '載入更多中...'
: this.#hasNextPage()
? '載入新資料'
: '已無更多資料',
)
#hasNextPage = this.query.hasNextPage
#isFetchingNextPage = this.query.isFetchingNextPage
}
<div>
@if (query.isPending()) {
<p>載入中...</p>
} @else if (query.isError()) {
<span>錯誤: {{ query?.error().message }}</span>
} @else { @for (page of query?.data().pages; track $index) { @for (project of
page.data; track project.id) {
<p>{{ project.name }} {{ project.id }}</p>
} }
<div>
<button (click)="query.fetchNextPage()" [disabled]="nextButtonDisabled()">
{{ nextButtonText() }}
</button>
</div>
}
</div>
<div>
@if (query.isPending()) {
<p>載入中...</p>
} @else if (query.isError()) {
<span>錯誤: {{ query?.error().message }}</span>
} @else { @for (page of query?.data().pages; track $index) { @for (project of
page.data; track project.id) {
<p>{{ project.name }} {{ project.id }}</p>
} }
<div>
<button (click)="query.fetchNextPage()" [disabled]="nextButtonDisabled()">
{{ nextButtonText() }}
</button>
</div>
}
</div>
必須理解的是,在進行中的 fetch 期間呼叫 fetchNextPage 有可能會覆蓋正在背景進行的資料刷新。當同時渲染列表和觸發 fetchNextPage 時,這種情況變得特別關鍵。
請記住,對於 InfiniteQuery 只能有一個進行中的 fetch。所有頁面共享單一快取條目,嘗試同時進行兩次 fetch 可能會導致資料覆寫。
如果你想啟用同時 fetch,可以在 fetchNextPage 中使用 { cancelRefetch: false } 選項(預設為 true)。
為了確保查詢過程無衝突,強烈建議檢查查詢是否處於 isFetching 狀態,特別是當使用者不會直接控制該呼叫時。
@Component({
template: ` <list-component (endReached)="fetchNextPage()" /> `,
})
export class Example {
query = injectInfiniteQuery(() => ({
queryKey: ['projects'],
queryFn: async ({ pageParam }) => {
return lastValueFrom(this.projectsService.getProjects(pageParam))
},
}))
fetchNextPage() {
// 如果已在獲取中,則不執行任何操作
if (this.query.isFetching()) return
this.query.fetchNextPage()
}
}
@Component({
template: ` <list-component (endReached)="fetchNextPage()" /> `,
})
export class Example {
query = injectInfiniteQuery(() => ({
queryKey: ['projects'],
queryFn: async ({ pageParam }) => {
return lastValueFrom(this.projectsService.getProjects(pageParam))
},
}))
fetchNextPage() {
// 如果已在獲取中,則不執行任何操作
if (this.query.isFetching()) return
this.query.fetchNextPage()
}
}
當無限查詢變為 stale 並需要重新獲取時,每組資料會從第一組開始「依序」獲取。這確保即使基礎資料發生變更,我們也不會使用過時的游標,從而可能導致重複或跳過記錄。如果無限查詢的結果從 queryCache 中移除,分頁將從初始狀態重新開始,僅請求初始組。
雙向列表可以通過使用 getPreviousPageParam、fetchPreviousPage、hasPreviousPage 和 isFetchingPreviousPage 屬性和函式來實現。
query = injectInfiniteQuery(() => ({
queryKey: ['projects'],
queryFn: fetchProjects,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
}))
query = injectInfiniteQuery(() => ({
queryKey: ['projects'],
queryFn: fetchProjects,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
}))
有時你可能想以相反順序顯示頁面。如果是這種情況,可以使用 select 選項:
query = injectInfiniteQuery(() => ({
queryKey: ['projects'],
queryFn: fetchProjects,
select: (data) => ({
pages: [...data.pages].reverse(),
pageParams: [...data.pageParams].reverse(),
}),
}))
query = injectInfiniteQuery(() => ({
queryKey: ['projects'],
queryFn: fetchProjects,
select: (data) => ({
pages: [...data.pages].reverse(),
pageParams: [...data.pageParams].reverse(),
}),
}))
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),
}))
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,
}))
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),
}))
確保始終保持 pages 和 pageParams 的相同資料結構!
在某些使用情境下,你可能想限制查詢資料中儲存的頁面數量以提高效能和使用者體驗:
解決方案是使用「有限無限查詢」。這可以通過將 maxPages 選項與 getNextPageParam 和 getPreviousPageParam 結合使用來實現,以便在需要時雙向獲取頁面。
在以下範例中,查詢資料 pages 陣列中僅保留 3 頁。如果需要重新獲取,將僅依序重新獲取 3 頁。
injectInfiniteQuery(() => ({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
maxPages: 3,
}))
injectInfiniteQuery(() => ({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
maxPages: 3,
}))
如果你的 API 不返回游標,可以使用 pageParam 作為游標。因為 getNextPageParam 和 getPreviousPageParam 也會獲取當前頁面的 pageParam,所以可以用它來計算下一個/上一個頁面參數。
injectInfiniteQuery(() => ({
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
},
}))
injectInfiniteQuery(() => ({
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
},
}))