今天繼續介紹 infinite scroll 功能。
還記得我們 posts 資料是透過 getPosts 拿的,現在我們要來改寫 getPosts 內容讓他變成可以有 paginaction 的 api。
export default function Home() {
const utils = api.useContext()
const { data: posts, isLoading, isError, error } = api.posts.getPosts.useQuery(undefined, {
trpc: {
context: {
skipBatch: true,
}
}
})
再開始寫 api 前先簡單介紹一下常見的 pagination 種類:
在 prisma 中要實現 Offset pagination 很簡單只要使用 skip() 跟 limit() 。
透過 skip 跳過前幾筆資料,然後搭配 take 拿取資料長度完成範圍的查詢。
const results = await prisma.post.findMany({
skip: 3,
take: 4,
})

如果要實現 pagination 功能只需要將 skip * 每頁顯示總數就好。
這樣就是第三頁顯示四筆資料結果。
const results = await prisma.post.findMany({
skip: 3 * 4,
take: 4,
})
Offset pagination 優點user 的 filter 排序順序對應到相同的內容。例如資料根據 title 降冪排序,同時塞選 email 中包含 Prisma 內容,如果是用 Offset pagination 每次的紀錄查詢都會是固定結果。
const results = await prisma.post.findMany({
skip: 200,
take: 20,
where: {
email: {
contains: 'Prisma',
},
},
orderBy: {
title: 'desc',
},
})
Cursor-based pagination 差,假設你要 skip 20000 筆資料,這次的 query 查詢都會遍歷前 20000 的結果。Cursor-based pagination 是基於Offset pagination 的延伸,有點就是書籤的概念透過 cursor 每次查詢添加書籤位置,讓一下次的查詢可以根據 cursor 位子繼續查詢,好處就是可以避免 Offset pagination 中遍歷問題。
當使用 Cursor-based 每次查詢都會返回 lastId 當作你下一次的 cursor 。
const secondQueryResults = await prisma.post.findMany({
take: 4,
skip: 1, // Skip the cursor
cursor: {
id: myCursor,
},
where: {
title: {
contains: 'Prisma' /* Optional filter */,
},
},
orderBy: {
id: 'asc',
},
})
const lastPostInResults = secondQueryResults[3] // Remember: zero-based index! :)
const myCursor = lastPostInResults.id // Example: 52
這樣結果就是 cursor id 為 29 的紀錄之後的 post 前 4 筆資料。
cursor 去記錄每次的查詢結果,讓SQL 底層不需要使用 offset 一次又一次的遍歷,加快查詢結果。page 跳轉到指定的頁面,假設你要請求 40頁的內容,你需要先請求 1 - 39頁的資料。先定義 input schema
export const getInfinitePostSchema = z.object({
limit: z.number().min(1).max(100).nullable(),
where: z.object({
content: z.string().optional(),
title: z.string().optional()
}).optional(),
cursor: z.number().nullish()
})
cursor ,並 orderBy postId 做降冪排序。input 塞選 posts 是否包含 title 或是 content 結果。take: limit + 1 每次選查詢數量。cursor: cursor ? { id: cursor } : undefined 第一次查詢因為沒有 cursor 所以是 undefinded。posts.length >= limit 如果查詢結果大於 limit 代表還有往後還有資料,所以就拿取 posts 最後的 id 當作下一次的 cursor。export const postsRouter = router({
infinitePosts: publicProcedure
.input(getInfinitePostSchema)
.query(async ({ input, ctx }) => {
const { cursor, where } = input
const { prisma } = ctx
const limit = input.limit ?? 50
const posts = await prisma.post.findMany({
where: {
title: {
contains: where?.title
},
content: {
contains: where?.content
},
},
orderBy: {
id: 'desc'
},
take: limit + 1,
cursor: cursor ? { id: cursor } : undefined
})
let nextCursor: number | undefined
if (posts.length >= limit) {
nextCursor = posts.pop()?.id
}
return {
posts,
nextCursor
}
}),
client 部分是透過 useInfiniteQuery 去完成所有事情,主要兩個部分。
limit : 對應到 infinitePosts 中的 input,用來定義資料返回數量。
getNextPageParam :
useInfiniteQuery 拿到資料時會返回最後一次查詢的結果 (lastPage) 對應我們在 infinitePosts 中 return 得內容。export const postsRouter = router({
infinitePosts: publicProcedure
.input(getInfinitePostSchema)
.query(async ({ input, ctx }) => {
//..
return {
posts,
nextCursor
}
}),
2.他會 return 一個結果用於下一次查詢的變數,這邊也就是 input 的cursor ,
export const postsRouter = router({
infinitePosts: publicProcedure
.input(getInfinitePostSchema)
.query(async ({ input, ctx }) => {
const { cursor, where } = input
//..
return {
posts,
nextCursor
}
}),
getNextPageParam return 是 undefinded,則 hasNextPage 為 false ,fetchNextPage 則不能執行。const { data, isLoading, isError, error, fetchNextPage, hasNextPage } = api.posts.infinitePosts.useInfiniteQuery(
{
limit: 10,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor
}
)
這邊第一次看到這樣寫法可能很多小夥伴對於 input 來源感到疑惑,但這邊簡單說明,在 trpc 中預設是透過 getNextPageParam 傳 cursor 到 input 中,其餘的 input 則是透過 useInfiniteQuery 的第一個參數傳進去。
從 response 結果來看 data 是在很深層的位子。

所以透過 flatMap 的方式把我們需要的資料來出來。
const posts = data?.pages.flatMap(page => page.posts) || []
透過 hasNextPage 去判斷是否沒有額外資料。
//..
<ul className="flex flex-col gap-[1rem] justify-center mt-5" >
{posts.map((post, index) => (
<li
key={post.id}
className="flex items-center justify-between cursor-pointer"
>
<label
htmlFor=""
className={`
text-2xl
${!!post.published && "line-through"}
`}
onClick={async () => {
await togglePost({ id: post.id, published: !post.published })
}}
>{post.title}</label>
<div className="flex items-center gap-[1rem]">
<AiFillDelete
color="red"
className="cursor-pointer"
size={20}
onClick={async () => {
await deletePost({ id: post.id })
}}
/>
<IoIosArchive
onClick={() => router.push(`/posts/${post.id}`)}
/>
</div>
</li>
))}
</ul>
{!hasNextPage && <p className="text-gray-500 py-2 text-center">no more data</p>}
..//
最後附上畫面

但我們要怎麼觸發 fetchNextPage 呢,這邊我使用 react-intersection-observer 去做,在做 infinite scroll 功能時候通常會搭配 intersection observer ,但這邊不多做說明可以簡單用套件去完成就好,想興趣的小夥伴可以選擇自己習慣的用法~
手用方很簡單只需要將 ref 放到你要監聽的 div 就好,inView 就會幫你判斷是否監聽的div 出現在畫面中。
import { useInView } from 'react-intersection-observer';
const { ref, inView, entry } = useInView({
/* Optional options */
threshold: 0,
});
然後放入 div
//..
<ul className="flex flex-col gap-[1rem] justify-center mt-5" >
{posts.map((post, index) => (
//..
))}
</ul>
{!hasNextPage && <p className="text-gray-500 py-2 text-center">no more data</p>}
<div ref={ref} className="invisible"></div>
..//
做後根據 inView 結果判斷是否要fetchNextPage
useEffect(() => {
if (!inView) return
fetchNextPage()
}, [inView])
這樣就完成摟~
大致上 infinite scroll 功能差不多這樣就完成了,剩下一些優化部分我們明天繼續介紹~
https://github.com/Danny101201/next_demo/tree/main
✅ 前端社群 :
https://lihi3.cc/kBe0Y