Skip to content

09|路由元信息:把页面政策放在 route record 上 ​

第六章概览 · 目标:用 meta 描述标题、登录要求和页面面包屑;把它当作导航政策的输入,而不是一张可以证明权限的通行证。

先区分三种信息 ​

路由记录上适合放“这个页面通常需要什么”的静态政策,例如 requiresAuth、roles、title 和 breadcrumb。一次访问的动态事实仍来自用户会话、服务端响应和当前 route:meta 不应该存 token,也不能独立决定用户是否有权读取数据。

ts
// router.ts
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from './views/HomeView.vue'
import ArticleView from './views/ArticleView.vue'

declare module 'vue-router' {
  interface RouteMeta {
    title: string
    requiresAuth?: boolean
    roles?: readonly string[]
  }
}

export const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', name: 'home', component: HomeView, meta: { title: '首页' } },
    {
      path: '/articles/:id',
      name: 'article',
      component: ArticleView,
      meta: { title: '文章', requiresAuth: true, roles: ['reader'] }
    }
  ]
})

RouteMeta 的类型扩展会检查已提供的meta对象是否含 title;route record本身仍可不写meta,因此它不等于强制每条记录都有标题。若项目要求全覆盖,需要自己的路由工厂或配置检查。TypeScript只约束代码,真正的角色仍要由服务端根据会话重新判断。

route.meta 怎样得到值 ​

进入嵌套路由时,Vue Router 会把从父到子的匹配记录合并成 route.meta。同名字段以更深的记录为准;需要判断“任意一层要求登录”时,应遍历 to.matched,不要只看一个可能被子记录覆盖的扁平字段。

看见浅合并的中间结果 ​

下面是可在Node执行的 meta-merge.mjs,不需要实际页面组件。它把父政策和子政策保留在matched中,同时显示最终扁平meta。

js
import assert from 'node:assert/strict'
import { createRouter, createMemoryHistory } from 'vue-router'
const Empty = { render: () => null }
const router = createRouter({
  history: createMemoryHistory(),
  routes: [{
    path: '/reports', component: Empty,
    meta: { requiresAuth: true, policy: { read: true, export: false }, title: '报告' },
    children: [{
      path: ':id', component: Empty,
      meta: { policy: { export: true }, title: '报告详情' }
    }]
  }]
})
const to = router.resolve('/reports/42')
assert.equal(to.matched.length, 2)
assert.equal(to.meta.requiresAuth, true)
assert.equal(to.meta.title, '报告详情')
assert.deepEqual(to.meta.policy, { export: true })
assert.deepEqual(to.matched[0].meta.policy, { read: true, export: false })
console.log('meta merge passed')

先得到父meta,再用子meta覆盖同名字段。子没有 requiresAuth,所以父值保留;子有 policy,则整个对象替换,里面的 read 不会递归补回来。matched仍保留原始记录链,适合自定义“每一层都满足”或“任意一层要求”的策略。

因此要先决定政策语义:父级强制登录不可被子级取消时,用matched.some;若允许子明确覆盖父政策,则读扁平meta。角色数组也不会自动合并;“父要求reader且子要求auditor”与“任一角色即可”是不同规则,需要由应用显式实现,Router不会替团队选择权限模型。

ts
router.beforeEach((to) => {
  const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
  if (!requiresAuth) return true

  const session = readSession()
  if (!session) {
    return { name: 'login', query: { redirect: to.fullPath } }
  }

  const requiredRoles = to.meta.roles ?? []
  if (requiredRoles.length > 0 && !requiredRoles.some(role => session.roles.includes(role))) {
    return { name: 'forbidden' }
  }
})

router.afterEach((to, from, failure) => {
  if (failure) return
  document.title = to.meta.title
})

返回路由位置对象会取消当前导航并开始一次新的导航;不要再调用 next(),也不要把整份用户对象塞进 query。afterEach 适合更新标题或记录结果,不适合阻止导航。

变化条件与边界 ​

  • 父路由有 requiresAuth: true、子路由没有时,两种读法都会得到需要登录;只有子显式覆盖为false时,扁平meta与“任意祖先要求”才分歧。
  • meta 可以描述“需要 reader 角色”,但不能证明文章 ID 对该用户可读;进入页面后仍要处理 401/403/404。
  • 标题需要等异步文章数据时,把静态默认标题放在 meta,再在页面数据到达后更新;不要在路由表里读请求结果。
  • 只有客户端才有 document。SSR 项目应在客户端入口更新标题,服务端则使用框架提供的 head API。

练习:写一条可追踪的页面政策 ​

为 /reports 和 /reports/:id 写出 RouteMeta 类型与全局守卫:

  • /reports 需要登录,/reports/:id 还需要 auditor 角色。
  • 未登录时跳到 /login?redirect=<原始 fullPath>;无角色时去 /forbidden。
  • afterEach 只在导航成功时把 to.meta.title 写入标题;守卫返回false、被新导航取消或重复导航时保留当前标题。
  • 用三个 session 输入(无会话、普通用户、auditor)验证守卫返回值,并说明服务端为什么仍需执行授权。

答案见第六章练习答案。

本篇验收 ​

  • 能解释 to.matched 与 to.meta 的差别,并在嵌套路由上验证覆盖规则。
  • 能区分导航体验、类型约束和真正的服务端授权。
  • 改变会话角色或刷新深链后,标题、重定向和错误页面仍有明确结果。

用心记录每一步成长