在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,解決了分頁切換時畫面閃爍的劣質體驗。