11
章節11
新增搜尋和分頁功能
在前一章節中,您已透過串流改善了儀表板的初始載入效能。現在讓我們繼續進行 /invoices
頁面,並學習如何新增搜尋和分頁功能。
在本章節中...
以下是我們將涵蓋的主題
學習如何使用 Next.js API:useSearchParams
、usePathname
和 useRouter
。
使用 URL 搜尋參數實作搜尋和分頁功能。
起始程式碼
在您的 /dashboard/invoices/page.tsx
檔案中,貼上以下程式碼
import Pagination from '@/app/ui/invoices/pagination';
import Search from '@/app/ui/search';
import Table from '@/app/ui/invoices/table';
import { CreateInvoice } from '@/app/ui/invoices/buttons';
import { lusitana } from '@/app/ui/fonts';
import { InvoicesTableSkeleton } from '@/app/ui/skeletons';
import { Suspense } from 'react';
export default async function Page() {
return (
<div className="w-full">
<div className="flex w-full items-center justify-between">
<h1 className={`${lusitana.className} text-2xl`}>Invoices</h1>
</div>
<div className="mt-4 flex items-center justify-between gap-2 md:mt-8">
<Search placeholder="Search invoices..." />
<CreateInvoice />
</div>
{/* <Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
<Table query={query} currentPage={currentPage} />
</Suspense> */}
<div className="mt-5 flex w-full justify-center">
{/* <Pagination totalPages={totalPages} /> */}
</div>
</div>
);
}
花一些時間熟悉此頁面以及您將使用的元件
<Search/>
允許使用者搜尋特定發票。<Pagination/>
允許使用者在發票頁面之間導航。<Table/>
顯示發票。
您的搜尋功能將跨越用戶端和伺服器。當使用者在用戶端搜尋發票時,URL 參數將會更新,資料將會在伺服器上提取,且表格將會在伺服器上以新資料重新渲染。
為何使用 URL 搜尋參數?
如上所述,您將使用 URL 搜尋參數來管理搜尋狀態。如果您習慣使用用戶端狀態來完成此操作,此模式可能會是新的。
使用 URL 參數實作搜尋有幾個好處
- 可加入書籤和分享的 URL:由於搜尋參數位於 URL 中,使用者可以將應用程式的目前狀態(包括其搜尋查詢和篩選器)加入書籤,以供日後參考或分享。
- 伺服器端渲染:URL 參數可以直接在伺服器上使用以渲染初始狀態,使其更容易處理伺服器渲染。
- 分析和追蹤:將搜尋查詢和篩選器直接放在 URL 中,可以更輕鬆地追蹤使用者行為,而無需額外的用戶端邏輯。
新增搜尋功能
以下是您將用來實作搜尋功能的 Next.js 用戶端 Hook
useSearchParams
- 允許您存取目前 URL 的參數。例如,此 URL/dashboard/invoices?page=1&query=pending
的搜尋參數會如下所示:{page: '1', query: 'pending'}
。usePathname
- 讓您讀取目前 URL 的路徑名稱。例如,對於路由/dashboard/invoices
,usePathname
將會傳回'/dashboard/invoices'
。useRouter
- 能夠以程式設計方式在用戶端元件內於路由之間導航。您可以使用多種方法。
以下是實作步驟的快速概觀
- 擷取使用者的輸入。
- 使用搜尋參數更新 URL。
- 讓 URL 與輸入欄位保持同步。
- 更新表格以反映搜尋查詢。
1. 擷取使用者的輸入
進入 <Search>
元件 (/app/ui/search.tsx
),您會注意到
"use client"
- 這是一個用戶端元件,表示您可以使用事件監聽器和 Hook。<input>
- 這是搜尋輸入。
建立新的 handleSearch
函式,並將 onChange
監聽器新增至 <input>
元素。每當輸入值變更時,onChange
都會調用 handleSearch
。
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
export default function Search({ placeholder }: { placeholder: string }) {
function handleSearch(term: string) {
console.log(term);
}
return (
<div className="relative flex flex-1 flex-shrink-0">
<label htmlFor="search" className="sr-only">
Search
</label>
<input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
placeholder={placeholder}
onChange={(e) => {
handleSearch(e.target.value);
}}
/>
<MagnifyingGlassIcon className="absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
</div>
);
}
開啟瀏覽器開發人員工具中的主控台,然後在搜尋欄位中輸入內容,以驗證其是否正常運作。您應該會在瀏覽器主控台中看到記錄的搜尋詞彙。
太棒了!您正在擷取使用者的搜尋輸入。現在,您需要使用搜尋詞彙更新 URL。
2. 使用搜尋參數更新 URL
從 next/navigation
匯入 useSearchParams
Hook,並將其指派給變數
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams } from 'next/navigation';
export default function Search() {
const searchParams = useSearchParams();
function handleSearch(term: string) {
console.log(term);
}
// ...
}
在 handleSearch
內,使用您的新 searchParams
變數建立新的 URLSearchParams
實例。
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams } from 'next/navigation';
export default function Search() {
const searchParams = useSearchParams();
function handleSearch(term: string) {
const params = new URLSearchParams(searchParams);
}
// ...
}
URLSearchParams
是一個 Web API,提供用於操作 URL 查詢參數的實用方法。您可以透過它取得像 ?page=1&query=a
這樣的參數字串,而不是建立複雜的字串文字。
接下來,根據使用者的輸入 set
參數字串。如果輸入為空,您會想要 delete
它
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams } from 'next/navigation';
export default function Search() {
const searchParams = useSearchParams();
function handleSearch(term: string) {
const params = new URLSearchParams(searchParams);
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
}
// ...
}
現在您有了查詢字串。您可以使用 Next.js 的 useRouter
和 usePathname
Hook 來更新 URL。
從 'next/navigation'
匯入 useRouter
和 usePathname
,並在 handleSearch
內使用 useRouter()
中的 replace
方法
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams, usePathname, useRouter } from 'next/navigation';
export default function Search() {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
function handleSearch(term: string) {
const params = new URLSearchParams(searchParams);
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
replace(`${pathname}?${params.toString()}`);
}
}
以下是正在發生的事情的細目
${pathname}
是目前路徑,在您的案例中為"/dashboard/invoices"
。- 當使用者在搜尋列中輸入內容時,
params.toString()
會將此輸入轉換為 URL 友善格式。 replace(${pathname}?${params.toString()})
會使用使用者的搜尋資料更新 URL。例如,如果使用者搜尋「Lee」,則為/dashboard/invoices?query=lee
。- 由於 Next.js 的用戶端導航(您在關於頁面之間導航的章節中已學到),URL 會在不重新載入頁面的情況下更新。
3. 讓 URL 和輸入保持同步
為了確保輸入欄位與 URL 同步,並在分享時會自動填入,您可以透過從 searchParams
讀取來將 defaultValue
傳遞至輸入
<input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
placeholder={placeholder}
onChange={(e) => {
handleSearch(e.target.value);
}}
defaultValue={searchParams.get('query')?.toString()}
/>
defaultValue
vs.value
/ 受控 vs. 非受控如果您使用狀態來管理輸入的值,您會使用
value
屬性使其成為受控元件。這表示 React 將管理輸入的狀態。但是,由於您未使用狀態,因此可以使用
defaultValue
。這表示原生輸入將管理其自己的狀態。這是可以接受的,因為您要將搜尋查詢儲存到 URL 而不是狀態。
4. 更新表格
最後,您需要更新表格元件以反映搜尋查詢。
導航回發票頁面。
頁面元件接受名為 searchParams
的屬性,因此您可以將目前的 URL 參數傳遞至 <Table>
元件。
import Pagination from '@/app/ui/invoices/pagination';
import Search from '@/app/ui/search';
import Table from '@/app/ui/invoices/table';
import { CreateInvoice } from '@/app/ui/invoices/buttons';
import { lusitana } from '@/app/ui/fonts';
import { Suspense } from 'react';
import { InvoicesTableSkeleton } from '@/app/ui/skeletons';
export default async function Page(props: {
searchParams?: Promise<{
query?: string;
page?: string;
}>;
}) {
const searchParams = await props.searchParams;
const query = searchParams?.query || '';
const currentPage = Number(searchParams?.page) || 1;
return (
<div className="w-full">
<div className="flex w-full items-center justify-between">
<h1 className={`${lusitana.className} text-2xl`}>Invoices</h1>
</div>
<div className="mt-4 flex items-center justify-between gap-2 md:mt-8">
<Search placeholder="Search invoices..." />
<CreateInvoice />
</div>
<Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
<Table query={query} currentPage={currentPage} />
</Suspense>
<div className="mt-5 flex w-full justify-center">
{/* <Pagination totalPages={totalPages} /> */}
</div>
</div>
);
}
如果您導航至 <Table>
元件,您會看到兩個屬性 query
和 currentPage
傳遞至 fetchFilteredInvoices()
函式,該函式會傳回符合查詢的發票。
// ...
export default async function InvoicesTable({
query,
currentPage,
}: {
query: string;
currentPage: number;
}) {
const invoices = await fetchFilteredInvoices(query, currentPage);
// ...
}
完成這些變更後,繼續測試它。如果您搜尋詞彙,您將更新 URL,這會向伺服器發送新的請求,資料將會在伺服器上提取,且只會傳回符合您查詢的發票。
何時使用
useSearchParams()
Hook 與searchParams
屬性?您可能已經注意到您使用了兩種不同的方式來提取搜尋參數。您使用哪一種取決於您是在用戶端還是伺服器上工作。
<Search>
是用戶端元件,因此您使用了useSearchParams()
Hook 從用戶端存取參數。<Table>
是伺服器元件,會提取自己的資料,因此您可以從頁面將searchParams
屬性傳遞至元件。作為一般規則,如果您想從用戶端讀取參數,請使用
useSearchParams()
Hook,因為這可以避免必須回到伺服器。
最佳實務:Debouncing(防抖動)
恭喜!您已使用 Next.js 實作搜尋功能!但您可以做些事情來最佳化它。
在您的 handleSearch
函式內,新增以下 console.log
function handleSearch(term: string) {
console.log(`Searching... ${term}`);
const params = new URLSearchParams(searchParams);
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
replace(`${pathname}?${params.toString()}`);
}
然後在您的搜尋列中輸入「Delba」,並檢查開發人員工具中的主控台。正在發生什麼事?
Searching... D
Searching... De
Searching... Del
Searching... Delb
Searching... Delba
您正在每次按鍵時更新 URL,因此每次按鍵時都在查詢您的資料庫!由於我們的應用程式很小,這不是問題,但想像一下,如果您的應用程式有成千上萬的使用者,每個人在每次按鍵時都向您的資料庫發送新的請求。
Debouncing(防抖動) 是一種程式設計實務,可限制函式觸發的速率。在我們的案例中,您只想在使用者停止輸入時查詢資料庫。
Debouncing(防抖動)如何運作
- 觸發事件:當應該 debouncing(防抖動)的事件(例如搜尋框中的按鍵)發生時,計時器會啟動。
- 等待:如果在計時器到期之前發生新事件,則計時器會重設。
- 執行:如果計時器達到倒數計時結束,則會執行 debouncing(防抖動)的函式。
您可以使用幾種方式實作 debouncing(防抖動),包括手動建立您自己的 debounce 函式。為了保持簡單,我們將使用名為 use-debounce
的程式庫。
安裝 use-debounce
pnpm i use-debounce
在您的 <Search>
元件中,匯入名為 useDebouncedCallback
的函式
// ...
import { useDebouncedCallback } from 'use-debounce';
// Inside the Search Component...
const handleSearch = useDebouncedCallback((term) => {
console.log(`Searching... ${term}`);
const params = new URLSearchParams(searchParams);
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
replace(`${pathname}?${params.toString()}`);
}, 300);
此函式將包裝 handleSearch
的內容,並僅在使用者停止輸入後經過特定時間(300 毫秒)才執行程式碼。
現在再次在您的搜尋列中輸入內容,並開啟開發人員工具中的主控台。您應該會看到以下內容
Searching... Delba
透過 debouncing(防抖動),您可以減少發送到資料庫的請求數量,從而節省資源。
新增分頁功能
在引入搜尋功能後,您會注意到表格一次只顯示 6 張發票。這是因為 data.ts
中的 fetchFilteredInvoices()
函式每頁最多傳回 6 張發票。
新增分頁功能允許使用者瀏覽不同的頁面以檢視所有發票。讓我們看看如何使用 URL 參數來實作分頁功能,就像您對搜尋功能所做的那樣。
導航至 <Pagination/>
元件,您會注意到它是一個用戶端元件。您不想要在用戶端提取資料,因為這會暴露您的資料庫機密(請記住,您未使用 API 層)。相反地,您可以在伺服器上提取資料,並將其作為屬性傳遞至元件。
在 /dashboard/invoices/page.tsx
中,匯入名為 fetchInvoicesPages
的新函式,並將來自 searchParams
的 query
作為引數傳遞
// ...
import { fetchInvoicesPages } from '@/app/lib/data';
export default async function Page(
props: {
searchParams?: Promise<{
query?: string;
page?: string;
}>;
}
) {
const searchParams = await props.searchParams;
const query = searchParams?.query || '';
const currentPage = Number(searchParams?.page) || 1;
const totalPages = await fetchInvoicesPages(query);
return (
// ...
);
}
fetchInvoicesPages
根據搜尋查詢傳回總頁數。例如,如果有 12 張發票符合搜尋查詢,且每頁顯示 6 張發票,則總頁數將為 2。
接下來,將 totalPages
屬性傳遞至 <Pagination/>
元件
// ...
export default async function Page(props: {
searchParams?: Promise<{
query?: string;
page?: string;
}>;
}) {
const searchParams = await props.searchParams;
const query = searchParams?.query || '';
const currentPage = Number(searchParams?.page) || 1;
const totalPages = await fetchInvoicesPages(query);
return (
<div className="w-full">
<div className="flex w-full items-center justify-between">
<h1 className={`${lusitana.className} text-2xl`}>Invoices</h1>
</div>
<div className="mt-4 flex items-center justify-between gap-2 md:mt-8">
<Search placeholder="Search invoices..." />
<CreateInvoice />
</div>
<Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
<Table query={query} currentPage={currentPage} />
</Suspense>
<div className="mt-5 flex w-full justify-center">
<Pagination totalPages={totalPages} />
</div>
</div>
);
}
導航至 <Pagination/>
元件,並匯入 usePathname
和 useSearchParams
Hook。我們將使用它來取得目前頁面並設定新頁面。請務必也取消註解此元件中的程式碼。由於您尚未實作 <Pagination/>
邏輯,您的應用程式將暫時中斷。讓我們現在就來做!
'use client';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import Link from 'next/link';
import { generatePagination } from '@/app/lib/utils';
import { usePathname, useSearchParams } from 'next/navigation';
export default function Pagination({ totalPages }: { totalPages: number }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const currentPage = Number(searchParams.get('page')) || 1;
// ...
}
接下來,在 <Pagination>
元件內建立新的函式,名為 createPageURL
。與搜尋功能類似,您將使用 URLSearchParams
來設定新頁碼,並使用 pathName
來建立 URL 字串。
'use client';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import Link from 'next/link';
import { generatePagination } from '@/app/lib/utils';
import { usePathname, useSearchParams } from 'next/navigation';
export default function Pagination({ totalPages }: { totalPages: number }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const currentPage = Number(searchParams.get('page')) || 1;
const createPageURL = (pageNumber: number | string) => {
const params = new URLSearchParams(searchParams);
params.set('page', pageNumber.toString());
return `${pathname}?${params.toString()}`;
};
// ...
}
以下是正在發生的事情的細目
createPageURL
建立目前搜尋參數的實例。- 然後,它會將「page」參數更新為提供的頁碼。
- 最後,它會使用路徑名稱和更新的搜尋參數來建構完整的 URL。
<Pagination>
元件的其餘部分處理樣式和不同狀態(第一個、最後一個、活動中、已停用等)。我們不會在本課程中詳細介紹,但歡迎隨時查看程式碼,以了解 createPageURL
的呼叫位置。
最後,當使用者輸入新的搜尋查詢時,您會想要將頁碼重設為 1。您可以透過更新 <Search>
元件中的 handleSearch
函式來完成此操作
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useDebouncedCallback } from 'use-debounce';
export default function Search({ placeholder }: { placeholder: string }) {
const searchParams = useSearchParams();
const { replace } = useRouter();
const pathname = usePathname();
const handleSearch = useDebouncedCallback((term) => {
const params = new URLSearchParams(searchParams);
params.set('page', '1');
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
replace(`${pathname}?${params.toString()}`);
}, 300);
摘要
恭喜!您剛使用 URL 搜尋參數和 Next.js API 實作了搜尋和分頁功能。
總而言之,在本章節中
- 您已使用 URL 搜尋參數而不是用戶端狀態來處理搜尋和分頁。
- 您已在伺服器上提取資料。
- 您正在使用
useRouter
路由器 Hook 進行更順暢的用戶端轉換。
這些模式與您在處理用戶端 React 時可能習慣的不同,但希望您現在更了解使用 URL 搜尋參數以及將此狀態提升到伺服器的好處。
這有幫助嗎?