返回首页

URL 作为 Next.js App Router 状态来源

在 Next.js App Router 中,URL 成为页面状态的单一真相来源。服务器组件在渲染前读取 searchParams 并形成内容。客户端表单与浏览器历史同步,无需额外的 useEffect。

URL 控制 Next.js App Router 中的数据
Advertisement 728x90

Next.js App Router 中以 URL 为唯一数据源

在 Next.js App Router 中,URL 成为了页面状态的最终依据。与传统方式中同步本地 useState 和地址栏不同,服务端渲染的页面直接读取 searchParams 并立即生成内容。这种方式彻底消除了输入框、数据和导航之间的不一致问题。

以商品搜索为例:当 URL 包含 ?q=phone 时,服务器提取参数 → 发起 API 请求 → 渲染出可直接展示的结果列表。无论是回退/前进导航,还是直接访问链接,行为都高度可预测。

客户端方案的常见陷阱

传统的 React 代码使用 useEffect 会引入多个数据源,导致状态混乱:

Google AdInline article slot
"use client";

import { useEffect, useState } from "react";

export default function GoodsPage() {
  const [q, setQ] = useState("");
  const [items, setItems] = useState([]);

  useEffect(() => {
    async function load() {
      const res = await fetch(`/api/goods?q=${encodeURIComponent(q)}`);
      const data = await res.json();
      setItems(data.products);
    }

    load();
  }, [q]);

  return (
    <>
      <input value={q} onChange={e => setQ(e.target.value)} />
      <div>{items.map(item => <div key={item.id}>{item.title}</div>)}</div>
    </>
  );
}

带来的后果包括:

  • 直接链接无法恢复状态
  • 页面刷新后数据丢失
  • 回退/前进破坏用户体验
  • 必须手动同步输入框与 URL

服务端模型:先获取数据再渲染

服务端组件在服务端读取 searchParams,并提前获取数据,再进行渲染:

  • URL 存储筛选参数
  • 服务端解析 searchParams
  • 执行带缓存的请求
  • 使用预加载的数据渲染 UI

构建通用的商品数据层:

Google AdInline article slot
// src/app/_data/dummyjson.js
const API_BASE = "https://dummyjson.com";

async function fetchJson(url, fetchOptions = {}) {
  const res = await fetch(url, fetchOptions);

  if (!res.ok) {
    const text = await res.text().catch(() => "");
    const err = new Error(`DummyJSON error: ${res.status} ${res.statusText}. ${text}`);
    err.status = res.status;
    throw err;
  }

  return res.json();
}

export async function getProducts({ q = "", limit = 12, skip = 0 } = {}) {
  const safeQ = String(q).trim();
  const qs = new URLSearchParams({
    limit: String(limit),
    skip: String(skip),
  });

  const url = safeQ
    ? `${API_BASE}/products/search?${qs.toString()}&q=${encodeURIComponent(safeQ)}`
    : `${API_BASE}/products?${qs.toString()}`;

  return fetchJson(url, {
    next: { revalidate: 60 },
  });
}

搜索逻辑被独立封装,页面组件保持简洁专注。

实现服务端页面

// src/app/(app)/goods/page.js
import Link from "next/link";
import { getProducts } from "@/app/_data/goodsApi";
import GoodsSearchBar from "@/app/_ui/GoodsSearchBar";
import GoodsGridMotionClient from "@/app/_ui/GoodsGridMotionClient";

export default async function GoodsPage({ searchParams }) {
  const sp = await searchParams;
  const q = typeof sp?.q === "string" ? sp.q : "";

  const data = await getProducts({ q, limit: 12, skip: 0 });

  const isEmpty = !data?.products?.length;
  const hasFilter = !!String(q || "").trim();

  return (
    <div>
      <h1>商品列表</h1>
      <GoodsSearchBar initialQuery={q} />

      {isEmpty ? (
        <div>
          <h2>未找到结果</h2>

          {hasFilter ? (
            <p>没有匹配 "{q}" 的商品</p>
          ) : (
            <p>列表为空</p>
          )}

          <Link href="/goods">查看全部商品</Link>
        </div>
      ) : (
        <GoodsGridMotionClient products={data.products} />
      )}
    </div>
  );
}

URL 完全决定了页面内容。

客户端搜索与 URL 同步

输入仍保留在客户端,但仅在提交后更新 URL:

Google AdInline article slot
"use client";

import { useEffect, useMemo, useState } from "react";

import { useRouter, useSearchParams } from "next/navigation";

function getFirstQ(sp) {
  const all = sp.getAll("q");
  return all[0] ?? "";
}

export default function GoodsSearchBar({ basePath = "/goods" }) {
  const router = useRouter();
  const sp = useSearchParams();

  const qFromUrl = getFirstQ(sp);
  const [value, setValue] = useState(qFromUrl);

  useEffect(() => {
    setValue(qFromUrl);
  }, [qFromUrl]);

  function buildHref(nextQ) {
    const q = nextQ.trim();
    return q ? `${basePath}?q=${encodeURIComponent(q)}` : basePath;
  }

  function onSubmit(e) {
    e.preventDefault();
    router.push(buildHref(value));
  }

  function onReset() {
    router.push(basePath);
  }

  const canSearch = useMemo(() => value.trim().length > 0, [value]);

  return (
    <form onSubmit={onSubmit}>
      <input
        value={value}
        onChange={e => setValue(e.target.value)}
        placeholder="例如:手机"
      />

      <button type="submit" disabled={!canSearch}>
        搜索
      </button>

      <button type="button" onClick={onReset}>
        重置
      </button>
    </form>
  );
}

关键点:

  • useSearchParams() 确保输入与浏览器历史同步
  • router.push() 更改 URL 而不触发客户端请求
  • getFirstQ() 可处理重复查询参数

这种架构的优势

  • 单一数据源:URL 定义页面状态
  • 直接链接/goods?q=phone 始终返回相同结果
  • 导航体验:回退/前进精准恢复状态
  • 服务端渲染:数据在挂载前已加载完成
  • 调试友好:状态清晰可见于 URL 中

何时避免使用 URL 参数

适合用 URL 参数的场景:

  • 搜索关键词
  • 排序与筛选
  • 分页
  • 视图模式切换

更适合保留在客户端状态的:

  • 下拉菜单与弹窗
  • 动画效果
  • 临时输入内容
  • 悬停或聚焦状态

三个必须避免的关键错误

  • 双重状态无同步:混合使用 useState 与 URL 却未协调
  • 服务端组件内执行客户端请求:通过 useEffect 在服务端发起 fetch
  • 提交后仍以输入框为主数据源:应以 URL 为准

最重要的一点:

  • App Router 中,URL 应定义页面数据
  • 服务端组件应在渲染前读取 searchParams
  • 客户端输入需通过 useSearchParams() 同步
  • 多个参数请使用 getAll() 处理

— Editorial Team

Advertisement 728x90

继续阅读