在 Nextjs 中实现分页

힘센캥거루
2025년 1월 5일(수정됨)
69
nextjs
在 Nextjs 中实现分页-1

想要实现分页,于是找了一些文章,但内容不多也不太满意。

博客上的文章大多是使用 useState 和 API 的例子。

这种方式虽然容易实现,但无法利用服务器端渲染的优势。

今天,我们将在 Nextjs 中通过服务器端渲染 (SSR) 来实现分页。

参考的文章仍然是 官方文档

1. SSR 分页理念

这篇文章的核心是让页面组件接收查询字符串。

利用查询字符串有以下优点:

  • 书签和 URL 共享:由于搜索参数在地址栏中,可以共享或添加书签

  • 服务器端渲染:可以在服务器上直接使用来渲染初始状态

  • 分析和追踪:无需额外的客户端代码即可追踪用户行为

现在来看代码。

可以从 props 中解析查询字符串为 searchParams。

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>
      <p>searchParams</p>
      <p>currentPage</p>
    </div>
  );
}

如果我访问 '/test?page=1&query="强壮袋鼠"',将在 div 内看到查询输出。

现在只需要料理好这个查询即可。

2. 实现搜索框

直接从官方文档中获取搜索组件。

这里只是为了在输入搜索词时在浏览器的 console.log 中显示。

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

然后,我将其放入 layout 组件中。

可以放入任何组件中,不受限制。

export default async function Layout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="mx-auto max-w-screen-xl w-full prose">
      <div className="flex justify-between">
        <div>
          <h1>列表</h1>
        </div>
        <div className="w-80">
          <Search placeholder="请输入搜索词" /> // 搜索框
        </div>
      </div>
      <div className="mb-8">
        <TagGroup path="/category" tags={tagsArray} />
      </div>
      <div>{children}</div>
    </div>
  );
}

现在输入搜索词并检查 console.log。

你会发现每次输入时都会在 console.log 中显示。

在 Nextjs 中实现分页-2

现在调用 useSearchParams、usePathname、useRouter 并创建对象。

参考下面的代码和注释。

"use client";

import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { useSearchParams, usePathname, useRouter } from "next/navigation";

export default function Search() {
  const searchParams = useSearchParams();
  // 调用 useSearchParams
  const pathname = usePathname();
  // 通过 usePathname 使用现有的 url 路径
  const { replace } = useRouter();
  // 通过 useRouter 的 replace 替换现有地址
  function handleSearch(term: string) {
    const params = new URLSearchParams(searchParams);
    // 使用内置函数 URLSearchParams 创建查询字符串
    if (term) {
      params.set("query", term); // 有输入值则重置
    } else {
      params.delete("query"); // 没有输入值则删除
    }
    replace(`${pathname}?${params.toString()}`);
    // 用生成的查询字符串替换地址。
  }
  // ...
}

写下这些代码并在搜索框中输入后,你将看到地址发生更改。

在 Nextjs 中实现分页-3

但是相反地址栏输入 '/category?query=这可以吗' 并访问时,输入框中没有任何内容。

为了同步地址栏查询和搜索输入框,在 input 中添加如下代码。

输入 defaultValue 后,通过 query 访问时 input 值会被同步。

<input
  // ...
  defaultValue={searchParams.get("query")?.toString()}
  // ...
/>
在 Nextjs 中实现分页-4

但有个问题。

打字时,每输入一个字母或音节都会发生变化。

让输入框在最后输入发生后的 300 毫秒后再执行。

这就是防抖,安装一个库会让你省心很多。

yarn add use-debounce

然后将代码更改如下。

调用防抖后,用 useDebouncedCallback 包裹 handleSearch 函数。

最后输入 300,最后输入完成 300 ms 后地址更改。

// ...
import { useDebouncedCallback } from "use-debounce";

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

现在服务器端只需解析查询并返回所需信息即可。

3. 服务器端搜索功能实现

服务器端实现可能因人而异。

在我这边,当前正以 JSON 形式保存各个 mdx 的元数据。

然后通过地址栏的 query 进行过滤后返回到组件。

import getPostCategory from "@/utils/getPostCategory";
import CategoryPage from "@/components/blog/CategoryPage";
import { postCardObject } from "@/types/allTypes"; // 这是我自定义的类型。

export default async function Page(props: {
  searchParams?: Promise<{
    query?: string;
    page?: string;
  }>;
}) {
  const searchParams = await props.searchParams;
  const query = searchParams?.query?.toLowerCase() || "";
  const currentPage = Number(searchParams?.page) || 1;
  // 解析查询,尚未实现分页。
  // 为避免区分大小写,将所有转换为小写。

  let posts: postCardObject[] = await getPostCategory();
  // 获取所有帖子数据。

  if (query) {
    posts = posts.filter((post) => {
      const title = post.data.title.toLowerCase();
      return title.includes(query);
    });
  }
  // 如果有查询,则过滤并重新赋值 posts。
  // 以后将在 getPostCategory 内部干净地处理,并通过参数传递 query。

  return (
    <>
      <CategoryPage postCategorys={posts} />
    </>
  ); // 然后将其渲染为帖子列表。
}
在 Nextjs 中实现分页-5

测试显示,帖子会根据查询过滤。

这样搜索功能就完成了。

4. 实现分页

分页与实现搜索框相同。

通过 useSearchParams、useRouter、usePathname 将点击页面的查询字符串传递。

有一点不同。

需要在组件中预先指定页面显示的帖子数和总页数。

export default async function getPostCategory(
  perPagePosts: number,
  page = 1,
  tag?: string
) {
  // ...
  const totalPage = Math.ceil(postCategorys.length / perPagePosts);
  postCategorys = postCategorys.slice(
    0 + perPagePosts * (page - 1),
    perPagePosts + perPagePosts * (page - 1)
  );
  // ...
  return { postCategorys, totalPage };
}

在获取帖子数据的函数中,每页截取 6 篇帖子并返回总页数。

然后从总页数除以 10 来创建页面。

先用 Link 大致测试看看。

export default async function Page(props: {
  searchParams?: Promise<{
    query?: string;
    page?: string;
  }>;
}) {
  // ...
  return (
    <div>
      <CategoryPage postCategorys={postCategorys} />
      <div>
        {Array.from({ length: totalPage }, (_, i) => i + 1).map((i) => (
          <Link
            key={i}
            className={`mx-3 bg-gray-200 ${
              currentPage === i ? "bg-gray-700 text-white" : ""
            }`}
            href={`?page=${i}`}
          >
            第 {i} 页
          </Link>
        ))}
      </div>
    </div>
  );
}
在 Nextjs 中实现分页-6

通过上面的链接,可以看到根据查询提供的帖子。

现在,我们创建 'use client' 组件。

实现相当麻烦,试交给 Claude 一下。

在 Nextjs 中实现分页-7

啊.. 真甜...

果然是 Claude 大哥的 React。

然后选择第一个,查看代码并进行一些更改。

在 Nextjs 中实现分页-8
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
import { useSearchParams, usePathname, useRouter } from "next/navigation";

export default function Pagination({
  totalItems,
  itemsPerPage = 10,
  className = "",
}: {
  totalItems: number;
  itemsPerPage: number;
  className?: string;
}) {
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const { replace } = useRouter();
  const currentPage = Number(searchParams.get("page")) || 1;

  const totalPages = Math.ceil(totalItems / itemsPerPage);

  // 将点击页面的值放入地址栏查询中
  function handlePageChange(page: number) {
    if (page >= 1 && page <= totalPages) {
      const params = new URLSearchParams(searchParams);
      params.set("page", page.toString());
      replace(`${pathname}?${params.toString()}`);
    }
  }

  // 创建呈现的页面数组
  function renderPageNumbers() {
    const pages = [];
    const showAroundCurrent = 2;

    const startPage: number = Math.max(1, currentPage - showAroundCurrent);
    const endPage: number = Math.min(
      totalPages,
      currentPage + showAroundCurrent
    );

    // 处理开头部分
    if (startPage > 1) {
      pages.push(1);
      if (startPage > 2) {
        pages.push("...");
      }
    }

    // 当前页面附近的数字
    for (let i = startPage; i <= endPage; i++) {
      pages.push(i);
    }

    // 处理结尾部分
    if (endPage < totalPages) {
      if (endPage < totalPages - 1) {
        pages.push("...");
      }
      pages.push(totalPages);
    }

    return pages;
  }

  // return 下的就看个大概好了。
  return (
    <div className={`flex items-center justify-center space-x-2 ${className}`}>
      <button
        onClick={() => handlePageChange(currentPage - 1)}
        disabled={currentPage === 1}
        className="p-2 rounded-lg hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
        aria-label="Previous page"
      >
        <ChevronLeftIcon className="w-5 h-5" />
      </button>

      {renderPageNumbers().map((page, index) => (
        <button
          key={index}
          onClick={() => typeof page === "number" && handlePageChange(page)}
          disabled={page === "..."}
          className={`
            px-4 py-2 rounded-lg transition-colors duration-200
            ${typeof page === "number" ? "hover:bg-gray-100" : ""}
            ${
              page === currentPage
                ? "bg-blue-500 text-white hover:bg-blue-600"
                : ""
            }
            ${page === "..." ? "cursor-default" : "cursor-pointer"}
            disabled:cursor-not-allowed
          `}
          aria-label={typeof page === "number" ? `Page ${page}` : "More pages"}
          aria-current={page === currentPage ? "page" : undefined}
        >
          {page}
        </button>
      ))}

      <button
        onClick={() => handlePageChange(currentPage + 1)}
        disabled={currentPage === totalPages}
        className="p-2 rounded-lg hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
        aria-label="Next page"
      >
        <ChevronRightIcon className="w-5 h-5" />
      </button>
    </div>
  );
}

然后在 category 的 layout 中进行测试,添加分页。

我随意设定总帖子数为 100,每页帖子数为 6。

// ...
export default async function Page(props: {
  searchParams?: Promise<{
    query?: string;
    page?: string;
  }>;
}) {
// ...
      <div>
        <Pagenation totalItems={100} itemsPerPage={6} />
      </div>
      <div>分页测试中</div>
//...
}
在 Nextjs 中实现分页-9

没有问题,正常运行。

那么服务器端只需将 totalItem 和 itemsPerPage 正确传递到客户端组件即可。

当然,之前示例中过去的 getPostCategory 需要稍作更改。

5. 结语

为怎么实现分页而苦恼许久,而今一举搞定。

最新趋势是文档比书本更好。

希望我的文章能有所帮助。

댓글을 불러오는 중...