如何使用 React Query 做下拉數(shù)據(jù)自動(dòng)刷新?
useInfiniteQuery() API
看名字就能猜出來(lái),useInfiniteQuery() 是專門用來(lái)應(yīng)付無(wú)限查詢場(chǎng)景的。不僅如此,useInfiniteQuery() API 能力也是基于 useQuery() 的。
之前的文章中我們介紹了 useQuery() 的核心 API,為了找回印象,我們?cè)诖速N出來(lái):
import { useQuery } from 'react-query'
const {
data,
error,
isError,
isFetching,
isLoading,
isRefetching,
isSuccess,
refetch,
} = useQuery(queryKey, queryFn?, {
enabled,
onError,
onSuccess,
refetchOnWindowFocus,
retry,
staleTime,
})
如果我們把這些 API 簡(jiǎn)化如下:
const {
...result,
} = useQuery(queryKey, queryFn?, {
...options,
})
useInfiniteQuery() 其實(shí)就是在 useQuery() 基礎(chǔ)之上增添一些無(wú)限查詢場(chǎng)景的參數(shù):
const {
fetchNextPage,
fetchPreviousPage,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
...result
} = useInfiniteQuery(queryKey, ({ pageParam = 1 }) => fetchPage(pageParam), {
...options,
getNextPageParam: (lastPage, allPages) => lastPage.nextCursor,
getPreviousPageParam: (firstPage, allPages) => firstPage.prevCursor,
})
如你所見(jiàn),增加的 API 其實(shí)就是跟上一頁(yè)/下一頁(yè)查詢動(dòng)作相關(guān)的參數(shù),相比較于自己組裝 的分頁(yè)查詢能力的 useQuery(),useInfiniteQuery() 需要配置上一頁(yè)/下一頁(yè)的參數(shù)獲取函數(shù),并提供了相應(yīng)的查詢調(diào)用能力,更加自動(dòng)化和便捷。
當(dāng)然,增加的不只是參數(shù),還有 2 處:
一個(gè)是 queryFn 參數(shù)的入?yún)?,多了一個(gè)名為 pageParam 的參數(shù)。
pageParam 表示當(dāng)前頁(yè)數(shù)。這個(gè)值是每次 useInfiniteQuery() 調(diào)用時(shí),通過(guò) getNextPageParam()/getPreviousPageParam() 返回值自動(dòng)獲取并傳入 queryFn 的。
第二個(gè)還有返回值的數(shù)據(jù)結(jié)構(gòu),即 data。
const { data } = useInfiniteQuery()
原來(lái) data 就是表示內(nèi)部請(qǐng)求方法的返回值。而 useInfiniteQuery() 的返回 data 因?yàn)橐囗?yè)數(shù)據(jù)(展示舊數(shù)據(jù)時(shí),還要持有舊數(shù)據(jù)),因此 data 變更為:
data: { pages: TData[], pageParams: unknown[] }
pages 很好理解,就是用來(lái)承載過(guò)程中請(qǐng)求到的多頁(yè)數(shù)據(jù);pageParams 則是每個(gè)頁(yè)面當(dāng)時(shí)在做數(shù)據(jù)獲取時(shí)使用的查詢參數(shù)。
簡(jiǎn)單一例
當(dāng)然語(yǔ)言上說(shuō)得再多,也是蒼白無(wú)力的,實(shí)踐出真知。這里我們就舉一個(gè)簡(jiǎn)單的例子說(shuō)明 useInfiniteQuery() 的使用。
首先,我們先創(chuàng)建一個(gè)獲取數(shù)據(jù)的請(qǐng)求函數(shù)(使用 Fetch API)。
const getPosts = async (pageParam) => {
return fetch(`https://jsonplaceholder.typicode.com/posts?_page=${pageParam.page}&_limit=${pageParam.size}`).then(res => res.json())
}
接著,使用 useInfiniteQuery() 請(qǐng)求數(shù)據(jù):
function Example() {
const {
isLoading,
isError,
error,
data,
} = useInfiniteQuery(
'posts',
({ pageParam }) => getPosts(pageParam),
{
getNextPageParam: (lastPage, allPages) => ({ page: allPages.length + 1, size: 6 }),
refetchOnWindowFocus: false, // Prevent refetching on window focus
}
)
// ...
}
增加下加載中或出現(xiàn)異常時(shí)的處理邏輯。
function Example() {
// ...
if (isLoading) {
return <div>Loading...</div>;
}
if (isError) {
return <div>Error: {error.message}</div>
}
// ...
}
最后渲染分頁(yè)數(shù)據(jù)。
function Example() {
// ...
return (
<div>
<ol>
{/* (1) */}
{data.pages.map((page) => (
{page.map((post) => (
<li key={post.id}>{post.title}</li>
))}
))}
</ol>
{/* (2) */}
<button onClick={() => fetchNextPage()}>More</button>
</div>
)
}
- 遍歷 data.pages 中所有頁(yè)面數(shù)據(jù),渲染出來(lái)
- 使用 fetchNextPage() 函數(shù)加載更多(實(shí)際上即“下一頁(yè)”)數(shù)據(jù)
瀏覽器訪問(wèn),不幸運(yùn)是,報(bào)錯(cuò)了。
圖片
沒(méi)關(guān)系,點(diǎn)擊查看報(bào)錯(cuò)處。
圖片
發(fā)現(xiàn)是是由于首次請(qǐng)求接口時(shí),pageParam 參數(shù)是 undefined,這就需要我們給 pageParam 一個(gè)默認(rèn)值。
- const getPosts = async (pageParam) => {
+ const getPosts = async (pageParam = { page: 1, size: 6 }) => {
return fetch(`https://jsonplaceholder.typicode.com/posts?_page=${pageParam.page}&_limit=${pageParam.size}`).then(res => res.json())
}
再次訪問(wèn)頁(yè)面,發(fā)現(xiàn)我們成功請(qǐng)求到了第一頁(yè)數(shù)據(jù)。
圖片
接下來(lái)點(diǎn)擊“More”按鈕,發(fā)現(xiàn)發(fā)起了到第二頁(yè)數(shù)據(jù)的請(qǐng)求。
圖片
圖片
這里邏輯是先調(diào)用 getNextPageParam() 函數(shù)獲得請(qǐng)求參數(shù)。getNextPageParam() 函數(shù)的類型聲明如下:
getNextPageParam: (lastPage, allPages) => unknown | undefined
第一個(gè)參數(shù)就是上一頁(yè)數(shù)據(jù),第二個(gè)參數(shù)即到目前為止請(qǐng)求到的所有數(shù)據(jù)。對(duì)照本例,當(dāng)我們第一次點(diǎn)擊“More”按鈕時(shí),返回的值為 { page:2, size:6 }。然后,再發(fā)起 getPosts 調(diào)用并這個(gè)參數(shù),于是我們便發(fā)起了第二頁(yè)的數(shù)據(jù)請(qǐng)求,并最終得到了數(shù)據(jù)。
下面,我們?cè)俳o“More”按鈕增加一個(gè)加載狀態(tài)——我們可以使用通用的 isFetching 狀態(tài),但這里我們使用更具體的 isFetchingNextPage 狀態(tài)。
function Example() {
const {
isLoading,
+ isFetchingNextPage,
isError,
error,
data,
} = useInfiniteQuery()
添加給按鈕。
- <button notallow={() => fetchNextPage()}>More</button>
+ <button disabled={isFetchingNextPage} notallow={() => fetchNextPage()}>More</button>
查看效果。
圖片
這樣,我們就能在請(qǐng)求下一頁(yè)數(shù)據(jù)的時(shí)候禁用按鈕點(diǎn)擊,避免用戶誤觸。
再來(lái)一例
以上案例中,我們是通過(guò) getNextPageParam() 所提供的第二個(gè)參數(shù) allPages 來(lái)獲得下一頁(yè)頁(yè)碼的。
getNextPageParam: (lastPage, allPages) => ({ page: allPages.length + 1, size: 6 })
這是因?yàn)榉祷氐牡谝豁?yè)數(shù)據(jù)總并未包含下一頁(yè)信息。
圖片
我們?cè)偕晕⑿薷南?/p>
const getPosts = async (pageParam = { page: 1, size: 6 }) => {
return fetch(`https://jsonplaceholder.typicode.com/posts?_page=${pageParam.page}&_limit=${pageParam.size}`).then(res => {
const total = res.headers.get('X-Total-Count')
return res.json().then(data => {
return {
total,
data,
hasMore: pageParam.page * pageParam.size < total
}
})
})
}
我們將返回?cái)?shù)據(jù)放在 data 屬性中,并返回?cái)?shù)據(jù)總數(shù)(total)以及是否還有數(shù)據(jù)(hasMore)。
有了這些信息,我們就可以有了更加細(xì)膩的交互了。
下面,修改渲染邏輯。
function Example() {
// ...
return (
<div>
<ol>
{data.pages.map((page) => (
<>
{page.data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</>
))}
</ol>
<p>總共 <strong>{data.pages[0].total}</strong> 條數(shù)據(jù)</p>
{
data.pages[data.pages.length - 1].hasMore ? (
<button disabled={isFetchingNextPage} onClick={() => fetchNextPage()}>More</button>
) : <span>--- 我是有底線的 ---</span>
}
</div>
)
}
查看初始加載效果。
圖片
查看最終效果。
圖片
不過(guò),按鈕顯隱邏輯還是有些冗余。
{
data.pages[data.pages.length - 1].hasMore ? (
<button disabled={isFetchingNextPage} onClick={() => fetchNextPage()}>More</button>
) : <span>--- 我是有底線的 ---</span>
}
這一點(diǎn) useInfiniteQuery 也幫我們想到了,便提供了一個(gè) hasNextPage 供我們使用。
{
- data.pages[data.pages.length - 1].hasMore ? (
+ hasNextPage ? (
<button disabled={isFetchingNextPage} notallow={() => fetchNextPage()}>More</button>
) : <span>--- 我是有底線的 ---</span>
}
hasNextPage 為 true 表示有下一頁(yè)數(shù)據(jù),為 false 表示沒(méi)有數(shù)據(jù)了。
不過(guò) hasNextPage 的值是受到 getNextPageParam() 返回值影響的——當(dāng) getNextPageParam() 返回 undefined 時(shí),hasNextPage 值為 false,否則為 true。
因此,我們修改下 getNextPageParam() 的判斷邏輯。
getNextPageParam: (lastPage, allPages) => {
return lastPage.hasMore
? { page: allPages.length + 1, size: 50 }
: undefined
},
如此,我們?cè)賮?lái)查看下效果。
圖片
發(fā)現(xiàn)沒(méi)有數(shù)據(jù)的時(shí)候,同樣也不會(huì)展示“More”按鈕了。大功告成!
下拉拉取新數(shù)據(jù)
當(dāng)然,以上通過(guò)點(diǎn)擊“More”按鈕加載新數(shù)據(jù)的方式還是太麻煩了一些。如果我們?cè)诰W(wǎng)頁(yè)下拉到底部的時(shí)候自動(dòng)獲取數(shù)據(jù)不是更好嗎?
下面就來(lái)實(shí)現(xiàn)一下。
我們將借助 IntersectionObserver API[4] + 底部一個(gè) .loadMore 元素來(lái)實(shí)現(xiàn)。
首先,將 “More” 按鈕部分的代碼替換成:
<div className="loadMore" style={{ height: '30px', lineHeight: '30px' }} ref={loadMoreRef}>
{
isFetchingNextPage
? <span>Loading...</span>
: <span>--- 我是有底線的 ---</span>
}
</div>
注意,我們同時(shí)通過(guò) loadMoreRef 拿到 DOM 元素。
然后,我們監(jiān)聽(tīng) .loadMore 元素,一旦出現(xiàn)在屏幕中,就調(diào)用 fetchNextPage() 獲取下一頁(yè)數(shù)據(jù)。
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasNextPage) {
fetchNextPage();
}
});
if (loadMoreRef.current) {
observer.observe(loadMoreRef.current);
}
return () => observer.disconnect();
}, [hasNextPage, fetchNextPage]);
來(lái)查看效果。
圖片
完美。
最后,再把完整代碼貼出來(lái),方便大家學(xué)習(xí)。
import { useEffect, useRef } from 'react'
import { QueryClient, QueryClientProvider, useInfiniteQuery } from 'react-query'
// Create a client
const queryClient = new QueryClient()
export default function App() {
return (
// Provide the client to your App
<QueryClientProvider client={queryClient}>
<Example />
</QueryClientProvider>
)
}
const getPosts = async (pageParam = { page: 1, size: 25 }) => {
return fetch(`https://jsonplaceholder.typicode.com/posts?_page=${pageParam.page}&_limit=${pageParam.size}`).then(res => {
const total = res.headers.get('X-Total-Count')
return res.json().then(data => {
return {
total,
data,
hasMore: pageParam.page * pageParam.size < total
}
})
})
}
function Example() {
const {
isLoading,
isFetchingNextPage,
hasNextPage,
isError,
error,
data,
fetchNextPage
} = useInfiniteQuery(
'posts',
({ pageParam }) => getPosts(pageParam),
{
getNextPageParam: (lastPage, allPages) => {
return lastPage.hasMore ? { page: allPages.length + 1, size: 25 } : undefined
},
refetchOnWindowFocus: false, // Prevent refetching on window focus
}
)
const loadMoreRef = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasNextPage) {
fetchNextPage();
}
});
if (loadMoreRef.current) {
observer.observe(loadMoreRef.current);
}
return () => observer.disconnect();
}, [hasNextPage, fetchNextPage]);
if (isLoading) {
return <div>Loading...</div>;
}
if (isError) {
return <div>Error: {error.message}</div>
}
return (
<div>
<p>總共 <strong>{data.pages[0].total}</strong> 條數(shù)據(jù)</p>
<ol>
{data.pages.map((page) => (
<>
{page.data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</>
))}
</ol>
<div className="loadMore" style={{ height: '30px', lineHeight: '30px' }} ref={loadMoreRef}>
{
isFetchingNextPage ? <span>Loading...</span> : <span>--- 我是有底線的 ---</span>
}
</div>
</div>
)
}
總結(jié)
本文我們講述了 React Query 中用于無(wú)限查詢 API useInfiniteQuery() 的使用。
通過(guò)循序漸進(jìn)的 3 個(gè)案例,最終實(shí)現(xiàn)了一個(gè)下拉到底后自動(dòng)新數(shù)據(jù)的交互效果,還是比較好實(shí)現(xiàn)的。
當(dāng)然,本文只是以“下一頁(yè)”舉例,“上一頁(yè)”與此同理。
希望本位講述的內(nèi)容能夠?qū)δ愕墓ぷ饔兴鶐椭?。感謝閱讀,再見(jiàn)。
參考資料
[1]React Query 是做什么的?: https://juejin.cn/post/7378015213348257855
[2]一個(gè)數(shù)據(jù)獲竟然被 React Query 玩出這么多花樣來(lái)!: https://juejin.cn/post/7380342160581918731
[3]React Query 的 useQuery 竟也內(nèi)置了分頁(yè)查詢支持!: https://juejin.cn/post/7380569775686746151
[4]IntersectionObserver API: https://ruanyifeng.com/blog/2016/11/intersectionobserver_api.html