添加搜索和分页 1年前

编程语言
967
添加搜索和分页

在上一章中,你通过流式渲染改善了仪表板的初始加载性能。现在,让我们继续学习如何在 /invoices 页面上添加搜索和分页功能!

在本章中...

以下是我们将要涵盖的主题:

  1. 学习如何使用 Next.js API:useSearchParamsusePathnameuseRouter
  2. 使用 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 函数,并将 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>
  );
}

测试一下它是否正常工作,通过在开发者工具中打开控制台,然后在搜索框中输入内容。你应该会看到搜索词被记录到控制台中。

很好!你已经捕捉了用户的搜索输入。现在,你需要更新 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);

    if (term) {
      params.set('query', term);
    } else {
      params.delete('query');
    }
  }
}

URLSearchParams 是一个 Web API,提供了用于操作 URL 查询参数的实用方法。它让你可以避免创建复杂的字符串字面量,而是使用它来获取类似 ?page=1&query=a 的参数字符串。

接下来,set 参数字符串基于用户的输入。如果输入为空,你要 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 钩子来更新 URL。

'next/navigation' 导入 useRouterusePathname,并在 handleSearch 中使用 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。例如,如果用户搜索 "Lee",URL 会变成 /dashboard/invoices?query=lee
  • URL 会在不重新加载页面的情况下更新,这得益于 Next.js 的客户端导航。

3. 保持 URL 和输入同步

为了确保输入字段与 URL 同步,并在分享时能够填充,你可以通过读取 searchParams 来将 defaultValue 传递给输入字段:

// /app/ui/search.tsx
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams, usePathname, useRouter } from 'next/navigation';

export default function Search({ placeholder }: { placeholder: string }) {
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const { replace } = useRouter();
  const query = searchParams.get('query') || '';

  function handleSearch(term: string) {
    const params = new URLSearchParams(searchParams);

    if (term) {
      params.set('query', term);
    } else {
      params.delete('query');
    }

    replace(`${pathname}?${params.toString()}`);
  }

  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}
        defaultValue={query}
        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>
  );
}

4. 更新表格以反映搜索查询

最后,你需要更新表格组件以反映搜索查询。 返回到发票页面。 页面组件接受一个名为 searchParams 的属性,因此你可以将当前的 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 { InvoicesTableSkeleton } from '@/app/ui/skeletons';
import { Suspense } from 'react';
import { useSearchParams } from 'next/navigation';

export default function Page() {
  const searchParams = useSearchParams();
  const query = searchParams.get('query') || '';
  const currentPage = Number(searchParams.get('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 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);
  // ...
}

这些更改完成后,你可以进行测试。如果你搜索一个术语,URL 将更新,这将向服务器发送新的请求,数据将由服务器获取,并且仅返回与查询匹配的发票。

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

你可能注意到你使用了两种不同的方式来提取搜索参数。你选择使用哪一种取决于你是正在客户端还是服务器端工作。

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

作为一般规则,如果你想从客户端读取参数,使用 useSearchParams() 钩子,这样可以避免回到服务器获取参数。

最佳实践:去抖动(debouncing)

祝贺你!你已经在 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()}`);
}

然后在搜索框中输入“Emil”,检查开发者工具中的控制台。会发生什么?

# Dev Tools Console
Searching...E
Searching...Em
Searching...Emi
Searching...Emil

你在每个按键输入时都在更新 URL,因此每次按键时都会查询你的数据库!虽然这对于我们的小应用来说不是问题,但想象一下如果你的应用有成千上万的用户,每次按键时都会向你的数据库发送新的请求。

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

去抖动的工作原理:

  1. 触发事件:当应该被去抖动的事件(例如搜索框中的按键)发生时,定时器开始。
  2. 等待:如果在定时器到期前发生了新的事件,定时器将重置。
  3. 执行:如果定时器倒计时结束,去抖动函数将被执行。

你可以通过几种方式实现去抖动,包括手动创建自己的去抖动函数。为了简单起见,我们将使用一个名为 use-debounce 的库。

安装 use-debounce

pnpm i use-debounce

<Search> 组件中,导入一个名为 useDebouncedCallback 的函数:

// /app/ui/search.tsx
import { useDebouncedCallback } from 'use-debounce';
// 在 Search 组件内部...
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)后才运行代码。

现在再次在搜索框中输入,你应该会看到以下内容:

# Dev Tools Console
Searching...Emil

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

是时候做一个小测试了!

测试一下你的知识,看看你刚刚学到了什么。

去抖动在搜索功能中解决了什么问题?

A. 提高了数据库查询速度 B. 使 URL 可书签化 C. 防止每次按键都进行新的数据库查询 D. 帮助 SEO 优化

检查答案

添加分页

在引入搜索功能后,你会注意到表格每次只显示 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({
  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>
  );
}

fetchInvoicesPages 根据搜索查询返回总页数。例如,如果有 12 条发票与搜索查询匹配,而每页显示 6 条发票,那么总页数为 2。

接下来,将 totalPages 属性传递给 <Pagination/> 组件:

// /app/dashboard/invoices/page.tsx
// ...
 
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>
  );
}

导航到 <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 搜索参数和将状态提升到服务器的好处。

image
EchoEcho官方
无论前方如何,请不要后悔与我相遇。
1377
发布数
439
关注者
2475879
累计阅读

热门教程文档

Spring Boot
24小节
React Native
40小节
爬虫
6小节
10.x
88小节
MyBatis
19小节
广告