iT邦幫忙

2023 iThome 鐵人賽

DAY 1
0
Modern Web

一些讓你看來很強的全端- trcp 伴讀系列 第 15

Day-015. 一些讓你看來很強的全端 TRPC 伴讀 -InfiniteQuery (中)

  • 分享至 

  • xImage
  •  

今天繼續介紹 infinite scroll 功能。

API

還記得我們 posts 資料是透過 getPosts 拿的,現在我們要來改寫 getPosts 內容讓他變成可以有 paginactionapi

export default function Home() {
 
  const utils = api.useContext()
  const { data: posts, isLoading, isError, error } = api.posts.getPosts.useQuery(undefined, {
    trpc: {
      context: {
        skipBatch: true,
      }
    }
  })

pagination strategy

再開始寫 api 前先簡單介紹一下常見的 pagination 種類:

  1. Offset pagination
  2. Cursor-based pagination

Offset 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 優點

  1. 可以快速跳轉到指定頁面的資料。
  2. 可以根據 userfilter 排序順序對應到相同的內容。

例如資料根據 title 降冪排序,同時塞選 email 中包含 Prisma 內容,如果是用 Offset pagination 每次的紀錄查詢都會是固定結果。

const results = await prisma.post.findMany({
  skip: 200,
  take: 20,
  where: {
    email: {
      contains: 'Prisma',
    },
  },
  orderBy: {
    title: 'desc',
  },
})

Offset pagination 缺點

  1. 效能上會比 Cursor-based pagination 差,假設你要 skip 20000 筆資料,這次的 query 查詢都會遍歷前 20000 的結果。
  2. 比較適合 db 量級不大的應用。

Cursor-based pagination

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-based pagination 優點

  1. 因為透過 cursor 去記錄每次的查詢結果,讓SQL 底層不需要使用 offset 一次又一次的遍歷,加快查詢結果。

Cursor-based pagination 缺點

  1. 無法透過 page 跳轉到指定的頁面,假設你要請求 40頁的內容,你需要先請求 1 - 39頁的資料。
  2. 比較適合做無限滾輪的內容或是需要針對長期追蹤的資料導出一部分。

實作

先定義 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()
})
  1. 指定 postId 作為 cursor ,並 orderBy postId 做降冪排序。
  2. 根據 input 塞選 posts 是否包含 title 或是 content 結果。
  3. take: limit + 1 每次選查詢數量。
  4. cursor: cursor ? { id: cursor } : undefined 第一次查詢因為沒有 cursor 所以是 undefinded
  5. 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

client 部分是透過 useInfiniteQuery 去完成所有事情,主要兩個部分。

limit : 對應到 infinitePosts 中的 input,用來定義資料返回數量。

getNextPageParam :

  1. 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
      }
    }),
  1. 如果 getNextPageParam return 是 undefinded,則 hasNextPagefalsefetchNextPage 則不能執行。
const { data, isLoading, isError, error, fetchNextPage, hasNextPage } = api.posts.infinitePosts.useInfiniteQuery(
    {
      limit: 10,
    },
    {
      getNextPageParam: (lastPage) => lastPage.nextCursor
    }
  )

這邊第一次看到這樣寫法可能很多小夥伴對於 input 來源感到疑惑,但這邊簡單說明,在 trpc 中預設是透過 getNextPageParamcursorinput 中,其餘的 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


上一篇
Day-014. 一些讓你看來很強的全端 TRPC 伴讀 -InfiniteQuery (上)
下一篇
Day-016. 一些讓你看來很強的全端 TRPC 伴讀 -InfiniteQuery (下)
系列文
一些讓你看來很強的全端- trcp 伴讀30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言