之前做的搜索页被用户吐槽过三件事:搜了关键词、点进结果、按返回------搜索状态没了;想把搜索结果发给同事------没链接可发;刷新页面------回到空白。三个问题的根因是同一个:搜索状态存在组件里,没同步到 URL。这篇记录把搜索页状态搬到 URL 的完整做法。
核心规则:URL 是搜索状态的唯一来源
搜索状态包括:关键词 q、页码 p、市场/语言(hl/gl)、排序、筛选。把它们全部放进 URL,收益立刻兑现:
- 可分享:复制地址栏就是分享链接
- 可回退:浏览器前进后退天然工作
- 可刷新:F5 之后状态还在
- 可 SSR:服务端能读到参数,直接渲染带结果的首屏(对 SEO 友好)
反过来说,别再用 useState 存一份------两份状态必然不同步。
读取:从 URL 派生状态
tsx
"use client";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
function useSearchState() {
const sp = useSearchParams();
const router = useRouter();
const pathname = usePathname();
// 状态全部从 URL 派生,不做本地副本
const q = sp.get("q") ?? "";
const page = Number(sp.get("p") ?? 1);
const hl = sp.get("hl") ?? "zh-cn";
const gl = sp.get("gl") ?? "cn";
const setParams = (
patch: Record<string, string | number | undefined>,
mode: "push" | "replace" = "replace",
) => {
const next = new URLSearchParams(sp.toString());
for (const [k, v] of Object.entries(patch)) {
if (v === undefined || v === "" || v === null) next.delete(k);
else next.set(k, String(v));
}
const url = `${pathname}?${next.toString()}`;
mode === "push" ? router.push(url) : router.replace(url);
};
return { q, page, hl, gl, setParams };
}
两个关键设计:
- 默认值不写入 URL :
hl=zh-cn、p=1是默认值就删掉参数,链接短、缓存命中率高 replace和push分开 :输入过程用replace,点「搜索」用push
写回:输入 replace,提交 push
这是最容易搞错的地方。如果每敲一个字都 push 一条历史,返回按钮就废了------用户要按 20 次才退得回去。
tsx
function SearchInput({ value, onSubmit }: { value: string; onSubmit: (v: string) => void }) {
const [text, setText] = useState(value);
// 输入防抖 + replace:不污染历史
useEffect(() => {
const t = setTimeout(() => {
if (text !== value) onSubmit(text.trim()); // 内部走 setParams(patch, "replace")
}, 300);
return () => clearTimeout(t);
}, [text]);
return (
<input
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onSubmit(text.trim())}
placeholder="搜索关键词"
/>
);
}
配合 router.replace 做草稿态,回车/点按钮时 router.push 落一个「历史锚点」------这样返回按钮退回的是上一次提交的查询,而不是上一个字符。
取数:以 URL 参数为缓存键
状态在 URL 后,数据请求直接以参数为 key,天然享受缓存:
tsx
const key = ["/api/serp", q, page, hl, gl];
const { data, isLoading } = useSWR(key, fetcher, { keepPreviousData: true });
- 返回上一个查询 → key 命中缓存,不重新请求
- 翻页/切市场 → key 变化,自动请求新数据
keepPreviousData让翻页时旧结果保留,不闪空白
分享与 SEO:两件小事
复制分享:一行搞定。
tsx
<button onClick={() => navigator.clipboard.writeText(location.href)}>
复制结果链接
</button>
服务端首屏 :分享链接被爬虫/用户打开时,希望直接看到结果。Next.js App Router 里页面组件能读到 searchParams:
tsx
export default async function Page({ searchParams }) {
const q = searchParams.q as string | undefined;
const initial = q ? await fetchSerp(q) : null; // 服务端调搜索数据接口
return <SearchPage q={q} initialData={initial} />;
}
服务端取数这一步,用搜索数据接口(比如 SerpBase 的 /google/search 端点)在服务端发起,Key 天然不暴露给浏览器,首屏直接带结果。
踩坑记录
坑 1:useState + URL 双份状态。 输入框里是 state,URL 里是另一份,改一处忘一处,行为诡异。只留 URL 一份,输入框用本地草稿态 + 防抖回写。
坑 2:每敲一键 push 历史。 返回按钮要按几十次。输入用 replace,提交用 push。
坑 3:忘记 encodeURIComponent。 中文关键词、空格、& 直接拼进 URL 会截断或乱码。用 URLSearchParams 构造,它会正确编码。
坑 4:读取 window.location 导致水合报错。 SSR 首屏没有 window,客户端 hydration 时值不一致。用框架的 useSearchParams(SSR 安全)而不是手读 window.location。
坑 5:默认值全写进 URL。 ?q=x&p=1&hl=zh-cn&gl=cn&sort=relevance------长、丑、缓存全 miss。默认值省略。
工程清单
- URL 是搜索状态的唯一来源,不做本地副本
- 输入
replace、提交push,历史干净 - 防抖 300ms,别每键请求/写历史
- 默认值不写 URL,参数用
URLSearchParams编码 - 取数以 URL 参数为缓存键,返回时命中缓存
- 服务端读
searchParams渲染首屏,分享链接即可用
URL 状态管理这件事,本质上是把「页面当前显示什么」变成「地址栏里写着什么」------前者只对当前用户可见,后者可分享、可回退、可爬取。搜索页尤其适用:搜索结果本来就是「可引用」的内容。
接口参数(q、page、hl/gl 这些)和 URL 参数一一对应,字段说明在 SerpBase 官方文档。你们的搜索页状态存在哪,URL 还是内存?评论区聊聊踩过的坑。