09|路由元信息:把页面政策放在 route record 上
第六章概览 · 目标:用
meta描述标题、登录要求和页面面包屑;把它当作导航政策的输入,而不是一张可以证明权限的通行证。
先区分三种信息
路由记录上适合放“这个页面通常需要什么”的静态政策,例如 requiresAuth、roles、title 和 breadcrumb。一次访问的动态事实仍来自用户会话、服务端响应和当前 route:meta 不应该存 token,也不能独立决定用户是否有权读取数据。
// 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'] }
}
]
})2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
RouteMeta 的类型扩展会检查已提供的meta对象是否含 title;route record本身仍可不写meta,因此它不等于强制每条记录都有标题。若项目要求全覆盖,需要自己的路由工厂或配置检查。TypeScript只约束代码,真正的角色仍要由服务端根据会话重新判断。
route.meta 怎样得到值
进入嵌套路由时,Vue Router 会把从父到子的匹配记录合并成 route.meta。同名字段以更深的记录为准;需要判断“任意一层要求登录”时,应遍历 to.matched,不要只看一个可能被子记录覆盖的扁平字段。
看见浅合并的中间结果
下面是可在Node执行的 meta-merge.mjs,不需要实际页面组件。它把父政策和子政策保留在matched中,同时显示最终扁平meta。
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')2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
先得到父meta,再用子meta覆盖同名字段。子没有 requiresAuth,所以父值保留;子有 policy,则整个对象替换,里面的 read 不会递归补回来。matched仍保留原始记录链,适合自定义“每一层都满足”或“任意一层要求”的策略。
因此要先决定政策语义:父级强制登录不可被子级取消时,用matched.some;若允许子明确覆盖父政策,则读扁平meta。角色数组也不会自动合并;“父要求reader且子要求auditor”与“任一角色即可”是不同规则,需要由应用显式实现,Router不会替团队选择权限模型。
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
})2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
返回路由位置对象会取消当前导航并开始一次新的导航;不要再调用 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的差别,并在嵌套路由上验证覆盖规则。 - 能区分导航体验、类型约束和真正的服务端授权。
- 改变会话角色或刷新深链后,标题、重定向和错误页面仍有明确结果。