为什么我建议你尽量在 NextJs 中尽量少用 useState ❓❓❓

最近在出一个前端的体系课程,里面的内容非常详细,如果你感兴趣,可以加我 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 查询参数可以在服务端请求阶段就被访问到,这使得在 getServerSidePropsgenerateMetadata 或 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 是非常不错的。

通过这样的修改之后:

  1. 页面刷新后状态持久化:查询参数会随着 URL 保留下来,即使页面刷新,URL 中的参数依然存在,页面也能根据这些参数加载用户之前的状态。

  2. 方便分享和跳转:用户可以分享包含查询参数的链接,接收者打开链接时,能准确看到分享者所看到的页面内容,而不仅仅是第一页。

  3. 搜索引擎可抓取:URL 查询参数(如 ?q=javascript&page=2)会被搜索引擎识别和抓取,从而为不同的状态生成独立的索引页面。例如,针对不同搜索关键词或分页的内容,搜索引擎会为每个具体的 URL 提供单独的索引和排名。

  4. 提升页面曝光:每个带有不同查询参数的 URL 都可以被搜索引擎单独索引和展示,这样可以扩大页面在搜索引擎中的覆盖面,吸引更多用户访问。

通过这种方式,不仅可以保持页面状态,还能提高用户体验和分享的便捷性。

这个优化可以通过改用 URL 查询参数 作为状态的存储方式来实现,在 Next.js 中,我们可以使用 useSearchParams 轻松实现该功能,同时保持与服务端渲染(SSR)的兼容。

总结

通过将应用状态绑定到 URL 查询参数,我们能够实现更好的状态持久性、页面分享和 SEO 优化。使用 URL 查询参数作为状态的存储方式,不仅可以避免 useState 导致的刷新后状态丢失问题,还可以使页面状态在浏览器刷新、分享和搜索引擎索引时得以保存。这样的做法提升了用户体验,简化了状态管理,并确保了与服务器端渲染(SSR)的兼容性,是构建现代 Web 应用的最佳实践之一。

相关推荐
崔庆才丨静觅12 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606113 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了13 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅13 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅13 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅14 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment14 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅14 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊14 小时前
jwt介绍
前端
爱敲代码的小鱼14 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax