最近在出一个前端的体系课程,里面的内容非常详细,如果你感兴趣,可以加我 v 进行联系 yunmz777
:

浪费你几秒钟时间,内容正式开始
在构建现代 Web 应用时,我们通常会使用 useState
来管理用户交互产生的状态,比如搜索关键词、筛选条件、分页页码等。然而,在 Next.js(尤其是采用 App Router 架构) 中,其实有一种更推荐、更优雅的方式 ------ 使用 URL 查询参数(search params)来替代 useState
。
这种方式不仅能够让状态与 URL 同步,提升用户体验,也更利于书签保存、页面分享和服务端渲染(SSR)的实现。
为什么要用 URL 查询参数替代 useState
相比使用 useState
管理组件内的状态,在 Next.js(尤其是 App Router 架构) 中,将 UI 状态绑定到 URL 查询参数(search params)是一种更加推荐和现代的做法,原因如下:
-
状态可分享、可外链:通过将搜索条件、筛选项、分页信息等状态写入 URL,用户可以直接复制当前页面链接,分享给他人或保存为书签。打开链接即可还原完整的页面状态,而
useState
中的状态只存在于内存中,刷新或跳转后就会丢失。 -
天然支持 SSR(服务端渲染):URL 查询参数可以在服务端请求阶段就被访问到,这使得在
getServerSideProps
、generateMetadata
或 React Server Components 中获取并处理这些状态变得非常方便。而如果使用useState
,则需要额外的逻辑来同步初始状态,处理起来更为复杂。 -
状态持久性强:URL 中的状态天生具有持久性。用户刷新页面或通过浏览器导航回到某个状态时,URL 中的查询参数依然存在,从而保证了状态不会丢失。而
useState
一旦刷新页面,所有状态都会重置。 -
更有利于 SEO 优化:搜索引擎只能抓取 URL,而不能理解 JavaScript 内存中的
useState
。将状态体现在 URL 上,可以让搜索引擎识别并索引不同的页面状态,从而提升页面在搜索结果中的曝光机会。 -
浏览器导航行为自然:使用 URL 查询参数可以自动集成浏览器的前进/后退历史。当用户点击浏览器返回按钮时,不仅路由会变化,状态也会随之还原,而无需手动处理历史记录。这种行为与用户的预期高度一致。
综上所述,将 UI 状态写入 URL 查询参数,不仅提升了用户体验,也大大简化了状态同步、服务端渲染和 SEO 等开发工作,是现代 Web 应用中更优雅、更健壮的方案。
使用 useState 的例子
接下来我们先使用 useState 来编写一个例子,我们要使用 NextJs 来启动这个 Demo,首先我们要编写一个后端接口:
ts
import { NextResponse } from "next/server";
// 模拟商品数据库
const allProducts = [
{ id: 1, name: "JavaScript 全栈指南", category: "books" },
{ id: 2, name: "TypeScript 精讲", category: "books" },
{ id: 3, name: "无线鼠标", category: "electronics" },
{ id: 4, name: "机械键盘", category: "electronics" },
{ id: 5, name: "React 入门教程", category: "books" },
{ id: 6, name: "MacBook Pro", category: "electronics" },
{ id: 7, name: "Vue.js 框架详解", category: "books" },
{ id: 8, name: "显示器支架", category: "electronics" },
{ id: 9, name: "Node.js 实战", category: "books" },
{ id: 10, name: "iPhone 手机壳", category: "electronics" },
{ id: 11, name: "算法图解", category: "books" },
{ id: 12, name: "蓝牙耳机", category: "electronics" },
];
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const q = searchParams.get("q")?.toLowerCase() || "";
const category = searchParams.get("category") || "all";
const page = parseInt(searchParams.get("page") || "1", 10);
const pageSize = 5;
// 过滤数据
const filtered = allProducts.filter((p) => {
const matchKeyword = p.name.toLowerCase().includes(q);
const matchCategory = category === "all" || p.category === category;
return matchKeyword && matchCategory;
});
const total = filtered.length;
// 分页处理
const start = (page - 1) * pageSize;
const pagedItems = filtered.slice(start, start + pageSize);
return NextResponse.json({ items: pagedItems, total });
}

接下来我们就可以编写我们的 React 代码了:
tsx
"use client";
import { useEffect, useState } from "react";
export default function ProductsPage() {
const [input, setInput] = useState("");
const [keyword, setKeyword] = useState("");
const [category, setCategory] = useState("all");
const [page, setPage] = useState(1);
const [products, setProducts] = useState<{ id: number; name: string }[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const pageSize = 5;
const totalPages = Math.ceil(total / pageSize);
useEffect(() => {
const fetchProducts = async () => {
setLoading(true);
const params = new URLSearchParams({
q: keyword,
page: String(page),
category,
});
const res = await fetch(`/api/products?${params.toString()}`);
const data = await res.json();
setProducts(data.items);
setTotal(data.total);
setLoading(false);
};
fetchProducts();
}, [keyword, category, page]);
return (
<div style={{ padding: 20 }}>
<h1>📦 商品列表(useState + 实际 API)</h1>
<div style={{ marginBottom: 10 }}>
<input
placeholder="搜索商品"
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<button
onClick={() => {
setKeyword(input);
setPage(1);
}}
>
搜索
</button>
</div>
<div style={{ marginBottom: 10 }}>
分类:
{["all", "books", "electronics"].map((cat) => (
<button
key={cat}
style={{
marginLeft: 8,
fontWeight: category === cat ? "bold" : "normal",
}}
onClick={() => {
setCategory(cat);
setPage(1);
}}
>
{cat}
</button>
))}
</div>
<div style={{ marginBottom: 10 }}>
<button
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
上一页
</button>
<span style={{ margin: "0 8px" }}>
第 {page} 页 / 共 {totalPages} 页
</span>
<button
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
下一页
</button>
</div>
{loading ? (
<p>加载中...</p>
) : (
<ul>
{products.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
)}
</div>
);
}
现在我们这里的效果实现了:

还有一个问题,就是返回的第一个 html 文档,它是不存在我们这些商品的信息的,这就会导致我们的 seo 不友好了:

当前的实现存在以下问题:
-
刷新后状态丢失:每次刷新页面时,
useState
中的状态会被重置,因此用户之前的搜索条件、分页信息等都会丢失,必须重新设置。 -
无法分享具体页面状态:如果用户查看到了某一页的商品并想分享链接,当前的页面 URL 不包含搜索词、分类或分页信息。因此,分享的链接无法精确指向用户所看到的具体内容,接收者打开链接后默认会跳转到第一页。
-
SEO 问题:当页面状态(如搜索关键词、分类、分页等)保存在客户端时,搜索引擎无法抓取这些状态,也就无法为不同的查询或页面提供独立的索引和排名。这意味着,即使页面的内容发生变化,搜索引擎也无法识别这些变化,因此会影响页面的排名和曝光,降低站点的 SEO 效果。
URL 查询参数方案
为了解决这些问题,可以通过将查询条件(如搜索关键词、分类、页码等)绑定到 URL 查询参数。我们现在要修改我们的前端代码了:
tsx
import { Suspense } from "react";
import ClientPagination, { CategoryButtons } from "@/components/index";
import ClientSearch from "@/components/search";
type Product = {
id: number;
name: string;
};
type Props = {
searchParams: {
q?: string;
category?: string;
page?: string;
};
};
// 获取商品数据的函数
async function fetchProducts(q: string, category: string, page: number) {
const params = new URLSearchParams({
q,
category,
page: String(page),
});
const res = await fetch(
`http://localhost:3000/api/products?${params.toString()}`
);
const data = await res.json();
return {
items: data.items,
total: data.total,
};
}
export default async function ProductsPage({ searchParams }: Props) {
const resolvedSearchParams = await searchParams;
const { q = "", category = "all", page = "1" } = resolvedSearchParams;
const pageNum = parseInt(page, 10);
const data = await fetchProducts(q, category, pageNum);
const products = data.items;
const total = data.total;
const totalPages = Math.ceil(total / 5);
return (
<div style={{ padding: 20 }}>
<h1>📦 商品列表(SSR + 查询参数)</h1>
<Suspense fallback={<div>Loading...</div>}>
<div>
<ClientSearch q={q} category={category} />
<CategoryButtons q={q} category={category} />
<ul>
{products.map((item: Product) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
</Suspense>
<ClientPagination
pageNum={pageNum}
totalPages={totalPages}
q={q}
category={category}
/>
</div>
);
}
除此之外,我们还需要两个组件:
tsx
"use client";
type PaginationProps = {
pageNum: number;
totalPages: number;
q: string;
category: string;
};
export default function ClientPagination({
pageNum,
totalPages,
q,
category,
}: PaginationProps) {
return (
<div style={{ marginBottom: 10 }}>
<button
disabled={pageNum <= 1}
onClick={() =>
(window.location.href = `/products?q=${q}&category=${category}&page=${
pageNum - 1
}`)
}
>
上一页
</button>
<span style={{ margin: "0 8px" }}>
第 {pageNum} 页 / 共 {totalPages} 页
</span>
<button
disabled={pageNum >= totalPages}
onClick={() =>
(window.location.href = `/products?q=${q}&category=${category}&page=${
pageNum + 1
}`)
}
>
下一页
</button>
</div>
);
}
// 新增分类按钮组件
type CategoryButtonsProps = {
q: string;
category: string;
};
export function CategoryButtons({ q, category }: CategoryButtonsProps) {
return (
<div style={{ marginBottom: 10 }}>
分类:
{["all", "books", "electronics"].map((cat) => (
<button
key={cat}
style={{
marginLeft: 8,
fontWeight: category === cat ? "bold" : "normal",
}}
onClick={() => {
window.location.href = `/products?q=${q}&category=${cat}&page=1`;
}}
>
{cat}
</button>
))}
</div>
);
}
tsx
"use client";
type ClientSearchProps = {
q: string;
category: string;
};
export default function ClientSearch({ q, category }: ClientSearchProps) {
return (
<div style={{ marginBottom: 10 }}>
<input
placeholder="搜索商品"
defaultValue={q}
onChange={(e) => {
const newQ = e.target.value;
window.location.href = `/products?q=${newQ}&category=${category}&page=1`;
}}
/>
<button
onClick={() => {
window.location.href = `/products?q=${q}&category=${category}&page=1`;
}}
>
搜索
</button>
</div>
);
}
如下文件所示:

现在打开浏览器,不仅刷新之后能保存内容到浏览器上面了,还能再第一个 html 文档中获取到了商品的信息了,这样对 SEO 是非常不错的。

通过这样的修改之后:
-
页面刷新后状态持久化:查询参数会随着 URL 保留下来,即使页面刷新,URL 中的参数依然存在,页面也能根据这些参数加载用户之前的状态。
-
方便分享和跳转:用户可以分享包含查询参数的链接,接收者打开链接时,能准确看到分享者所看到的页面内容,而不仅仅是第一页。
-
搜索引擎可抓取:URL 查询参数(如 ?q=javascript&page=2)会被搜索引擎识别和抓取,从而为不同的状态生成独立的索引页面。例如,针对不同搜索关键词或分页的内容,搜索引擎会为每个具体的 URL 提供单独的索引和排名。
-
提升页面曝光:每个带有不同查询参数的 URL 都可以被搜索引擎单独索引和展示,这样可以扩大页面在搜索引擎中的覆盖面,吸引更多用户访问。
通过这种方式,不仅可以保持页面状态,还能提高用户体验和分享的便捷性。
这个优化可以通过改用 URL 查询参数 作为状态的存储方式来实现,在 Next.js 中,我们可以使用 useSearchParams
轻松实现该功能,同时保持与服务端渲染(SSR)的兼容。
总结
通过将应用状态绑定到 URL 查询参数,我们能够实现更好的状态持久性、页面分享和 SEO 优化。使用 URL 查询参数作为状态的存储方式,不仅可以避免 useState
导致的刷新后状态丢失问题,还可以使页面状态在浏览器刷新、分享和搜索引擎索引时得以保存。这样的做法提升了用户体验,简化了状态管理,并确保了与服务器端渲染(SSR)的兼容性,是构建现代 Web 应用的最佳实践之一。