2206 字
约 7 分钟
16
S14-A16 · URL 里的状态:哪些状态该可分享

一个后台列表页把筛选条件全存在前端状态里,URL 只有 /orders。运营想把"待发货、近三天、按金额倒序"这一屏发给同事看,只能截图。同事照着截图重新点一遍,点出来的结果与原页面不完全一样——因为默认排序在两个会话里不同。

状态放在哪里,决定了这个页面能不能被指认。而"能不能被指认"决定了它能不能被协作、被排障、被记录。


1. 状态的四类归属

归属 例子 特性
URL 筛选、分页、排序、选中项 可分享、可回退
组件内部 展开/折叠、输入中的草稿 短生命周期
全局/持久 用户偏好、主题 跨页面
服务端 列表数据、权限 单一来源

第四类最容易被误用:服务端状态不该被复制到 URL 里"治标"。比如把"总数"也写进 URL,看起来让页面更完整,实际上引入了一份会过期的事实——刷新时总数变了,URL 却还写着旧的。

URL 适合承载**"看什么",组件内部适合"怎么看"(展开态、hover),持久层适合"我是谁"**(偏好)。把"看什么"放进组件内部,页面就无法被指认。

2. 该进 URL 的五个判据

判据 例子
别人需要看到同样结果 筛选条件
需要浏览器回退生效 分页、tab
需要刷新后保留 排序方式
需要被记录/引用 详情选中项
需要被日志/工单引用 视图标识

值得注意的判据还有一条"成本":进 URL 的状态会让每个新功能都要考虑 URL 兼容。所以判据要严——不是"能放就放",而是"不放会让人做不到事"。

第五项在排障时很实用:用户报障说"我这里看不到",如果 URL 里带着筛选条件,就能直接复现;否则只能问"你点了哪些"。

// 一个可分享的列表页 URL
/orders?status=PAID&from=2026-11-07&to=2026-11-10&sort=-amount&page=2
// 五个参数都是"看什么",别人打开即得到同一屏

3. 不该进 URL 的三类

内容 为什么
输入框里的未提交草稿 每次按键都会改历史
敏感信息(token、手机号) 会进日志、浏览器历史、Referer
瞬时 UI 状态(hover、展开) 无协作价值,噪音

还有一个常被忽略的位置:第三方分析工具的自动采集。很多前端监控会把完整 URL 上报,于是 query 里的敏感值同时进了分析平台——这一处往往比自家日志更难清理。

第二项是安全问题而非体验问题:URL 会出现在访问日志、Referer 头、分享链接里。把筛选条件里的手机号放进 URL,等于把它写进了三个地方。

// 反例:敏感值进 URL
/search?phone=13800138000        // 会进日志与 Referer
// 正例:放进请求体,或用不可逆的哈希/ID
POST /search { "phone_hash": "..." }

4. 回退与重置的语义

操作 期望行为
点浏览器回退 回到上一屏(含筛选)
刷新页面 保持当前视图
点"重置" 回到默认视图并把 URL 清干净
从外部链接进入 落在链接描述的视图

与之对应的是"从 URL 初始化"这一步,它常被漏在状态管理的初始化函数外:

// 初始化:从 URL 读取,而不是从空状态开始
const initial = Object.fromEntries(new URLSearchParams(location.search));
const [filters, setFilters] = useState({ ...DEFAULTS, ...initial });
// 漏掉这一步 → 刷新后筛选丢失,而"重置"看起来又正常

第三项常被实现错:重置只清了前端状态,URL 里还留着参数,于是刷新又回到旧筛选。正确做法是重置时用 replace 更新 URL,而不是只改内存。

// 重置:清空筛选并同步 URL,避免"刷新又回来"
function reset() {
  const url = new URL(location.href);
  ["status", "from", "to", "sort", "page"].forEach(k => url.searchParams.delete(k));
  history.replaceState(null, "", url);       // replace 而非 push,不产生历史项
  setFilters(DEFAULT);
}

5. 参数命名与默认值

做法 好处
默认值不出现在 URL 链接更短、语义更清楚
参数名稳定(不改名) 外部引用不会断
多值用重复参数而非逗号 兼容性与可读性
空值用"不传"而非空串 避免歧义

第一项值得展开:如果默认排序也写进 URL,那么"默认视图"就有了两个地址(/orders 与 /orders?sort=-created),统计与分享都会分裂。

// 默认值不落 URL:一个视图一个地址
/orders                      ← 默认(sort=-created, page=1)
/orders?sort=-amount         ← 只有偏离默认时才出现参数
// 对比:/orders?sort=-created&page=1 与 /orders 指向同一屏,却是两个 URL

还有一个容易被忽略的细节:URL 参数要有"可解析的容错"。外部链接可能带着旧参数、拼错的枚举值、空串——页面应当忽略它们而不是崩掉,因为 URL 一旦分享出去,就进入了一个你无法控制的环境。

// 容错解析:不认识的参数忽略,非法值回落默许值
const status = ["PENDING","PAID"].includes(raw.status) ? raw.status : "ALL";
const page = Number.isFinite(+raw.page) && +raw.page > 0 ? +raw.page : 1;

6. 排错:五种"链接发过去对不上"

现象 成因 处理方向
对方打开结果不同 关键状态在组件内部 移进 URL
刷新后筛选丢失 只存内存 从 URL 初始化
回退行为诡异 每次输入都 push 历史 用 replace 或防抖
链接过长被截断 默认值也写入 省略默认值
敏感信息泄漏 值放进了 query 移到请求体

输入类状态还有个折中:用"草稿 + 提交"两段式。输入过程只改本地,提交时才写 URL——这样历史干净,也不会把半成品状态分享出去。

第三项的用户体验最差:回退要按十几次才能离开页面,因为每次输入都产生一条历史。修法是"输入过程中 replace,提交时才 push"。

7. 边界:什么情况不必进 URL

场景 为什么 做法
单页应用内的临时弹窗 无分享价值 组件内部状态
只读展示页(无筛选) 无状态可放 不必
内部调试面板 使用者是自己 随意
状态极多(>15 个参数) URL 不可读 用"视图 ID"保存

状态超过 15 个时,"视图 ID"是个实用解法:把一整套筛选保存成一个命名视图,URL 只带 ?view=42。代价是视图需要服务端存储与权限,收益是链接可读且稳定。

另一个成本项是"参数之间的关系":筛选条件之间常有隐含约束(结束日期不能早于开始),这些约束放进 URL 后,非法组合会由外部链接带进来,页面必须能兜住而不是渲染成空列表。

判断口径一句话:这个页面的某个状态,会不会被别人要求"我也要看这个"。会,就该进 URL;不会,它只是交互细节。

还有一条与协作有关的边界:URL 结构一旦对外发布,就是接口。埋点、分享、书签、外部链接都依赖它,所以参数名与语义的稳定性应当按"接口兼容"来管理,而不是随前端重构随手改。


8. 自测

  1. 状态的四类归属分别适合承载什么?为什么"看什么"不该放进组件内部?
  2. 该进 URL 的五个判据里,哪一个在排障时最实用?
  3. 敏感值为什么不能进 URL?它会出现在哪三个地方?
  4. "重置"最常见的实现错误是什么?为什么应当用 replace 而不是 push?
  5. 默认值写进 URL 会造成什么分裂?举一个具体例子。
  6. 判断"是否进 URL"的那个问题是什么?状态超过 15 个时该怎么办?
S14-A16 · URL 里的状态:哪些状态该可分享
https://www.clxhxhhr.top/posts/6443/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。