iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
Vibe Coding

Vibe Coding的30天,自然語言與AI共舞,從Prompt到高品質原型落地系列 第 18

Day 18|進階功能實作多欄位搜尋、Prisma分頁與URL狀態同步

  • 分享至 

  • xImage
  •  

在Day17中,我們成功導入了TanStackQuery(ReactQuery),封裝了異步資料流與OptimisticUpdates,讓前後端資料互動變得無比流暢。

然而,當系統資料量成長至幾百、幾千筆時,一次性回傳所有技能資料(UnpaginatedResponse)將造成巨大的網路傳輸開銷與DOM渲染負擔。此外,如果使用者篩選了Frontend且搜尋了React,當他重新整理頁面或將網址分享給同事時,目前的篩選狀態也會完全遺失。

今天(Day18),我們將進行三大進階優化:

Prisma後端分頁與動態條件建構(skip/take與count)。

前端URLSearchParams狀態同步(讓篩選狀態可搜尋、可被分享與書籤化)。

TanStackQuery搭配防抖動(Debounce)搜尋與連續分頁體驗。

1.全局架構圖:URL狀態驅動資料流
我們將採用URLasSourceofTruth(以URL為單一真理來源)的設計範式。頁面上的搜尋框與Dropdown變更時會直接更新網址的QueryString,進而觸發ReactQuery依據URL參數重新Fetch分頁資料:
2.實戰步驟1:升級後端RouteHandler支援分頁(app/api/skills/route.ts)
我們首先請AI重構/api/skills,加入page與limit的解析,並透由prisma.$transaction同時查出當頁資料與總筆數。

開啟Cursor,對著@app/api/skills/route.ts發送Prompt:

@lib/prisma.ts
@lib/utils/skillMappers.ts
@lib/api/response.ts

請幫我升級`app/api/skills/route.ts`中的`GET`Handler,使其完整支援分頁(Pagination)與動態多欄位搜尋。

需求細節:
1.解析Query參數:
-`page`:數字,預設為`1`
-`limit`:數字,預設為`10`(每頁筆數)
-`category`:類別過濾(預設'All')
-`status`:狀態過濾(預設'All')
-`search`:搜尋關鍵字(比對`title`與`tags`)
2.Prisma查詢邏輯:
-計算`skip=(page-1)*limit`,`take=limit`。
-使用`prisma.$transaction([...])`同時執行`findMany`與`count`,確保資料一致性與查詢效能。
3.回傳格式:
-使用`successResponse(formattedSkills,200,paginationMeta)`。
-包含中元資料:`{page,limit,totalCount,totalPages:Math.ceil(totalCount/limit)}`。

AI產出的升級版GET Route Handler:

// app/api/skills/route.ts
import { NextRequest } from 'next/server';
import { prisma } from '@/lib/prisma';
import { successResponse, errorResponse } from '@/lib/api/response';
import { toDbCategory, toDbStatus, formatSkillFromDb } from '@/lib/utils/skillMappers';
import { Prisma } from '@prisma/client';

export async function GET(request: NextRequest) {
  try {
    const { searchParams } = new URL(request.url);
    
    // 1. 解析分頁與篩選參數
    const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10));
    const limit = Math.min(50, Math.max(1, parseInt(searchParams.get('limit') || '10', 10)));
    const category = searchParams.get('category');
    const status = searchParams.get('status');
    const search = searchParams.get('search')?.trim();

    // 2. 動態組合 Prisma Where 條件
    const where: Prisma.SkillWhereInput = {};

    if (category && category !== 'All') {
      where.category = toDbCategory(category);
    }

    if (status && status !== 'All') {
      where.status = toDbStatus(status);
    }

    if (search) {
      where.OR = [
        { title: { contains: search, mode: 'insensitive' } },
        { tags: { hasSome: [search] } }, // 支援單字與標籤精準匹配
      ];
    }

    const skip = (page - 1) * limit;

    // 3. 透過 Transaction 平行處理資料與總筆數查詢
    const [dbSkills, totalCount] = await prisma.$transaction([
      prisma.skill.findMany({
        where,
        skip,
        take: limit,
        orderBy: { createdAt: 'desc' },
      }),
      prisma.skill.count({ where }),
    ]);

    const formattedSkills = dbSkills.map(formatSkillFromDb);
    const totalPages = Math.ceil(totalCount / limit);

    return successResponse(formattedSkills, 200, {
      page,
      limit,
      totalCount,
      totalPages,
      hasNextPage: page < totalPages,
      hasPrevPage: page > 1,
    });
  } catch (error) {
    console.error('[GET /api/skills Error]:', error);
    return errorResponse('無法取得技能清單,請稍後再試', 500);
  }
}

3.實戰步驟2:更新前端Client與CustomHook
我們隨之更新lib/api/skillsClient.ts與hooks/useSkills.ts,使其能正確傳遞分頁參數並儲存帶有分頁的中元資料(PaginationMetadata)。
步驟A:更新API Client介面

// types/api.ts
export interface PaginationMeta {
  page: number;
  limit: number;
  totalCount: number;
  totalPages: number;
  hasNextPage: boolean;
  hasPrevPage: boolean;
}

export interface PaginatedSkillsResponse {
  data: Skill[];
  meta: PaginationMeta;
}

步驟B:更新useSkillsCustomHook
在hooks/useSkills.ts中加入placeholderData:keepPreviousData,這能讓使用者在切換頁碼(如從Page1到Page2)時,畫面不會閃爍或突然變成空白,而是保持上一頁資料直到新頁面資料回傳完成!

// hooks/useSkills.ts
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { fetchPaginatedSkills } from '@/lib/api/skillsClient';

export interface SkillQueryParams {
  category?: string;
  status?: string;
  search?: string;
  page?: number;
  limit?: number;
}

export function useSkills(params: SkillQueryParams) {
  const { category = 'All', status = 'All', search = '', page = 1, limit = 10 } = params;

  return useQuery({
    queryKey: ['skills', { category, status, search, page, limit }],
    queryFn: () => fetchPaginatedSkills({ category, status, search, page, limit }),
    placeholderData: keepPreviousData, // 保持前頁資料流暢無縫接軌
    staleTime: 1000 * 60 * 2,           // 2 分鐘快取
  });
}

4.實戰步驟3:封裝URL狀態管理Hook(hooks/useSkillFilters.ts)
為了避免在UI組件中手動操作繁雜的useSearchParams()與useRouter(),我們請AI撰寫一個優雅的CustomHook,把搜尋、篩選、分頁狀態全部同步到Next.js網址中:

請幫我建立`hooks/useSkillFilters.ts`,用於封裝基於Next.jsAppRouter的URLSearchParams雙向同步邏輯。

需求細節:
1.使用Next.js`useSearchParams()`、`usePathname()`與`useRouter()`。
2.`setFilter(key,value)`:
-當修改`category`、`status`或`search`時,自動將`page`重置為`1`。
-使用`router.replace(newUrl,{scroll:false})`避免滾動跳動。
3.`setPage(newPage)`:更新`page`參數。
4.匯出平坦化的狀態物件:`{category,status,search,page,setFilter,setPage,resetFilters}`。

AI產出的hooks/useSkillFilters.ts:

// hooks/useSkillFilters.ts
'use client';

import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useCallback } from 'react';

export function useSkillFilters() {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();

  // 從 URL 讀取當前狀態
  const category = searchParams.get('category') || 'All';
  const status = searchParams.get('status') || 'All';
  const search = searchParams.get('search') || '';
  const page = parseInt(searchParams.get('page') || '1', 10);
  const limit = parseInt(searchParams.get('limit') || '10', 10);

  // 更新 URL 參數的通用 Helper
  const updateQueryParams = useCallback(
    (newParams: Record<string, string | number | null>) => {
      const params = new URLSearchParams(searchParams.toString());

      Object.entries(newParams).forEach(([key, value]) => {
        if (value === null || value === '' || value === 'All' || (key === 'page' && value === 1)) {
          params.delete(key);
        } else {
          params.set(key, String(value));
        }
      });

      const queryString = params.toString();
      const updatedUrl = queryString ? `${pathname}?${queryString}` : pathname;
      router.replace(updatedUrl, { scroll: false });
    },
    [searchParams, pathname, router]
  );

  const setFilter = useCallback(
    (key: string, value: string) => {
      // 變更條件時自動將頁碼切回第 1 頁
      updateQueryParams({ [key]: value, page: 1 });
    },
    [updateQueryParams]
  );

  const setPage = useCallback(
    (newPage: number) => {
      updateQueryParams({ page: newPage });
    },
    [updateQueryParams]
  );

  const resetFilters = useCallback(() => {
    router.replace(pathname, { scroll: false });
  }, [pathname, router]);

  return {
    category,
    status,
    search,
    page,
    limit,
    setFilter,
    setPage,
    resetFilters,
  };
}

5.實戰步驟4:前端控制UI與分頁器組件(components/skills/Pagination.tsx)
最後,我們建立一個俐落的分頁控制元件(PaginationControls),並在主頁面中使用。

// components/skills/Pagination.tsx
'use client';

import { PaginationMeta } from '@/types/api';

interface PaginationProps {
  meta: PaginationMeta;
  onPageChange: (page: number) => void;
}

export function Pagination({ meta, onPageChange }: PaginationProps) {
  const { page, totalPages, totalCount, hasPrevPage, hasNextPage } = meta;

  if (totalPages <= 1) return null;

  return (
    <div className="flex flex-col sm:flex-row items-center justify-between gap-4 py-6 border-t border-slate-800 text-sm text-slate-400">
      <div>
        顯示第 <span className="font-semibold text-slate-200">{(page - 1) * meta.limit + 1}</span> 到{' '}
        <span className="font-semibold text-slate-200">{Math.min(page * meta.limit, totalCount)}</span> 筆,
        共 <span className="font-semibold text-cyan-400">{totalCount}</span> 筆技能
      </div>

      <div className="flex items-center gap-2">
        <button
          onClick={() => onPageChange(page - 1)}
          disabled={!hasPrevPage}
          className="px-3 py-1.5 rounded-lg border border-slate-700 bg-slate-900 text-slate-200 hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed transition"
        >
          上一頁
        </button>

        <span className="px-3 py-1 text-slate-300 font-medium">
          {page} / {totalPages}
        </span>

        <button
          onClick={() => onPageChange(page + 1)}
          disabled={!hasNextPage}
          className="px-3 py-1.5 rounded-lg border border-slate-700 bg-slate-900 text-slate-200 hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed transition"
        >
          下一頁
        </button>
      </div>
    </div>
  );
}

今天我們為VibePulse的資料檢索層注入了生產環境級別的效能與體驗優化:

Prisma效能優化:利用$transaction與skip/take實作後端高效分頁與總數統計。

URL狀態同步:開發了useSkillFiltersHook,讓所有篩選與分頁狀態均可分享、重新整理不遺失。

使用者體驗無縫化:採用TanStackQuery的keepPreviousData,解決了分頁切換時畫面閃爍的劣質體驗。


上一篇
Day 17|前後端串接TanStack Query (React Query) 異步資料流實戰
系列文
Vibe Coding的30天,自然語言與AI共舞,從Prompt到高品質原型落地18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言