添加搜索和分页 3个月前

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

在上一章中,你通过流式渲染改善了仪表板的初始加载性能。现在,让我们继续学习如何在 /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
关注者
2223064
累计阅读

热门教程文档

HTML
32小节
Lua
21小节
Vue
25小节
C++
73小节
React Native
40小节