拒绝“健忘症”:把 React 状态同步到 URL 里,让你的应用拥有“记忆”

58 阅读6分钟

前言:一个关于“链接失效”的悲伤故事

接上回。你用复合组件模式重构了 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 里:

  1. 当前的 Tab。
  2. 列表的页码(Page=2)。
  3. 筛选条件(Filter=active)。
  4. 搜索关键词(Search=apple)。
  5. 弹窗是否打开(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>
  );
};

发生了什么变化?

  1. 初始化:组件挂载时,不再傻傻地用 0,而是先看 URL 脸色。
  2. 更新:不再 setState,而是 setSearchParams。这一步会修改浏览器地址栏,React Router 监听到 URL 变化,触发组件重渲染。
  3. 结果:现在你在第二个 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?

  1. 敏感信息:Token、密码、身份证号。URL 可能会被记录在服务器日志或浏览器历史里。
  2. 复杂对象:巨大的 JSON 字符串。虽然你可以 Base64 编码后塞进去,但不仅丑,还容易超长。
  3. 高频变化的状态:比如监听页面滚动位置、鼠标坐标。这种一秒钟变 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” 。教你如何用最少的代码,搞定最复杂的表单校验。