04|params、query、hash 与组件复用
第六章概览 / 04。目标:URL 是外部、不可信、可复制的状态合同;读取后先规范化,再用于请求和缓存 key。
每种位置状态表达什么
| 部分 | 例子 | 适合表达 |
|---|---|---|
| params | /users/42 的 42 | 资源身份、层级 |
| query | ?page=2&tag=vue | 可选筛选、排序、分页、tab |
| hash | #examples | 当前文档内位置 |
| history state | Router 内部滚动/导航信息、短命附加状态 | 与单条 history entry 同寿命,不适合作为可分享合同 |
params/query 来自 URL,类型不是业务模型。动态参数通常是字符串,重复参数可能是字符串数组;query 值可能是字符串、null 或数组。Number(route.query.page) 还会产生 NaN/0/负数,必须校验范围。
解析成稳定的业务输入
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const userId = computed(() => {
const value = route.params.id
return typeof value === 'string' && value.length > 0 ? value : null
})
const page = computed(() => {
const raw = Array.isArray(route.query.page) ? route.query.page[0] : route.query.page
const value = Number(raw ?? 1)
return Number.isInteger(value) && value > 0 ? value : 1
})
const tags = computed(() => {
const value = route.query.tag
if (Array.isArray(value)) return value.filter((tag): tag is string => typeof tag === 'string')
return typeof value === 'string' ? [value] : []
})2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
规范化函数应与缓存 key 共用,避免页面显示 page=1,缓存却使用非法原字符串。若非法 URL 需要改正,可 router.replace 到规范位置,避免在 history 中增加一条错误记录。
由 Router 负责编码
router.push({
name: 'user',
params: { id: 'a/b' },
query: { tag: ['vue', 'router'], page: '2' },
})2
3
4
5
命名路由会按路径参数规则编码 a/b。不要先 encodeURIComponent 后再交给 named params,否则可能双重编码。拼 path 时则由你负责:`/users/${encodeURIComponent(id)}`。
下面写法中的 params 会被忽略:
router.push({ path: '/users', params: { id: '42' } })应使用 { name: 'user', params: { id } },或 path /users/${encodeURIComponent(id)}。
更新 query 时保留需要的字段
function setPage(nextPage: number) {
return router.push({
query: { ...route.query, page: String(nextPage) },
})
}
function normalizePage() {
return router.replace({
query: { ...route.query, page: String(page.value) },
})
}2
3
4
5
6
7
8
9
10
11
分页是用户可返回的动作时用 push;输入防抖每个字符都写 history 通常体验很差,可在输入中 replace、提交时 push,或只在提交时写 URL。
同一路由参数变化不会自动重建组件
从 /users/1 到 /users/2 会复用同一个 UserView 实例,因此 onMounted 不再执行。监听具体字段,并清理旧请求:
watch(() => route.params.id, id => loadUser(String(id)), { immediate: true })这段只说明监听哪个输入,尚未实现取消。若 loadUser 直接写结果,a请求比b慢时仍会覆盖b。下面把加载过程放回当前watcher轮次,文件可直接作为 /users/:id 的页面。模拟接口按id产生不同延迟,故意忽略signal来验证“旧轮次不能写”这一层。
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const user = ref<{ id: string; name: string } | null>(null)
const loading = ref(false)
const error = ref('')
onMounted(() => console.log('UserView mounted'))
function fetchUser(id: string, _signal: AbortSignal) {
return new Promise<{ id: string; name: string }>(resolve => {
setTimeout(() => resolve({ id, name: `用户 ${id}` }), id === 'a' ? 800 : 100)
})
}
watch(() => route.params.id, async (rawId, _old, onCleanup) => {
const id = typeof rawId === 'string' ? rawId : ''
const controller = new AbortController()
let active = true
onCleanup(() => { active = false; controller.abort() })
user.value = null
error.value = ''
loading.value = Boolean(id)
if (!id) return
try {
const result = await fetchUser(id, controller.signal)
if (active) user.value = result
} catch (cause) {
if (active) error.value = cause instanceof Error ? cause.message : String(cause)
} finally {
if (active) loading.value = false
}
}, { immediate: true })
</script>
<template>
<p v-if="loading">正在读取 {{ route.params.id }}</p>
<p v-else-if="error" role="alert">{{ error }}</p>
<p v-else-if="user">{{ user.name }}</p>
</template>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
从 /users/a 快速跳 /users/b:mounted只输出一次;a的watcher轮次被清理;b先显示,a晚到不会改变文本。把query改成 ?tab=posts 时,这个watcher不会新建请求,因为它只依赖id。移除页面后,当前轮次也会失效。真实接口应把signal传入fetch并检查HTTP状态;模拟接口保留晚到回调,是为了独立观察写入保护,不能据此声称网络已被取消。
也可用 onBeforeRouteUpdate 在确认导航阶段处理,或用 route props 把参数转换后传给页面组件,减少页面直接依赖 Router:
{
path: '/users/:id',
component: UserView,
props: route => ({ userId: String(route.params.id) }),
}2
3
4
5
watch 整个 route 会因不相关 query/hash 变化重复请求;只监听真正决定资源的参数。
安全边界
URL 会进入浏览器历史、服务器日志、分析平台、Referer 和截图,不放 token、密码或私密查询。route params 只是一段用户输入,不能直接拼 SQL/文件路径;服务端仍要校验身份、格式和资源权限。
练习:可分享列表位置
实现 /products/:category? 与 query page、重复 tag、sort。写纯函数 parseProductLocation(route):category 默认 all;page 为正整数否则 1;tag 输出字符串数组;sort 只允许 price|rating,否则 rating。
再实现 setPage:保留 category/tag/sort,合法页码用 push,非法页码规范化到 1 并 replace。证明从 /products/books 到 /products/games 时同一组件会重新请求但不会重复注册监听器。
参考答案:第 04 题。
本页结论
URL 是位置的 owner,不是可信数据库。先解析成业务类型,再驱动请求;同组件复用时显式处理参数变化和旧请求清理。