第 11 课:把筛选条件同步到 URL
这一课开始让任务页更像真实后台系统。
因为你会发现,很多管理后台在你筛选列表时,地址栏都会跟着变。
比如:
text
/tasks?keyword=Vue&status=待评审&priority=高
这不是"顺手为之",而是一个非常常见、非常有价值的页面设计。
所以这节课要解决的核心问题是:
为什么列表页要把筛选条件写进 URL,以及在 Vue 里应该怎么做?
先讲结论
你先记住一句最重要的话:
当一个列表页的筛选条件值得被刷新、分享、回退恢复时,它就很适合同步到 URL 查询参数
这句话里有 3 个关键词:
- 刷新后还能保留
- 可以复制链接发给别人
- 浏览器前进后退时能恢复状态
如果一个筛选条件满足这些需求,
那它通常就不该只待在组件的本地 ref 里。
这次我们做了什么
这一轮新增了:
src/composables/useTaskFilterQuerySync.tssrc/composables/__tests__/useTaskFilterQuerySync.spec.ts
更新了:
src/views/TasksView.vuesrc/components/tasks/TaskPageHeader.vue
这说明这次不是只讲原理,
而是真的把"任务页筛选条件 <-> 地址栏查询参数"的同步做出来了。
先看这次解决了什么体验问题
现在任务页有 3 个筛选条件:
- 关键字
- 状态
- 优先级
如果这些状态只存在于页面本地变量里,就会有几个明显问题:
1. 刷新页面后筛选条件会丢
你明明刚筛到一个很具体的结果,
一刷新就回到默认状态了。
2. 不能把当前筛选结果分享给别人
你想把"只看高优先级 + 待评审"的结果发给同事,
但地址栏里没有这些信息,对方打开后看到的还是默认列表。
3. 浏览器回退体验会很怪
用户改了几次筛选条件,再点浏览器后退,
页面状态可能完全对不上地址栏历史。
4. 重新进入页面时不能恢复上下文
很多后台系统最重要的体验之一就是:
我离开再回来,最好还能接着刚才的筛选上下文继续看
URL 查询参数正好很适合承载这种"页面级上下文"。
为什么这次单独新建了一个 composable
这次没有把逻辑继续塞进 useTasksPage.ts,
而是新建了:
这是一个很值得你学的边界判断。
因为这组逻辑的核心问题已经不是:
- 任务怎么加载
- 列表怎么筛选
- 任务怎么新增
而是:
任务页筛选状态如何和路由查询参数保持一致
这是一组新的职责。
它和"任务页业务逻辑"相关,
但它本身更偏:
路由同步逻辑
所以把它单独抽成一个 composable,会比继续堆到 useTasksPage() 里更清楚。
这正好也是第 9 课讲过的边界思路的一个真实应用。
当前这次同步的到底是什么
这次我们同步的是 3 个查询参数:
keywordstatuspriority
也就是说:
页面状态
keywordstatusFilterpriorityFilter
地址栏状态
route.query.keywordroute.query.statusroute.query.priority
现在这两组状态会互相保持一致。
这次最关键的能力:双向同步
这次你一定要理解一个概念:
不是单向写 URL,而是 URL 和页面状态双向同步
也就是说:
方向 1:页面状态变化,写回 URL
比如你在输入框里输入关键字,
或者切换状态筛选,地址栏会跟着更新。
方向 2:URL 变化,回填页面状态
比如你直接打开一个深链接:
text
/tasks?keyword=Pinia&status=待开始&priority=中
页面也会自动把这 3 个筛选条件恢复出来。
这一步非常关键。
因为如果你只做"页面改了写 URL",
却不做"URL 改了回填页面",
那它其实还不算完整同步。
为什么 URL 用 query 参数而不是 params
你现在可以先这样记:
params
更适合"资源定位"
比如:
text
/tasks/18
它表示"第 18 条任务"。
query
更适合"页面条件"
比如:
text
/tasks?status=待评审&priority=高
它表示"任务列表当前用什么条件在看"。
所以筛选条件放进 query,是非常自然的选择。
为什么这次用 router.replace,而不是 router.push
这是这一课非常重要的一个细节。
如果用户每敲一个字都触发一次筛选变化,
你如果每次都用:
ts
router.push(...)
浏览器历史记录会爆炸式增长。
结果就是:
- 用户输入 5 个字
- 浏览器可能多出 5 条历史记录
- 后退键体验会很糟
所以这次我们用了:
ts
router.replace(...)
它的意义是:
更新当前这条地址记录,而不是追加一条新历史
这通常更符合"筛选条件变化"这种场景。
为什么默认值不写进 URL
现在的实现里:
- 关键字为空,不写进 URL
- 状态是"全部",不写进 URL
- 优先级是"全部",不写进 URL
所以默认状态下,地址会更干净。
这背后的思路是:
URL 只保留真正有信息量的状态
这样有两个好处:
1. 地址更短、更可读
不会出现:
text
/tasks?status=全部&priority=全部&keyword=
这种冗余地址。
2. 更容易区分"默认页"和"带条件页"
只要地址栏里出现查询参数,
你就知道这个列表页现在不是默认状态。
为什么这次还做了"非法参数清理"
现在的 useTaskFilterQuerySync.ts 不会盲目信任地址栏参数。
比如有人手动输:
text
/tasks?status=未知状态
页面不会直接把这个非法值塞进状态里。
而是会:
- 先检查这个值是不是合法选项
- 如果不合法,就回退到默认值
- 再把地址栏清理成标准格式
这一步非常像真实项目。
因为:
地址栏是外部输入,所有外部输入都应该被校验
这和表单校验、本地存储校验、本质上是同一种思维。
这次最值得你学的设计点:侦听器不是乱写的
这次的核心实现其实是两组 watch:
第一个 watch
监听:
ts
route.query
作用:
把地址栏状态回填进页面状态
第二个 watch
监听:
keywordstatusFilterpriorityFilter
作用:
把页面状态写回地址栏
为什么这里会有"循环更新"风险
这是这一课最需要你提高警惕的地方。
因为如果你不加控制:
- 地址栏变化
- 回填页面状态
- 页面状态变化
- 又写回地址栏
- 地址栏再变化
就可能进入重复同步。
所以这次实现里专门用了一个标记:
isApplyingRouteQuery
它的作用就是:
告诉第二个 watch:这次状态变化不是用户输入造成的,而是地址栏回填造成的,不要再反向写回
这就是"副作用控制"的典型写法。
为什么任务页要先同步 URL,再加载数据
现在 TasksView.vue 里先调用了:
useTaskFilterQuerySync(...)
然后页面挂载后才执行:
loadTasks()
这个顺序很合理。
因为如果用户一打开就是:
text
/tasks?status=待评审
那页面应该先恢复"待评审"这个筛选条件,
再去加载并显示最终筛选结果。
这会让页面一进入就处在正确上下文里。
这次为什么还专门补了测试
这次新增了:
它验证了 4 件很关键的事:
- 首次进入页面时,查询参数会正确回填到页面筛选状态
- 页面筛选状态变化后,会写回 URL
- 默认值不会冗余地留在 URL 里
- 非法查询参数会被自动清理
这很重要,因为带 watch 的逻辑往往很容易写出"看起来能跑,但边界有问题"的代码。
能测住它,说明你的同步边界更清楚了。
真实项目里为什么很多后台都这么做
你现在可以用下面这 4 个理由去理解:
1. 可分享
把当前筛选结果直接发给别人。
2. 可恢复
刷新页面、重新打开页面后,仍然保留原来的条件。
3. 可回退
用户通过浏览器前进后退,也能恢复到之前的筛选上下文。
4. 可调试
开发时你只看地址栏就知道当前页面到底处在什么筛选状态。
这对排查问题非常有帮助。
这节课最容易犯的 6 个错误
1. 只会把状态写进 URL,不会从 URL 恢复状态
这不叫完整同步。
2. 不校验 query 参数是否合法
这会让页面状态被脏数据污染。
3. 把默认值也全部写进 URL
会让地址又长又脏。
4. 用 router.push 处理每一次筛选变化
会让浏览器历史记录非常难用。
5. 忘记处理循环同步问题
很容易造成重复 replace,甚至让状态流变得难以理解。
6. 把这类逻辑无脑塞进页面主 composable
会让 useTasksPage() 的职责越来越散。
你现在应该能回答的 10 个问题
- 为什么列表页的筛选条件很适合同步到 URL 查询参数?
- 为什么这类信息适合放在
query,而不是params? - 为什么这次要单独新建
useTaskFilterQuerySync.ts? - 为什么同步必须是双向的,而不是单向写 URL?
- 为什么筛选变化通常更适合用
router.replace? - 为什么默认值不应该冗余地写进地址栏?
- 为什么地址栏里的查询参数也要做合法性校验?
- 为什么这种实现里很容易出现循环更新问题?
isApplyingRouteQuery这个标记解决了什么问题?- 为什么要先恢复 URL 状态,再加载任务数据?
这节课的动手练习
练习 1
自己手动在浏览器地址栏试这几种地址:
/tasks?status=待评审/tasks?priority=高/tasks?keyword=Vue&status=进行中
观察:
- 页面筛选控件会不会自动恢复
- 列表结果会不会跟着变化
目的:
把"地址栏就是页面状态的一部分"这件事真正体会到。
练习 2
打开 useTaskFilterQuerySync.ts,自己用一句话解释下面这 3 个函数分别在干什么:
getSingleQueryValuebuildTaskFilterQueryisTaskFilterQuerySynced
目的:
训练你读懂一个 composable 内部的"辅助函数分工"。
练习 3
试着自己再加一个筛选条件:
- 只看"我负责的任务"
然后先回答:
- 它应不应该同步到 URL
- 应该对应哪个 query key
- 默认值是不是要写进 URL
目的:
训练你判断"什么状态值得进地址栏,什么状态不值得"。
这节课的复习结论
把这一课压缩成 8 句话:
- 列表页筛选条件如果需要被刷新、分享和回退恢复,就很适合同步到 URL。
- 这类"页面条件"通常放在
query,而不是params。 - 这次的新 composable 本质上解决的是"筛选状态与路由状态同步"问题。
- 完整同步必须同时支持"页面写 URL"和"URL 回填页面"。
- 默认值不应该冗余地出现在地址栏里。
- 地址栏参数和表单输入一样,都是外部输入,必须做合法性校验。
router.replace比router.push更适合高频筛选变化。- 带
watch的同步逻辑最关键的不是写出来,而是防止它进入循环更新。