11-添加搜索和分页
原文链接:https://nextjs.org/learn/dashboard-app/adding-search-and-pagination (opens new window)
在上一章中,您通过流式处理提高了仪表板的初始加载性能。现在,让我们转到/invoices
页面,学习如何添加搜索和分页!
# 本章目标
- 了解如何使用Next.js API:
useSearchParams
,usePathname
, anduseRouter
. - 使用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>
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
花点时间熟悉页面和将要使用的组件:
<Search>
允许用户搜索特定 invoices;<Pagination/>
允许在 invoices数据中创建分页功能;<Table>
展示了invoices数据。
您的搜索功能将覆盖客户端和服务器。当用户在客户端上搜索发票时,URL参数将被更新,数据将在服务器上提取,表将使用新数据在服务器上重新呈现。
# 为什么使用URL搜索参数
如上所述,您将使用URL搜索参数来管理搜索状态。如果您习惯于使用客户端状态,那么这种模式可能是新的。 使用URL参数实现搜索有几个好处:
- 书签标记和可分享的URL:由于搜索参数在URL中,用户可以将应用程序的当前状态(包括他们的搜索查询和过滤器)标记为书签,以供将来参考或共享。
- 服务器端渲染和初始加载:URL参数可以直接在服务器上使用以呈现初始状态,从而更容易处理服务器呈现。
- 分析和跟踪:直接在URL中设置搜索查询和过滤器,可以更容易地跟踪用户行为,而不需要额外的客户端逻辑。
# 添加搜索功能
以下是您将用于实现搜索功能的Next.js客户端hooks:
**useSearchParams**
:允许您访问当前URL的参数。例如,这个URL搜索参数/dashboard/invoices?page=1&query=pending
结果看起来是这样的:{page: '1', query: 'pending'}
**usePathname**
:用于读取当前URL的路径名。例如,如下路由/dashboard/invoices
、usePathname将会返回/dashboard/invoices
;**useRouter**
:以编程方式启用客户端组件内路由之间的导航。您可以使用多种方法 (opens new window)。
以下是实施步骤的快速概述:
- 捕获用户的输入。
- 使用搜索参数更新URL。
- 使URL与输入字段保持同步。
- 更新表以反映搜索查询。
# 捕获用户的输入
进入到<Search>
组件(/app/ui/search.tsx),你将会注意到:
"use client"
,这是一个客户端组件,这意味着您可以使用事件侦听器和挂钩。<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>
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
在开发人员工具中打开控制台,然后在搜索字段中键入,以测试它是否正常工作。您应该会看到记录到控制台的搜索词。 太棒了您正在捕获用户的搜索输入。现在,您需要使用搜索词更新URL。
# 使用搜索参数更新URL
从'next/navigation'
导入useSearchParams
hooks钩子;并将其分配给变量;
'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);
}
// ...
}
2
3
4
5
6
7
8
9
10
11
12
13
在handleSearch中,创建一个新的 useSearchParams
实例使用新的searchParams变量。
'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);
}
// ...
}
2
3
4
5
6
7
8
9
10
11
12
13
URLSearchParams
是一个Web API,它提供了用于操作URL查询参数的实用方法。您可以使用它来获取params字符串,而不是创建复杂的字符串文字?page=1&query=a
接下来,根据用户的输入设置params字符串。如果输入为空,则要删除它:
'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');
}
}
// ...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
现在您有了查询字符串。您可以使用Next.js的useRouter
和usePathname
挂钩来更新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()}`);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
以下是正在发生的事情的明细:
${pathname}
是当前路径,比如,当前是/dashboard/invoices
;- 当用户在搜索栏中键入时,
params.toString()
将此输入转换为URL友好格式。 replace(${pathname}?${params.toString()})
使用用户的搜索数据更新URL。例如,/dashboard/invoices?query=lee
,如果用户搜索“Lee”。- 在不重新加载页面的情况下更新URL,感谢Next.js的客户端导航(您在页面间导航一章中了解到了这一点)。
# 使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()}
/>
2
3
4
5
6
7
8
defaultValue vs. value 以及 Controlled vs. Uncontrolled 区别:
- 如果您使用state来管理输入的值,那么您应该使用value属性使其成为受控组件。这意味着React将管理输入的状态。
- 但是,由于您没有使用state,所以可以使用defaultValue。这意味着本机输入将管理自己的状态。这是可以的,因为您将搜索查询保存到URL而不是状态。
# 更新table数据
最后,您需要更新表组件以反映搜索查询。
导航回invoices页面。
页面组件接收 props
和 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({
searchParams,
}: {
searchParams?: {
query?: string;
page?: string;
};
}) {
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>
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
如果导航到<Table>
组件,你将看到2个props:query
和currentPage
。传递给fetchFilteredInvoices()
函数,该函数返回与查询匹配的invoices
。
// ...
export default async function InvoicesTable({
query,
currentPage,
}: {
query: string;
currentPage: number;
}) {
const invoices = await fetchFilteredInvoices(query, currentPage);
// ...
}
2
3
4
5
6
7
8
9
10
11
有了这些变化,继续测试.如果你搜索一个术语,你会更新URL。它将向服务器发送一个新的请求,数据将在服务器上提取,并且只返回与您的查询匹配的invoices
。
什么时候使用useSearchParams()
hooks vs searchParams props?
您可能已经注意到,您使用了两种不同的方法来提取搜索参数。是否使用其中一个取决于您是在客户端还是在服务器上工作。
<Search>
是一个客户端组件,因此您使用useSearchParams()
钩子从客户端访问参数。<Table>
是一个获取自己数据的服务器组件,因此您可以将searchParams
属性从页面传递给该组件。
一般来说,如果您想从客户端读取params,请使用useSearchParams()钩子,因为这样可以避免返回服务器。
# 添加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()}`);
}
2
3
4
5
6
7
8
9
10
11
然后在搜索栏中键入“Emil”,并在开发工具中检查控制台。发生了什么?
Searching... E
Searching... Em
Searching... Emi
Searching... Emil
2
3
4
您在每次击键时都会更新URL,因此每次键入值都会查询数据库!这不是问题,因为我们的应用程序很小,但想象一下,如果你的应用程序有数千个用户,每个用户在每次击键时都会向你的数据库发送一个新的请求。 Debouncing是一种编程实践,它限制了函数可以激发的频率。在我们的例子中,您只想在用户停止键入时查询数据库。
# Debouncing工作原理
- 触发事件:当应该取消跳动的事件(如搜索框中的击键)发生时,计时器启动。
- 等待:如果在计时器到期之前发生新事件,则计时器将重置。
- 执行:如果计时器达到倒计时结束,则执行去抖动功能。
您可以通过几种方式实现防抖,包括手动创建自己的防抖函数。
为了简单起见,我们将使用第三方库 [use-debounce](https://www.npmjs.com/package/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);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
此函数将包装handleSearch的内容.并且仅在用户停止键入(300ms)之后在特定时间之后运行代码。 现在,再次在搜索栏中键入,并在开发工具中打开控制台。您应该看到以下内容:
Searching... Emil
通过使用Debounce可以减少发送到数据库的请求数量,从而节省资源。
# 添加分页
引入搜索功能后,您会注意到该表一次只显示6个invoices。这是因为data.ts中的fetchFilteredInvoices()
函数每页最多返回6张invoices。
添加分页允许用户浏览不同的页面以查看所有invoices
。让我们看看如何使用URL参数实现分页,就像您在搜索中所做的那样。
导航到<Pagination/>
组件,您会注意到它是一个客户端组件。您不能在客户端上获取数据,因为这会暴露您的数据库机密(记住,您没有使用API层)。相反,您可以在服务器组件上获取数据,并将其作为 props 传递给组件。
在 /dashboard/invoices/page.tsx
文件中,导入一个名为fetchInvoicesPages
的新函数,并将searchParams中的查询作为参数传递:
// ...
import { fetchInvoicesPages } from '@/app/lib/data';
export default async function Page({
searchParams,
}: {
searchParams?: {
query?: string,
page?: string,
},
}) {
const query = searchParams?.query || '';
const currentPage = Number(searchParams?.page) || 1;
const totalPages = await fetchInvoicesPages(query);
return (
// ...
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fetchInvoicesPages 函数请求,返回基于搜索查询的总页数。例如,如果有12个invoices与搜索查询相匹配,并且每页显示6个invoices,则总页数为2。
接下来,将totalPages属性传递给<Pagination/>
组件:
// ...
export default async function Page({
searchParams,
}: {
searchParams?: {
query?: string;
page?: string;
};
}) {
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>
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
导航到组件<Pagination/>
,并且导入usePathname
和useSearchParams
hooks函数;我们将使用它来获取当前页面并设置新页面。请确保还取消注释该组件中的代码。
您的应用程序将暂时中断,因为您还没有实现<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;
// ...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
接下来在 组件 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()}`;
};
// ...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
以下是正在发生的事情的明细:
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);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 总结:
祝贺您刚刚使用URL Params和Next.js API实现了搜索和分页。 总之,在本章中:
- 您已经使用URL搜索参数而不是客户端状态处理了搜索和分页
- 你已经在服务器上获取了数据
- 您正在使用useRouter路由器挂钩来实现更平滑的客户端转换。
这些模式与您在使用客户端React时可能习惯的模式不同,但希望您现在能更好地理解使用URL搜索参数并将此状态提升到服务器的好处。