Next.js App Router 中以 URL 为唯一数据源
在 Next.js App Router 中,URL 成为了页面状态的最终依据。与传统方式中同步本地 useState 和地址栏不同,服务端渲染的页面直接读取 searchParams 并立即生成内容。这种方式彻底消除了输入框、数据和导航之间的不一致问题。
以商品搜索为例:当 URL 包含 ?q=phone 时,服务器提取参数 → 发起 API 请求 → 渲染出可直接展示的结果列表。无论是回退/前进导航,还是直接访问链接,行为都高度可预测。
客户端方案的常见陷阱
传统的 React 代码使用 useEffect 会引入多个数据源,导致状态混乱:
"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
构建通用的商品数据层:
// 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:
"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
暂无评论。