前言:一个关于“链接失效”的悲伤故事
接上回。你用复合组件模式重构了 Tabs 组件,代码优雅得像首诗。你把它部署上线,发给了产品经理(PM)。
PM 点开链接,切换到“系统设置” Tab,然后把此时浏览器地址栏里的链接复制发给了老板,说:“老板,你看这个设置页面的新功能。”
老板点开链接,页面加载完毕,显示的是……默认的“用户管理” Tab。 老板:“你让我看啥?这不还是老样子吗?” PM:“哎?不对啊,我明明切过去了……”
这时候,你才意识到问题的严重性:你的应用得了**“健忘症”**。 用户刷新页面,状态重置;用户分享链接,状态丢失;用户点浏览器“后退”按钮,直接退出了网页,而不是回到上一个 Tab。
今天,我们要聊聊 URL as State(URL 即状态)。我们要把 React 的状态(useState)搬家到浏览器地址栏(URL Search Params)里去。
观念转变:URL 不只是用来做路由的
很多前端(特别是单页应用时代的)都有个误区:
URL 的 path(比如 /users/123)是用来决定渲染哪个页面的。
而 URL 的 query(比如 ?tab=settings&search=jack)……往往被忽略了,或者只在搜索页用一下。
其实,URL 是 Web 应用的“唯一真理来源 (Single Source of Truth)”。
这就好比玩游戏存盘。
useState 是内存里的存档,一断电(刷新)就没了。
URL 是写在硬盘里的存档,什么时候读档(打开链接)都在。
只要是影响页面展示内容的状态,都应该考虑同步到 URL 里:
- 当前的 Tab。
- 列表的页码(Page=2)。
- 筛选条件(Filter=active)。
- 搜索关键词(Search=apple)。
- 弹窗是否打开(Modal=true)。
实战演练:改造 Tabs 组件
还记得上一篇那个用 Context 做的 Tabs 吗?当时我们是这样管理状态的:
// ❌ 以前的写法:状态死在组件里
const Tabs = ({ children, defaultIndex = 0 }) => {
// 这个 state,刷新就没了
const [selectedIndex, setSelectedIndex] = useState(defaultIndex);
return (
<TabsContext.Provider value={{ selectedIndex, setSelectedIndex }}>
{children}
</TabsContext.Provider>
);
};
现在,我们要把这个 useState 换成 React Router 的 useSearchParams。
✅ 改造后的写法:状态活在 URL 里
const Tabs = ({ children, defaultValue = 'profile', queryKey = 'tab' }) => {
// 1. 拿到 URL 里的参数
const [searchParams, setSearchParams] = useSearchParams();
// 2. 读状态:如果 URL 里有 ?tab=settings,就用 settings,否则用默认值
const activeTab = searchParams.get(queryKey) || defaultValue;
// 3. 写状态:更新 URL,组件会自动感知变化并重渲染
const setActiveTab = (newValue) => {
// ⚠️ 注意:我们要保留其他可能存在的参数(比如 ?page=2),不能把它们覆盖了
setSearchParams(prev => {
prev.set(queryKey, newValue);
return prev;
}, { replace: true }); // 💡 小技巧:用 replace 而不是 push,防止点“后退”全是切换 Tab 的记录
};
return (
<TabsContext.Provider value={{ selectedIndex: activeTab, setSelectedIndex: setActiveTab }}>
{children}
</TabsContext.Provider>
);
};
发生了什么变化?
- 初始化:组件挂载时,不再傻傻地用
0,而是先看 URL 脸色。 - 更新:不再
setState,而是setSearchParams。这一步会修改浏览器地址栏,React Router 监听到 URL 变化,触发组件重渲染。 - 结果:现在你在第二个 Tab 刷新页面,URL 里的
?tab=settings还在,组件读到的初始值就是settings。完美复原案发现场。
进阶技巧:这不仅仅是 Tabs 的事
这个模式威力巨大,可以用在很多地方。
场景一:带搜索和筛选的列表页
你肯定写过这样的代码:
const [status, setStatus] = useState('all');
const [page, setPage] = useState(1);
// 然后写一堆 useEffect 去监听它们变化并发请求
useEffect(() => { fetchList({ search, status, page }) }, [search, status, page]);
改成 URL 驱动后:
// 任何状态变化,直接去改 URL
// 然后用 React Query 监听 params 变化自动发请求
const { data } = useQuery({
queryKey: ['list', params.toString()], // key 变了自动请求
queryFn: () => fetchList(Object.fromEntries(params)),
});
你的代码里甚至连一个 useState 都不需要了!所有的状态管理都交给了浏览器地址栏。用户可以直接复制链接分享给同事:“你看这个筛选结果。”
场景二:弹窗 (Modal)
这可能是最反直觉的。弹窗也能用 URL 控制? 当然!
http://myapp.com/projects?createModal=true
当 URL 里有 createModal=true 时,渲染弹窗。关闭弹窗时,把这个参数删掉。 好处? 用户在弹窗里填了一半表单,不小心按了浏览器的“刷新”。 如果是 useState 控制的弹窗,刷新后弹窗没了,用户一脸懵逼。 如果是 URL 控制的弹窗,刷新后弹窗依然开着(甚至可以通过 URL 保存表单的部分状态),用户体验满分。
避坑指南:不要把整个 Redux 塞进 URL
虽然 URL 很香,但别走火入魔。URL 有长度限制(通常 2000 字符左右),而且太长了很难看。
什么不该放 URL?
- 敏感信息:Token、密码、身份证号。URL 可能会被记录在服务器日志或浏览器历史里。
- 复杂对象:巨大的 JSON 字符串。虽然你可以 Base64 编码后塞进去,但不仅丑,还容易超长。
- 高频变化的状态:比如监听页面滚动位置、鼠标坐标。这种一秒钟变 60 次的,放 URL 里浏览器会卡死。
最佳实践库推荐: 如果你在用 Next.js 或者想更优雅地处理 URL 状态(比如自动把 URL 里的字符串 "true" 转成布尔值 true,把 "10" 转成数字 10),强烈推荐使用 nuqs (原 next-usequerystate) 这个库。它让 URL State 用起来就像 useState 一样丝滑。
总结
URL 是 Web 的灵魂。 一个好的 Web 应用,应该让用户随时随地可以刷新、后退、分享,而不会丢失当前的上下文。
当你下次设计组件状态时,先别急着写 useState。停下来想一想:“这个状态值得被分享吗?值得被记住吗?” 如果是,请把它放进 URL 里。
这不仅是写代码的技巧,这是对用户体验最起码的尊重。
好了,我要去把那个一刷新就回到首页的后台管理系统给修了。
下期预告:Tabs 状态是保住了,但如果用户在 Tab 里填了一个很长的表单,还没点保存就手滑关了网页怎么办? 各种
onChange手写表单逻辑是不是让你写到手抽筋?校验逻辑更是写成了一坨浆糊? 下一篇,我们来聊聊 “现代表单解决方案:React Hook Form + Zod” 。教你如何用最少的代码,搞定最复杂的表单校验。