Skip to content

从 useState 到 URLState:为什么大佬们都在删状态管理代码? #379

@mqyqingfeng

Description

@mqyqingfeng

1. 前言

当你打开这个网址时:

https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript&plugins=line-numbers

你会发现,所有你需要的主题、语言、插件已经被自动勾选:

当你在页面修改配置时,URL 也会随之改变。

你看,这个 URL 不仅仅是一个链接,更是一个完整的状态容器,保存了我的所有配置。无需数据库、cookie 或 localStorage,一个 URL 就解决了一切。

2. 被忽视的 URL 超能力

URL 是互联网最伟大的创意之一,通过 URL 请求,我们可以查找到网络上的唯一资源。

它的标准格式为:<scheme>://<netloc>/<path>?<query>#<fragment>

但 URL 的价值远不止于此——它们是天然的状态管理解决方案。想想 URL 给我们带来的好处:

  • 可分享性:发送链接,对方会看到与你完全相同的内容
  • 可书签化:保存 URL 就是保存一个特定时刻的状态
  • 浏览器历史:后退按钮正常工作
  • 深度链接:直接跳转到应用的特定状态

URL 使 Web 应用具有韧性和可预测性。它们是 Web 最初的状态管理方案,自 1990 年以来就开始使用,所以千万不要忘记使用这种方式。

3. URL 如何编码状态?

URL 的不同部分编码不同类型的状态:

路径段(/path/to/myfile.html):最适合层次化资源导航

/users/123/posts        # 用户123的文章
/docs/api/authentication # 文档结构

查询参数(?key1=value1&key2=value2):完美用于过滤器、选项和配置

?theme=dark&lang=en     # UI 偏好设置
?page=2&limit=20        # 分页
?status=active&sort=date # 数据过滤

锚点片段(#SomewhereInTheDocument):适合客户端导航和页面部分

#L20-L35        # GitHub 行高亮
#features       # 滚动到某个章节

4. URL 编码状态常见模式

4.1. 多个带分隔符的值

?languages=javascript+typescript+python
?tags=frontend,react,hooks

这种方式简洁易读,但需要在服务器端手动解析。

4.2. 嵌套或结构化数据

?filters=status:active,owner:me,priority:high
?config=eyJyaWNrIjoicm9sbCJ9==  (base64-encoded JSON)

开发者有时会将复杂的筛选器或配置对象编码到单个查询字符串中。

一种简单的约定是使用逗号分隔的键值对,而其他方法则会序列化 JSON,甚至为了安全起见对其进行 Base64 编码。

4.3. 数组处理(方括号表示法)

?tags[]=frontend&tags[]=react&tags[]=hooks
?ids[0]=42&ids[1]=73

一种古老的模式是方括号表示法,它用于在查询参数中表示数组。这种表示法起源于早期的 Web 框架,例如 PHP,在 [] 参数名称后添加括号表示多个值应该组合在一起。

许多现代框架和解析器(例如 Node 的 qs 库或 Express 中间件)仍然能够自动识别这种模式。然而,它并未在 URL 规范中正式标准化,因此其行为可能因服务器或客户端的实现而异。

4.4. 布尔处理

对于 flag 或开关,通常会显式传递布尔值,或者依赖于键值是否为真。这样可以缩短 URL 长度,并简化功能切换。

?debug=true&analytics=false
?mobile  (presence = true)

4.5. 结论

使用哪种模式都是可以的,关键在于保持一致性。选择适合你应用场景的模式,并坚持使用。

5. 实际应用案例

GitHub 行高亮:

https://github.com/zepouet/Xee-xCode-4.5/blob/master/XeePhotoshopLoader.m#L108-L136

链接到特定文件,同时高亮显示 108-136 行。点击此链接,你会直接定位到讨论的确切代码部分。

电商数据过滤器:

https://store.com/laptops?brand=dell+hp&price=500-1500&rating=4&sort=price-asc

这是最常见的实现。每个过滤条件、排序选项都被保存。用户可以用书签保存他们的筛选条件。

谷歌地图:

https://www.google.com/maps/@22.443842,-74.220744,19z

坐标、缩放级别和地图类型都包含在 URL 中。分享此链接,任何人都可以看到完全相同的地图视图。

6. 什么状态应该放入 URL?

然而并非所有状态都应该属于 URL,那什么样的状态应该放入 URL 呢?

适合 URL 状态:

  • 搜索查询和筛选器
  • 分页和排序
  • 视图模式(列表/网格、深色/浅色)
  • 日期范围和时间段
  • 选中项或活动标签
  • 影响内容的 UI 配置
  • 功能开关和 A/B 测试版本

不适合 URL 状态:

  • 敏感信息(密码、令牌、个人身份信息)
  • 临时 UI 状态(模态框打开/关闭)
  • 表单输入进行中(未保存的更改)
  • 极其庞大或复杂的嵌套数据
  • 高频瞬态(鼠标位置、滚轮位置)

简单来说,你的判断标准是:

如果别人点击这个 URL,他们应该看到相同的状态吗?

如果是,它就属于 URL。

7. 实现方案

7.1. 使用纯 JavaScript 实现

现代 URLSearchParams API 使 URL 状态管理变得简单:

// 读取URL参数
const params = new URLSearchParams(window.location.search);
const view = params.get("view") || "grid"; // 默认值
const page = parseInt(params.get("page")) || 1;

// 更新URL参数
function updateFilters(filters) {
  const params = new URLSearchParams(window.location.search);

  params.set("status", filters.status);
  params.set("sort", filters.sort);

  // 更新URL而不重新加载页面
  const newUrl = `${window.location.pathname}?${params.toString()}`;
  window.history.pushState({}, "", newUrl);
}

// 处理后/前进按钮
window.addEventListener("popstate", () => {
  const params = new URLSearchParams(window.location.search);
  const filters = {
    status: params.get("status") || "all",
    sort: params.get("sort") || "date",
  };
  renderContent(filters);
});

7.2. 使用 React 实现

React Router 提供了更简洁的钩子:

import { useSearchParams } from "react-router-dom";

function ProductList() {
  const [searchParams, setSearchParams] = useSearchParams();

  const color = searchParams.get("color") || "all";
  const sort = searchParams.get("sort") || "price";

  const handleColorChange = (newColor) => {
    setSearchParams((prev) => {
      const params = new URLSearchParams(prev);
      params.set("color", newColor);
      return params;
    });
  };

  return (
    <select value={color} onChange={(e) => handleColorChange(e.target.value)}>
      <option value="all">所有颜色</option>
      <option value="silver">银色</option>
    </select>
  );
}

8. URL 使用最佳实践

8.1. 优雅处理默认值

不要在 URL 中使用默认值:

// ❌
?theme=light&lang=en&page=1&sort=date

// ✅
?theme=dark  // light 是默认的,但 dark 不是默认的

在代码中读取参数时使用默认值:

function getTheme(params) {
  return params.get("theme") || "light"; // 在代码中设置默认值
}

8.2. URL 更新防抖动

对于高频更新(例如边输入边搜索),要对 URL 更改进行防抖处理:

import { debounce } from "lodash";

const updateSearchParam = debounce((value) => {
  const params = new URLSearchParams(window.location.search);
  if (value) {
    params.set("q", value);
  } else {
    params.delete("q");
  }
  window.history.replaceState({}, "", `?${params.toString()}`);
}, 300);

8.3. URL 传达意义

https://example.com/p?id=x7f2k&v=3 ❌
https://example.com/products/laptop?color=silver&sort=price ✅

第一个链接隐藏了意图,第二个链接则意义清晰。人可以阅读它并理解其含义。机器可以解析它并提取有意义的结构。这才是优秀的 URL。

9. 使用时要避免的反模式

9.1. 状态都保存在内存中的单页应用程序

// 用户一刷新,状态都丢失了
const [filters, setFilters] = useState({});

如果你的应用在刷新后丢失了之前的状态,你就破坏了网络的一项基本功能。用户期望 URL 能够保留上下文。

9.2. 包含敏感数据

// 别这样干
?password=secret123

9.3. 命名不一致或晦涩难懂

// 晦涩难懂
?foo=true&bar=2&x=dark

// 自文档化且风格保持一致
?mobile=true&page=2&theme=dark

9.4. 注意 URL 长度限制

浏览器和服务器对 URL 长度都有实际的限制(通常在 2000 到 8000 个字符之间),但实际情况更为复杂,会有来自浏览器行为、服务器配置、CDN 甚至搜索引擎的限制等多种因素。

如果你遇到了这些限制,那就说明你需要重新考虑你的策略了。

10. 总结

好的 URL 不仅仅是指向内容,它更是描述了用户和应用程序之间的对话。

我们已经构建了复杂的状态管理库,但有时最好的解决方案其实是最简单的那一个。当你的应用在点击刷新时失去了状态,想一想,你是否错过了这个 Web 最古老、最优雅的特性?

11. 参考链接

  1. Your URL Is Your State

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions