为什么我建议你尽量在 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 应用的最佳实践之一。

相关推荐
寅时码几秒前
我开源了一款 Canvas “瑞士军刀”,十几种“特效与工具”开箱即用
前端·开源·canvas
CF14年老兵2 分钟前
🚀 React 面试 20 题精选:基础 + 实战 + 代码解析
前端·react.js·redux
CF14年老兵3 分钟前
2025 年每个开发人员都应该知道的 6 个 VS Code AI 工具
前端·后端·trae
十五_在努力7 分钟前
参透 JavaScript —— 彻底理解 new 操作符及手写实现
前端·javascript
典学长编程19 分钟前
前端开发(HTML,CSS,VUE,JS)从入门到精通!第四天(DOM编程和AJAX异步交互)
javascript·css·ajax·html·dom编程·异步交互
拾光拾趣录22 分钟前
🔥99%人答不全的安全链!第5问必翻车?💥
前端·面试
IH_LZH26 分钟前
kotlin小记(1)
android·java·前端·kotlin
lwlcode34 分钟前
前端大数据渲染性能优化 - 分时函数的封装
前端·javascript
Java技术小馆35 分钟前
MCP是怎么和大模型交互
前端·面试·架构
玲小珑39 分钟前
Next.js 教程系列(二十二)代码分割与打包优化
前端·next.js