跳至内容

11

添加搜索和分页

在上一章中,您使用流改进了仪表盘的初始加载性能。现在让我们继续/invoices页面,并学习如何添加搜索和分页!

在本节中...

以下是我们将介绍的主题

学习如何使用 Next.js API:useSearchParamsusePathnameuseRouter

使用 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 中,可以更容易地跟踪用户行为,而无需额外的客户端逻辑。

添加搜索功能

这些是您将用于实现搜索功能的 Next.js 客户端钩子

  • useSearchParams- 允许您访问当前 URL 的参数。例如,此 URL/dashboard/invoices?page=1&query=pending的搜索参数如下所示:{page: '1', query: 'pending'}
  • usePathname - 允许您读取当前 URL 的路径名。例如,对于路由/dashboard/invoicesusePathname将返回'/dashboard/invoices'
  • useRouter - 能够以编程方式在客户端组件中的路由之间导航。您可以使用多种方法

以下是实现步骤的快速概述

  1. 捕获用户的输入。
  2. 使用搜索参数更新 URL。
  3. 使 URL 与输入字段保持同步。
  4. 更新表格以反映搜索查询。

1. 捕获用户的输入

进入<Search>组件(/app/ui/search.tsx),您会注意到

  • "use client" - 这是一个客户端组件,这意味着您可以使用事件监听器和钩子。
  • <input> - 这是搜索输入。

创建一个新的handleSearch函数,并向<input>元素添加一个onChange监听器。每当输入值发生变化时,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>
  );
}

通过在开发者工具中打开控制台,然后在搜索字段中键入内容来测试它是否正常工作。您应该会看到搜索词记录到控制台中。

太棒了!您正在捕获用户的搜索输入。现在,您需要使用搜索词更新 URL。

2. 使用搜索参数更新 URL

'next/navigation'导入useSearchParams钩子,并将其分配给一个变量

/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,提供用于操作 URL 查询参数的实用程序方法。您可以使用它来获取类似?page=1&query=a的参数字符串,而不是创建复杂的字符串文字。

接下来,根据用户的输入设置参数字符串。如果输入为空,则需要将其删除

/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钩子来更新 URL。

'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()会将此输入转换为 URL 友好的格式。
  • replace(${pathname}?${params.toString()})使用用户的搜索数据更新 URL。例如,如果用户搜索“李”,则为/dashboard/invoices?query=lee
  • 由于 Next.js 的客户端导航(您在关于页面之间导航的章节中了解过),因此 URL 会更新而不会重新加载页面。

3. 使 URL 和输入保持同步

为了确保输入字段与 URL 同步,并在共享时填充,您可以通过从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。这意味着本机输入将管理其自己的状态。这没关系,因为您将搜索查询保存到 URL 而不是状态。

4. 更新表格

最后,您需要更新表格组件以反映搜索查询。

导航回发票页面。

页面组件接受名为searchParams的 prop,因此您可以将当前 URL 参数传递给<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>组件,您会看到将两个 prop querycurrentPage传递给fetchFilteredInvoices()函数,该函数返回与查询匹配的发票。

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

完成这些更改后,请继续测试它。如果您搜索某个词语,您将更新 URL,这将向服务器发送新的请求,数据将在服务器端获取,并且只会返回与您的查询匹配的发票。

何时使用useSearchParams()钩子与searchParams prop?

您可能已经注意到,您使用了两种不同的方法来提取搜索参数。您使用哪一个取决于您是在客户端还是服务器上工作。

  • <Search>是一个客户端组件,因此您使用了useSearchParams()钩子从客户端访问参数。
  • <Table>是一个服务器组件,它获取自己的数据,因此您可以将searchParams prop 从页面传递到组件。

一般来说,如果要读取客户端的参数,请使用useSearchParams()钩子,因为这样可以避免返回到服务器。

最佳实践:防抖

恭喜!您已经使用 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

您在每次按键时都在更新 URL,因此在每次按键时都在查询数据库!对于我们的小型应用程序来说这不是问题,但想象一下,如果您的应用程序有数千用户,每个用户在每次按键时都向您的数据库发送新的请求。

**防抖**是一种编程实践,用于限制函数触发的频率。在我们的例子中,您只希望在用户停止输入时查询数据库。

防抖的工作原理

  1. **触发事件**:当发生应进行防抖处理的事件(例如搜索框中的按键)时,计时器开始。
  2. **等待**:如果在计时器到期前发生新的事件,则重置计时器。
  3. **执行**:如果计时器达到倒计时结束,则执行防抖函数。

您可以通过多种方式实现防抖,包括手动创建自己的防抖函数。为了简单起见,我们将使用一个名为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的内容,并且仅在用户停止输入(300ms)后一段时间运行代码。

现在再次在搜索栏中输入内容,并在开发者工具中打开控制台。您应该会看到以下内容

开发者工具控制台
Searching... Delba

通过防抖,您可以减少发送到数据库的请求数量,从而节省资源。

添加分页

引入搜索功能后,您会注意到表格一次仅显示 6 张发票。这是因为data.ts中的fetchFilteredInvoices()函数每页最多返回 6 张发票。

添加分页功能允许用户浏览不同的页面以查看所有发票。让我们看看如何使用 URL 参数实现分页,就像您对搜索所做的那样。

导航到<Pagination/>组件,您会注意到它是一个客户端组件。您不希望在客户端获取数据,因为这会泄露您的数据库密钥(请记住,您没有使用 API 层)。相反,您可以在服务器端获取数据,并将其作为道具传递给组件。

/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道具传递给<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钩子。我们将使用它来获取当前页面并设置新页面。确保也取消此组件中代码的注释。您的应用程序将暂时中断,因为您尚未实现<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创建 URL 字符串。

/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”参数更新为提供的页码。
  • 最后,它使用路径名和更新后的搜索参数构建完整的 URL。

<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);
 

总结

恭喜!您刚刚使用 URL 参数和 Next.js API 实现了搜索和分页功能。

总而言之,在本节中

  • 您已使用 URL 搜索参数而不是客户端状态处理搜索和分页。
  • 您已在服务器端获取数据。
  • 您正在使用useRouter路由钩子实现更流畅的客户端过渡。

这些模式与您使用客户端 React 时可能习惯的不同,但希望您现在能够更好地理解使用 URL 搜索参数并将此状态提升到服务器的优势。

您已完成本章11

您的仪表盘现在具有搜索和分页功能!

下一步

12:修改数据

了解如何使用服务器操作修改数据。