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 函数,并为 <input> 元素添加一个 onChange 监听器。每当输入值更改时,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与value/ 受控与非受控如果你使用状态来管理输入的值,你会使用
value属性来使其成为受控组件。这意味着 React 将管理输入的状态。然而,由于你不使用状态,你可以使用
defaultValue。这意味着原生输入将管理其自己的状态。这是可以的,因为你将搜索查询保存到 URL 而不是状态。
4. 更新表格
最后,你需要更新表格组件以反映搜索查询。
导航回发票页面。
页面组件接受一个名为 searchParams 的 prop,因此你可以将当前的 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 这两个 prop 被传递给 fetchFilteredInvoices() 函数,该函数返回与查询匹配的发票。
// ...
export default async function InvoicesTable({
query,
currentPage,
}: {
query: string;
currentPage: number;
}) {
const invoices = await fetchFilteredInvoices(query, currentPage);
// ...
}完成这些更改后,继续测试一下。如果你搜索一个术语,你会更新 URL,这将向服务器发送一个新的请求,数据将在服务器上获取,并且只返回与你的查询匹配的发票。
何时使用
useSearchParams()Hook 与searchParamsprop?你可能已经注意到你使用了两种不同的方式来提取搜索参数。使用哪种方式取决于你是在客户端还是服务器上工作。
<Search>是一个客户端组件,因此你使用useSearchParams()Hook 从客户端访问参数。<Table>是一个服务器组件,它获取自己的数据,因此你可以将searchParamsprop 从页面传递给组件。作为一般规则,如果你想从客户端读取参数,请使用
useSearchParams()Hook,因为这可以避免回溯到服务器。
最佳实践:防抖
恭喜!你已经用 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,因此在每次按键时都在查询你的数据库!对于我们的小应用程序来说这不是问题,但想象一下如果你的应用程序有成千上万的用户,每个用户在每次按键时都向你的数据库发送一个新请求。
防抖是一种编程实践,它限制了函数可以触发的速率。在我们的例子中,你只想在用户停止输入时查询数据库。
防抖如何工作
- 触发事件:当一个应该防抖的事件(例如搜索框中的按键)发生时,计时器开始。
- 等待:如果在计时器到期之前发生新事件,则计时器会重置。
- 执行:如果计时器达到倒计时结束,则执行防抖函数。
你可以通过几种方式实现防抖,包括手动创建自己的防抖函数。为了简单起见,我们将使用一个名为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通过防抖,你可以减少发送到数据库的请求数量,从而节省资源。
添加分页功能
在引入搜索功能后,你会注意到表格一次只显示 6 张发票。这是因为 data.ts 中的 fetchFilteredInvoices() 函数每页最多返回 6 张发票。
添加分页功能允许用户浏览不同的页面以查看所有发票。让我们看看如何像使用搜索一样,使用 URL 参数实现分页。
导航到 <Pagination/> 组件,你会注意到它是一个客户端组件。你不想在客户端获取数据,因为这会暴露你的数据库密钥(记住,你没有使用 API 层)。相反,你可以在服务器上获取数据,并将其作为 prop 传递给组件。
在 /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 prop 传递给 <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 搜索参数并将此状态提升到服务器的好处。
这有帮助吗?