跳至內容

11

新增搜尋和分頁

在上一章中,您使用串流技術改善了儀表板的初始載入效能。現在讓我們繼續到 `/invoices` 頁面,學習如何新增搜尋和分頁功能!

本章節內容…

以下是我們將涵蓋的主題

學習如何使用 Next.js API:`useSearchParams`、`usePathname` 和 `useRouter`。

使用 URL 搜尋參數實作搜尋和分頁。

起始程式碼

在您的 `/dashboard/invoices/page.tsx` 檔案中,貼上以下程式碼

/app/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>
  );
}

花一些時間熟悉您將使用的頁面和元件

  1. `<Search/>` 允許使用者搜尋特定發票。
  2. `<Pagination/>` 允許使用者在發票頁面之間瀏覽。
  3. `<Table/>` 顯示發票。

您的搜尋功能將涵蓋客戶端和伺服器端。當使用者在客戶端搜尋發票時,URL 參數將會更新,伺服器端將會擷取資料,表格將會在伺服器端使用新資料重新渲染。

為何使用 URL 搜尋參數?

如上所述,您將使用 URL 搜尋參數來管理搜尋狀態。如果您習慣使用客戶端狀態來執行此操作,則此模式可能對您來說比較陌生。

使用 URL 參數實作搜尋功能有幾個好處:

  • 可加入書籤和可分享的 URL:由於搜尋參數位於 URL 中,使用者可以將應用程式的目前狀態(包括其搜尋查詢和篩選條件)加入書籤,以供日後參考或分享。
  • 伺服器端渲染和初始載入:可以直接在伺服器上使用 URL 參數來渲染初始狀態,讓處理伺服器端渲染更加容易。
  • 分析和追蹤:將搜尋查詢和篩選條件直接放在 URL 中,可以更輕鬆地追蹤使用者行為,而無需額外的客戶端邏輯。

新增搜尋功能 多種方法

以下是實作步驟的快速概覽:

  1. 擷取使用者的輸入。
  2. 使用搜尋參數更新 URL。
  3. 讓 URL 與輸入欄位保持同步。
  4. 更新表格以反映搜尋查詢。

1. 擷取使用者的輸入

進入 <Search> 元件 (/app/ui/search.tsx),您會注意到:

  • "use client" - 這是一個客戶端元件,這表示您可以使用事件監聽器和 hooks。
  • <input> - 這是搜尋輸入欄位。

建立一個新的 handleSearch 函式,並將 onChange 監聽器添加到 <input> 元素。每當輸入值改變時,onChange 都會呼叫 handleSearch

/app/ui/search.tsx
'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>
  );
}

透過在開發者工具中開啟主控台,然後在搜尋欄位中輸入文字來測試它是否正常運作。您應該會在主控台中看到記錄的搜尋詞彙。

太棒了!您已擷取使用者的搜尋輸入。現在,您需要使用搜尋詞彙更新網址。

2. 使用搜尋參數更新網址

'next/navigation' 導入 useSearchParams hook,並將其賦值給一個變數。

/app/ui/search.tsx
'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 實例。

/app/ui/search.tsx
'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,它提供了操作網址查詢參數的工具方法。您可以使用它來取得像 ?page=1&query=a 這樣的參數字串,而不用建立複雜的字串字面值。

接下來,根據使用者的輸入設定參數字串。如果輸入為空,則需要將其 delete 刪除。

/app/ui/search.tsx
'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 的 useRouterusePathname hooks 來更新網址。

'next/navigation' 導入 useRouterusePathname,並在 handleSearch 函式內使用 useRouter()replace 方法。

/app/ui/search.tsx
'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() 會將此輸入轉換為網址友善的格式。
  • replace(${pathname}?${params.toString()}) 使用使用者的搜尋資料更新網址。例如,如果使用者搜尋「Lee」,則網址會更新為 /dashboard/invoices?query=lee
  • 由於 Next.js 的客戶端導航(您在關於頁面間導航的章節中學到的),網址會在不重新載入頁面的情況下更新。

3. 保持網址和輸入同步

為了確保輸入欄位與網址同步,並在分享時填入內容,您可以透過從 searchParams 讀取來將 defaultValue 傳遞給輸入欄位。

/app/ui/search.tsx
<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()}
/>

defaultValuevalue / 受控與非受控

如果您使用狀態來管理輸入的值,則可以使用 value 屬性使其成為受控組件。這表示 React 會管理輸入的狀態。

然而,由於您未使用狀態,因此可以使用 defaultValue。這表示原生輸入框將會自行管理其狀態。由於您是將搜尋查詢儲存到網址而非狀態,因此這樣是可以的。

4. 更新表格

最後,您需要更新表格元件以反映搜尋查詢。

導覽回發票頁面。

頁面元件接受一個名為 searchParams 的屬性,因此您可以將目前的網址參數傳遞給 <Table> 元件。

/app/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 { 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> 元件,您會看到 querycurrentPage 這兩個屬性被傳遞給 fetchFilteredInvoices() 函式,該函式會返回符合查詢的發票。

/app/ui/invoices/table.tsx
// ...
export default async function InvoicesTable({
  query,
  currentPage,
}: {
  query: string;
  currentPage: number;
}) {
  const invoices = await fetchFilteredInvoices(query, currentPage);
  // ...
}

完成這些更改後,請繼續進行測試。如果您搜尋一個詞彙,您將會更新網址,這會向伺服器發送一個新的請求,伺服器上將會擷取資料,並且只會返回符合您查詢的發票。

何時使用 useSearchParams() hook 與 searchParams 屬性?

您可能已經注意到您使用了兩種不同的方式來提取搜尋參數。使用哪一種方式取決於您是在客戶端還是伺服器端工作。

  • <Search> 是一個客戶端元件,因此您使用 useSearchParams() hook 從客戶端存取參數。
  • <Table> 是一個擷取自身資料的伺服器元件,因此您可以將 searchParams 屬性從頁面傳遞到元件。

一般來說,如果您想從客戶端讀取參數,請使用 useSearchParams() hook,因為這樣可以避免返回伺服器。

最佳實務:防抖

恭喜!您已使用 Next.js 實作搜尋功能!但您可以做一些事情來優化它。

在您的 handleSearch 函式中,新增以下 console.log

/app/ui/search.tsx
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

您在每次按鍵時都會更新網址,因此每次按鍵時都會查詢您的資料庫!由於我們的應用程式很小,這不是問題,但想像一下,如果您的應用程式有數千名使用者,每個使用者在每次按鍵時都會向您的資料庫發送一個新的請求。

**防抖(Debouncing)** 是一種程式設計實務,它限制了函式觸發的速率。在我們的例子中,您只希望在使用者停止輸入時才查詢資料庫。

防抖的工作原理

  1. **觸發事件**: 當發生應該防抖的事件(例如搜尋方塊中的按鍵)時,計時器就會啟動。
  2. **等待**: 如果在計時器到期之前發生新的事件,則計時器會重設。
  3. **執行**: 如果計時器達到倒計時結束,則會執行防抖函式。

您可以透過幾種方式實現防抖動(Debouncing),包括手動創建您自己的防抖動函數。為了簡化操作,我們將使用名為 use-debounce 的函式庫。

安裝 use-debounce

終端機
pnpm i use-debounce

在您的 <Search> 元件中,導入一個名為 useDebouncedCallback 的函數

/app/ui/search.tsx
// ...
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

透過防抖動,您可以減少發送到資料庫的請求數量,從而節省資源。

新增分頁功能

導入搜尋功能後,您會注意到表格一次只顯示 6 張發票。這是因為 data.ts 中的 fetchFilteredInvoices() 函數每頁最多返回 6 張發票。

新增分頁功能可讓使用者瀏覽不同頁面以查看所有發票。讓我們看看如何像使用搜尋一樣,使用網址參數來實作分頁。

導覽到 <Pagination/> 元件,您會注意到它是一個 Client Component(客戶端元件)。您不希望在客戶端擷取資料,因為這會暴露您的資料庫機密(請記住,您沒有使用 API 層)。您可以改在伺服器上擷取資料,並將其作為 prop 傳遞給元件。

/dashboard/invoices/page.tsx 中,導入一個名為 fetchInvoicesPages 的新函數,並將 searchParams 中的 query 作為參數傳遞

/app/dashboard/invoices/page.tsx
// ...
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 prop 傳遞給 <Pagination/> 元件

/app/dashboard/invoices/page.tsx
// ...
 
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/> 元件並導入 usePathnameuseSearchParams hooks。我們將使用它來取得目前頁面並設定新頁面。請務必取消註釋此元件中的程式碼。由於您尚未實作 <Pagination/> 邏輯,因此您的應用程式將暫時中斷。現在讓我們開始吧!

/app/ui/invoices/pagination.tsx
'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 來創建網址字串。

/app/ui/invoices/pagination.tsx
'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」參數更新為提供的頁碼。
  • 最後,它會使用路徑名稱和更新後的搜尋參數來建構完整的網址。

<Pagination> 元件的其餘部分處理樣式和不同狀態(第一個、最後一個、作用中、停用等)。在本課程中,我們不會詳細介紹,但您可以自行瀏覽程式碼,查看 createPageURL 的呼叫位置。

最後,當使用者輸入新的搜尋查詢時,您需要將頁碼重設為 1。您可以透過更新 <Search> 元件中的 handleSearch 函式來完成此操作。

/app/ui/search.tsx
'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);
 

總結

恭喜!您剛剛使用網址參數和 Next.js API 實作了搜尋和分頁功能。

總而言之,在本章中

  • 您已使用網址搜尋參數而非用戶端狀態來處理搜尋和分頁。
  • 您已在伺服器上擷取資料。
  • 您正在使用 useRouter 路由器 hook 進行更順暢的用戶端轉換。

這些模式與您在使用用戶端 React 時可能習慣的模式不同,但希望您現在能更好地理解使用網址搜尋參數並將此狀態提升到伺服器的好處。

您已完成本章11

您的儀表板現在具有搜尋和分頁功能!

下一步

12:變更資料

瞭解如何使用伺服器動作變更資料。