Skip to content

04|params、query、hash 与组件复用 ​

第六章概览 / 04。目标:URL 是外部、不可信、可复制的状态合同;读取后先规范化,再用于请求和缓存 key。

每种位置状态表达什么 ​

部分例子适合表达
params/users/42 的 42资源身份、层级
query?page=2&tag=vue可选筛选、排序、分页、tab
hash#examples当前文档内位置
history stateRouter 内部滚动/导航信息、短命附加状态与单条 history entry 同寿命,不适合作为可分享合同

params/query 来自 URL,类型不是业务模型。动态参数通常是字符串,重复参数可能是字符串数组;query 值可能是字符串、null 或数组。Number(route.query.page) 还会产生 NaN/0/负数,必须校验范围。

解析成稳定的业务输入 ​

ts
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] : []
})

规范化函数应与缓存 key 共用,避免页面显示 page=1,缓存却使用非法原字符串。若非法 URL 需要改正,可 router.replace 到规范位置,避免在 history 中增加一条错误记录。

由 Router 负责编码 ​

ts
router.push({
  name: 'user',
  params: { id: 'a/b' },
  query: { tag: ['vue', 'router'], page: '2' },
})

命名路由会按路径参数规则编码 a/b。不要先 encodeURIComponent 后再交给 named params,否则可能双重编码。拼 path 时则由你负责:`/users/${encodeURIComponent(id)}`。

下面写法中的 params 会被忽略:

ts
router.push({ path: '/users', params: { id: '42' } })

应使用 { name: 'user', params: { id } },或 path /users/${encodeURIComponent(id)}。

更新 query 时保留需要的字段 ​

ts
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) },
  })
}

分页是用户可返回的动作时用 push;输入防抖每个字符都写 history 通常体验很差,可在输入中 replace、提交时 push,或只在提交时写 URL。

同一路由参数变化不会自动重建组件 ​

从 /users/1 到 /users/2 会复用同一个 UserView 实例,因此 onMounted 不再执行。监听具体字段,并清理旧请求:

ts
watch(() => route.params.id, id => loadUser(String(id)), { immediate: true })

这段只说明监听哪个输入,尚未实现取消。若 loadUser 直接写结果,a请求比b慢时仍会覆盖b。下面把加载过程放回当前watcher轮次,文件可直接作为 /users/:id 的页面。模拟接口按id产生不同延迟,故意忽略signal来验证“旧轮次不能写”这一层。

vue
<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>

从 /users/a 快速跳 /users/b:mounted只输出一次;a的watcher轮次被清理;b先显示,a晚到不会改变文本。把query改成 ?tab=posts 时,这个watcher不会新建请求,因为它只依赖id。移除页面后,当前轮次也会失效。真实接口应把signal传入fetch并检查HTTP状态;模拟接口保留晚到回调,是为了独立观察写入保护,不能据此声称网络已被取消。

也可用 onBeforeRouteUpdate 在确认导航阶段处理,或用 route props 把参数转换后传给页面组件,减少页面直接依赖 Router:

ts
{
  path: '/users/:id',
  component: UserView,
  props: route => ({ userId: String(route.params.id) }),
}

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,不是可信数据库。先解析成业务类型,再驱动请求;同组件复用时显式处理参数变化和旧请求清理。

用心记录每一步成长