Administrator
发布于 2024-10-18 / 31 阅读
0
0

Next.js 零基础教程12 站内搜索技术实现

在上一篇文章中,你可以使用流式传输技术改进了仪表板的初始化加载性能。现在让我们转到页面/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 客户端挂钩:

  • useSearchParams允许您访问当前URL的参数。例如,此URL的搜索参数/dashboard/invoices?page=1&query=pending如下所示:{page: '1', query: 'pending'}。

  • usePathname 让您读取当前URL的路径名。例如,对于路线/dashboard/invoices,usePathname将返回'/dashboard/invoices'。

  • useRouter 以编程方式启用客户端组件内路由之间的导航。您可以使用多种方法。

以下是实施步骤的简要概述:

  1. 捕获用户的输入。

  2. 使用搜索参数更新 URL。

  3. 保持 URL 与输入字段同步。

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

捕获用户输入

编辑<Search>组件(/app/ui/search.tsx),创建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">
        搜索
      </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>
  );
}

搜索页面

然后我们把page.tsx的注释内容修改一下,把搜索功能取消注释
搜索页面
访问地址:http://172.16.100.104/dashboard/invoices 即可看到我们刚刚的修改
近期发票
通过在开发者工具中打开控制台来测试它是否正常工作,然后在搜索字段中输入。您应该看到搜索词记录到控制台。太棒了!您捕获了用户的搜索输入。现在,您需要使用搜索词更新 URL。

使用搜索参数更新 URL

编辑 /app/ui/search.tsx 修改部分内容

use SearchParams从导入钩子'next/navigation',并将其分配给变量:

'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,创建一个新的URLSearchParams使用新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);
  }
  // ...
}

URLSearchParams是一个WebAPI,它提供用于操作URL查询参数的实用方法。您无需创建复杂的字符串文字,而是可以使用它来获取参数字符串,例如?page=1&query=a。

接下来,set根据用户的输入获取params字符串。如果输入为空,则需要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');
    }
  }
  // ...
}

在页面使用F12可以看到调试的信息
查询延时
现在您有了查询字符串。您可以使用Next.jsuseRouterusePathname钩子来更新 URL。导入useRouter并使用内部的方法:usePathname 'next/navigation' replace use Router() handleSearch

'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如果用户搜索“Lee”。

  • 得益于 Next.js的客户端导航(您在页面间导航章节中了解过),无需重新加载页面即可更新 URL。

保持 URL 和输入同步

为了确保输入字段与URL同步并在共享时填充,您可以defaultValue通过读取以下内容将输入传递给输入searchParams

编辑 /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()}
/>

关于 React 中受控组件和非受控组件的区别,以及 defaultValue 和 value 属性的使用。让我来详细解释一下:

  • 受控组件 vs 非受控组件

    1. 受控组件:组件的状态完全由 React 控制。通常使用 value 属性和 onChange 事件来管理。

    2. 非受控组件:组件的状态由 DOM 自身管理。通常使用 defaultValue 属性来设置初始值

  • value vs defaultValue

    1. value:用于受控组件,需要配合 onChange 使用,每次输入都会触发状态更新。

    2. defaultValue:用于非受控组件,只设置初始值,之后的输入不会触发 React 状态更新。

这里使用了 defaultValue,表明这是一个非受控组件。初始值从URL的查询参数中获取,但之后的输入不会直接更新 React 的状态。

  • 为什么这样做?

    1. 性能考虑:非受控组件可能在某些情况下性能更好,因为它不需要 React 为每次输入更新状态。

    2. URL 状态管理:您将搜索查询保存在 URL 中,而不是组件状态中。这允许用户分享或书签特定的搜索结果。

  • onChange 的作用

    1. 尽管这是一个非受控组件,但仍然使用了 onChange 事件。这里 handleSearch 函数可能用于更新 URL 或触发搜索,而不是更新组件的内部状态。

这种方法结合了非受控组件的简单性和URL状态管理的优势。输入框的值由浏览器管理,而不是 React 状态。搜索逻辑和 URL 更新通过 onChange 事件处理。

更新表格

最后,您需要更新表格组件以反映搜索查询,返回发票页面。页面组件接受一个名为的 prop searchParams,因此您可以将当前URL参数传递给该组件。
编辑 /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({
  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>
  );
}

这些需要简单实现Table表格的逻辑用于验证查询是否成功,创建table页面编辑/app/ui/invoices/table.tsx

import Image from "next/image";
import { formatDateToLocal, formatCurrency } from "@/app/lib/utils";
import { fetchFilteredInvoices } from "@/app/lib/data";

export default async function InvoicesTable({
  query,
  currentPage,
}: {
  query: string;
  currentPage: number;
}) {
  const invoices = await fetchFilteredInvoices(query, currentPage);
  console.log(invoices);

  return <div className="mt-6 flow-root"></div>;
}

输入查询内容之后可以看到在控制台上面有日志输出
日志输出
完成这些更改后,请继续进行测试。如果您搜索某个术语,您将更新URL,这将向服务器发送新请求,服务器将获取数据,并且仅返回与您的查询匹配的发票。

何时使用useSearchParams()钩子,何时使用searchParams道具?您可能已经注意到,您使用了两种不同的方法来提取搜索参数。使用哪种方法取决于您是在客户端还是在服务器上工作。

  • <Search>是一个客户端组件,因此您使用useSearchParams()钩子从客户端访问参数。

  • <Table> 是一个获取其自身数据的服务器组件,因此您可以将searchParamsprop 从页面传递到组件。
    一般来说,如果您想从客户端读取参数,请使用use SearchParams()钩子,因为这样可以避免必须返回服务器。

接下来我们需要实现表格,用于展示我们刚刚获取的数据内容,需要修改/app/ui/invoices/table.tsx

import Image from "next/image";
import { formatDateToLocal, formatCurrency } from "@/app/lib/utils";
import { fetchFilteredInvoices } from "@/app/lib/data";

export default async function InvoicesTable({
  query,
  currentPage,
}: {
  query: string;
  currentPage: number;
}) {
  const invoices = await fetchFilteredInvoices(query, currentPage);

  return (
    <div className="mt-6 flow-root">
      <div className="inline-block min-w-full align-middle">
        <div className="rounded-lg bg-gray-50 p-2 md:pt-0">
          <div className="md:hidden">
            {invoices?.map((invoice) => (
              <div
                key={invoice.id}
                className="mb-2 w-full rounded-md bg-white p-4"
              >
                <div className="flex items-center justify-between border-b pb-4">
                  <div>
                    <div className="mb-2 flex items-center">
                      <Image
                        src={invoice.image_url}
                        className="mr-2 rounded-full"
                        width={28}
                        height={28}
                        alt={`${invoice.name}'s profile picture`}
                      />
                      <p>{invoice.name}</p>
                    </div>
                    <p className="text-sm text-gray-500">{invoice.email}</p>
                  </div>
                </div>
                <div className="flex w-full items-center justify-between pt-4">
                  <div>
                    <p className="text-xl font-medium">
                      {formatCurrency(invoice.amount)}
                    </p>
                    <p>{formatDateToLocal(invoice.date)}</p>
                  </div>
                  <div className="flex justify-end gap-2"></div>
                </div>
              </div>
            ))}
          </div>
          <table className="hidden min-w-full text-gray-900 md:table">
            <thead className="rounded-lg text-left text-sm font-normal">
              <tr>
                <th scope="col" className="px-4 py-5 font-medium sm:pl-6">
                  Customer
                </th>
                <th scope="col" className="px-3 py-5 font-medium">
                  Email
                </th>
                <th scope="col" className="px-3 py-5 font-medium">
                  Amount
                </th>
                <th scope="col" className="px-3 py-5 font-medium">
                  Date
                </th>
                <th scope="col" className="px-3 py-5 font-medium">
                  Status
                </th>
                <th scope="col" className="relative py-3 pl-6 pr-3">
                  <span className="sr-only">Edit</span>
                </th>
              </tr>
            </thead>
            <tbody className="bg-white">
              {invoices?.map((invoice) => (
                <tr
                  key={invoice.id}
                  className="w-full border-b py-3 text-sm last-of-type:border-none [&:first-child>td:first-child]:rounded-tl-lg [&:first-child>td:last-child]:rounded-tr-lg [&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg"
                >
                  <td className="whitespace-nowrap py-3 pl-6 pr-3">
                    <div className="flex items-center gap-3">
                      <Image
                        src={invoice.image_url}
                        className="rounded-full"
                        width={28}
                        height={28}
                        alt={`${invoice.name}'s profile picture`}
                      />
                      <p>{invoice.name}</p>
                    </div>
                  </td>
                  <td className="whitespace-nowrap px-3 py-3">
                    {invoice.email}
                  </td>
                  <td className="whitespace-nowrap px-3 py-3">
                    {formatCurrency(invoice.amount)}
                  </td>
                  <td className="whitespace-nowrap px-3 py-3">
                    {formatDateToLocal(invoice.date)}
                  </td>
                  <td className="whitespace-nowrap px-3 py-3"></td>
                  <td className="whitespace-nowrap py-3 pl-6 pr-3">
                    <div className="flex justify-end gap-3"></div>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>
    </div>
  );
}

列表

现在,我们已经基本实现了查询表格内容了,接下来需要优化一下这个查询防抖

每次击键时,您都会更新URL,因此每次击键时都会查询数据库,这不是问题,因为我们的应用程序很小,但想象一下,如果您的应用程序有数千名用户,每个用户每次击键时都会向您的数据库发送一个新请求。
查询

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

防抖工作原理:

  • 触发事件:当发生需要去抖动的事件(例如搜索框中的击键)时,计时器启动。

  • 等待:如果在计时器到期之前发生新事件,则重置计时器。

  • 执行:如果计时器到达倒计时结束,则执行去抖动功能。

您可以通过几种方式实现去抖动,包括手动创建自己的去抖动函数。为了简单起见,我们将使用一个名为use-debounce。
安装use-debounce: pnpm i use-debounce
安装防抖
安装完成之后,修改/app/ui/search.tsx

  const handleSearch = useDebouncedCallback((term: string) => {
    const params = new URLSearchParams(searchParams);
    if (term) {
      params.set("query", term);
    } else {
      params.delete("query");
    }
    replace(`${pathname}?${params.toString()}`);
  }, 300);

此函数将包装的内容handleSearch,并且仅在用户停止输入后的特定时间(300 毫秒)后运行代码,通过去抖动,您可以减少发送到数据库的请求数量,从而节省资源。

更详细内容查看

独立博客 https://www.dataeast.cn/
CSDN博客 https://blog.csdn.net/siberiaWarpDrive
B站视频空间 https://space.bilibili.com/25871614?spm_id_from=333.1007.0.0
关注 “曲速引擎 Warp Drive” 微信公众号
公众号二维码


评论