
想要实现分页,于是找了一些文章,但内容不多也不太满意。
博客上的文章大多是使用 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 中显示。

现在调用 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()}`);
// 用生成的查询字符串替换地址。
}
// ...
}写下这些代码并在搜索框中输入后,你将看到地址发生更改。

但是相反地址栏输入 '/category?query=这可以吗' 并访问时,输入框中没有任何内容。
为了同步地址栏查询和搜索输入框,在 input 中添加如下代码。
输入 defaultValue 后,通过 query 访问时 input 值会被同步。
<input
// ...
defaultValue={searchParams.get("query")?.toString()}
// ...
/>
但有个问题。
打字时,每输入一个字母或音节都会发生变化。
让输入框在最后输入发生后的 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} />
</>
); // 然后将其渲染为帖子列表。
}
测试显示,帖子会根据查询过滤。
这样搜索功能就完成了。
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>
);
}

通过上面的链接,可以看到根据查询提供的帖子。
现在,我们创建 'use client' 组件。
实现相当麻烦,试交给 Claude 一下。

啊.. 真甜...
果然是 Claude 大哥的 React。
然后选择第一个,查看代码并进行一些更改。

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>
//...
}
没有问题,正常运行。
那么服务器端只需将 totalItem 和 itemsPerPage 正确传递到客户端组件即可。
当然,之前示例中过去的 getPostCategory 需要稍作更改。
5. 结语
为怎么实现分页而苦恼许久,而今一举搞定。
最新趋势是文档比书本更好。
希望我的文章能有所帮助。
댓글을 불러오는 중...