项目实践:Vue Router RouteMeta扩展:一份路由配置驱动菜单、面包屑、Tab 和鉴权
Vue Router RouteMeta 扩展实战:一份路由配置驱动菜单、面包屑、Tab 和鉴权
前言
这是在一份真实的 Vue 3 + TypeScript 中后台项目中沉淀下来的实践。
中后台项目的典型诉求是:路由配置能不能同时驱动菜单、面包屑、Tab 标签、页面缓存、登录鉴权?如果菜单维护一套数据、路由维护一套数据、Tab 又维护一套数据,那三份配置迟早会不同步。
本文的答案是:通过 Vue Router 的 RouteMeta 模块扩充,用一份路由配置作为唯一事实来源,让菜单、面包屑、Tab、缓存和鉴权都从 meta 派生。
一、为什么需要扩展 RouteMeta
Vue Router 的 RouteMeta 默认类型是空对象 {}:
// node_modules/vue-router/dist/vue-router.d.ts
export interface RouteMeta {}
这意味着在 meta 上写任何字段都不会有 IDE 提示和类型检查,所以项目中经常出现:
// 拼错字段名,运行时才暴露
meta: { titile: '首页' }
// 消费方要写 as string 断言
const title = route.meta.title as string
而中后台项目里,路由配置需要承载的职责远不止"路径和组件映射":
| 职责 | 典型字段 | 消费方 |
|---|---|---|
| 侧栏菜单显示 | 标题、图标、是否隐藏 | 菜单组件 |
| 面包屑导航 | 标题 | 面包屑组件 |
| Tab 标签页 | 是否固定、是否缓存、是否生成 Tab | Tab 系统 |
| 菜单高亮 | 详情页高亮所属菜单 | 侧栏组件 |
| 登录鉴权 | 是否公开 | 路由守卫 |
目标:一份路由配置,通过 meta 字段驱动上述所有子系统,消除多份配置不同步。
二、类型声明:模块扩充
利用 TypeScript 的声明合并(Declaration Merging),在项目中扩充 vue-router 的 RouteMeta 接口。
// src/types/route.d.ts
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
title?: string
icon?: string
hidden?: boolean
activeMenu?: string
affix?: boolean
noCache?: boolean
noTagsView?: boolean
permission?: string | string[]
roles?: string | string[]
public?: boolean
}
}
原理:TypeScript 同名 interface 会自动合并。项目中的 declare module 'vue-router' 会扩充 node_modules 中 vue-router 定义的 RouteMeta 接口,从此在 meta 上写字段就有类型提示和编译检查。
三、字段速查
| 字段 | 类型 | 默认值 | 作用 | 消费方 |
|---|---|---|---|---|
title |
string |
undefined |
菜单/面包屑/Tab 显示文本 | 菜单树、面包屑、Tab 标题 |
icon |
string |
undefined |
侧栏菜单图标 | 菜单树 → 侧栏渲染 |
hidden |
boolean |
undefined |
是否从侧栏菜单隐藏 | useMenuTree() 过滤 |
activeMenu |
string |
undefined |
详情页高亮所属菜单路径 | AppSidebar activeMenu 计算 |
affix |
boolean |
undefined |
是否为固定 Tab(不可关闭) | tabsStore 初始化/关闭 |
noCache |
boolean |
undefined |
是否跳过 keep-alive 缓存 | tabsStore.cachedNames |
noTagsView |
boolean |
undefined |
是否不生成 Tab | tabsStore.addRouteTab |
public |
boolean |
undefined |
是否跳过登录鉴权 | router.beforeEach |
permission |
string|string[] |
undefined |
权限标识(预留) | — |
roles |
string|string[] |
undefined |
角色标识(预留) | — |
四、消费链路一:title + icon + hidden → 侧栏菜单
4.1 转译层:从路由配置推导菜单树
菜单不直接操作路由,而是通过一个 composable 做转译。
// src/layout/composables/useMenuTree.ts
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { staticRoutes } from '@/router/routes'
import type { RouteRecordRaw } from 'vue-router'
export interface MenuItem {
index: string
title: string
icon?: string
children?: MenuItem[]
}
export function useMenuTree() {
const router = useRouter()
function resolveMenuItem(route: RouteRecordRaw, parentPath: string): MenuItem | null {
if (route.meta?.hidden) return null
if (!route.meta?.title) return null
const fullPath = route.name
? router.resolve({ name: route.name }).path
: route.path.startsWith('/')
? route.path
: `${parentPath}/${route.path}`.replace(/\/\/+/g, '/')
const item: MenuItem = {
index: fullPath,
title: route.meta.title,
icon: route.meta.icon,
}
if (route.children?.length) {
const children = route.children
.map((child) => resolveMenuItem(child, fullPath))
.filter((c): c is MenuItem => c !== null)
if (children.length > 0) item.children = children
}
return item
}
const menuTree = computed(() =>
staticRoutes
.map((route) => resolveMenuItem(route, '/'))
.filter((item): item is MenuItem => item !== null),
)
return { menuTree }
}
要点:
hidden的路由不加入菜单- 没有
title的路由不展示 - 只取
title和icon,不关心路由的component、path等细节 router.resolve确保路径准确,不受嵌套层级影响
4.2 侧栏组件消费
<!-- src/layout/components/AppSidebar.vue -->
<template>
<el-menu :default-active="activeMenu" :default-openeds="defaultOpeneds" router>
<AppSidebarMenuItem v-for="item in menuTree" :key="item.index" :item="item" />
</el-menu>
</template>
<script setup>
const { menuTree } = useMenuTree()
</script>
4.3 递归渲染菜单项
<!-- src/layout/components/AppSidebarMenuItem.vue -->
<template>
<template v-if="item.children?.length">
<el-sub-menu :index="item.index">
<template #title>
<i v-if="item.icon" class="sidebar-menu-icon">{{ item.icon }}</i>
<span>{{ item.title }}</span>
</template>
<AppSidebarMenuItem v-for="child in item.children" :key="child.index" :item="child" />
</el-sub-menu>
</template>
<template v-else>
<el-menu-item :index="item.index">
<i v-if="item.icon" class="sidebar-menu-icon">{{ item.icon }}</i>
<span>{{ item.title }}</span>
</el-menu-item>
</template>
</template>
五、消费链路二:title → 面包屑
面包屑直接从 route.matched 中取有 title 的路由,不需要额外配置。
// src/layout/components/AppHeader.vue
const breadcrumbItems = computed(() => {
return route.matched
.filter((m) => m.meta?.title)
.map((m) => ({
path: m.path,
title: m.meta.title as string,
}))
})
注意:meta.hidden 的路由不会被过滤,因此隐藏的详情页仍然可以出现在面包屑中。
六、消费链路三:affix + noTagsView + noCache → Tab 系统
6.1 Tab 数据结构
Tab 的字段直接映射路由 meta:
// src/stores/tabs.ts
export interface RouteTab {
name?: string
path: string
fullPath: string
query: LocationQuery
params: RouteParams
hash: string
title: string // 来自 route.meta.title
affix: boolean // 来自 route.meta.affix
noCache: boolean // 来自 route.meta.noCache
noTagsView: boolean // 来自 route.meta.noTagsView
}
6.2 添加 Tab
function addRouteTab(route) {
// noTagsView 的路由不生成 Tab,只更新激活状态
if (route.meta?.noTagsView) {
setActiveTab(route.fullPath)
return
}
// 按 fullPath 去重,同一路由不同参数可形成多个详情 Tab
const existing = tabs.value.find(t => t.fullPath === route.fullPath)
if (!existing) {
tabs.value.push({
title: route.meta?.title || '',
affix: route.meta?.affix || false,
noCache: route.meta?.noCache || false,
noTagsView: route.meta?.noTagsView || false,
// ...
})
}
setActiveTab(route.fullPath)
}
6.3 初始化固定 Tab
退出登录或重置时,自动从路由配置中恢复 affix 的 Tab:
function collectAffixRoutes(routes: RouteRecordRaw[]): RouteRecordRaw[] {
const result: RouteRecordRaw[] = []
for (const route of routes) {
if (route.meta?.affix) result.push(route)
if (route.children?.length) result.push(...collectAffixRoutes(route.children))
}
return result
}
6.4 keep-alive 缓存控制
const cachedNames = computed(() => {
return tabs.value
.filter(tab => !tab.noCache) // noCache 的路由不缓存
.map(tab => tab.name as string)
.filter(name => !_excludedCacheNames.value.has(name)) // 刷新时临时排除
})
Layout 中消费:
<keep-alive :include="tabsStore.cachedNames">
<component :is="Component" :key="viewRoute.fullPath" />
</keep-alive>
6.5 Tab 关闭限制
<span v-if="!tab.affix" class="app-tab-close" @click.stop="handleCloseTab(tab)">✕</span>
affix 的 Tab 不显示关闭按钮,closeTab() 中也会跳过 affix 的 Tab。
七、消费链路四:activeMenu → 详情页菜单高亮
隐藏详情页进入时,侧栏需要高亮所属的父级菜单项。
// src/layout/components/AppSidebar.vue
const activeMenu = computed(() => {
if (typeof route.meta.activeMenu === 'string') {
return route.meta.activeMenu
}
return route.path
})
路由配置示例:
{
path: 'monthly-report',
name: 'MonthlyReport',
meta: {
title: '月度填报',
hidden: true,
activeMenu: '/data-deal/data-collection', // 高亮所属菜单
},
}
同时,父级菜单需要自动展开:
const defaultOpeneds = computed(() => {
if (isCollapsed.value) return []
return findParentIndexes(menuTree.value, activeMenu.value)
})
findParentIndexes 递归查找 activeMenu 的所有父级 index,确保进入详情页时所属菜单组是展开的。
八、消费链路五:public → 登录鉴权
// src/router/index.ts
router.beforeEach((to) => {
const hasToken = Boolean(getToken())
if (!to.meta.public && !hasToken) {
return { path: '/login', query: { redirect: to.fullPath } }
}
if (to.path === '/login' && hasToken) {
return '/'
}
return true
})
路由配置:
{
path: '/login',
name: 'Login',
component: () => import('@/views/LoginView.vue'),
meta: { public: true },
}
九、完整路由配置示例
// src/router/routes/home.ts
const homeRoutes: RouteRecordRaw[] = [
// 父级菜单(无 component,只做菜单容器)
{
path: '',
name: 'Workspace',
meta: { title: '工作台首页', icon: '📊' },
children: [
// 固定 Tab(不可关闭,退出登录自动恢复)
{ path: '', name: 'Home', component: () => import('@/views/HomeView.vue'),
meta: { title: '首页', affix: true } },
// 普通页面
{ path: 'todo', name: 'Todo', component: () => import('@/views/TodoView.vue'),
meta: { title: '待办事项' } },
],
},
{
path: 'data-deal',
name: 'DataDeal',
meta: { title: '数据采集与填报', icon: '📋' },
children: [
// 重定向路由:不生成 Tab
{ path: '', redirect: 'data-collection', meta: { noTagsView: true } },
// 普通页面
{ path: 'data-collection', name: 'DataCollection',
meta: { title: '数据采集与填报' } },
// 隐藏详情页:从侧栏隐藏,通过 activeMenu 高亮所属菜单
{ path: 'monthly-report', name: 'MonthlyReport',
meta: { title: '月度填报', hidden: true, activeMenu: '/data-deal/data-collection' } },
{ path: 'audit-detail', name: 'AuditDetail',
meta: { title: '审核详情', hidden: true, activeMenu: '/data-deal/data-collection' } },
{ path: 'spot-check-detail', name: 'SpotCheckDetail',
meta: { title: '抽查详情', hidden: true, activeMenu: '/data-deal/data-collection' } },
],
},
// 单级页面
{ path: 'device', name: 'Device', component: () => import('@/views/DeviceView.vue'),
meta: { title: '设备管理', icon: '🖥️' } },
]
十、数据流全景
┌─────────────────────────────────────────────────────────────────────────┐
│ 路由配置(单一事实来源) │
│ { path, name, component, meta: { title, icon, hidden, activeMenu, │
│ affix, noCache, noTagsView, public } } │
└──────────┬────────────────┬──────────────┬──────────────┬───────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌──────────┐ ┌──────────────┐
│useMenuTree │ │breadcrumb │ │tabsStore │ │beforeEach │
│(hidden) │ │(title) │ │(affix/ │ │(public) │
│(title) │ │ │ │ noTagsView│ │ │
│(icon) │ │ │ │ noCache) │ │ │
└─────┬──────┘ └─────┬──────┘ └─────┬─────┘ └──────┬───────┘
│ │ │ │
▼ ▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌──────────────┐ ┌──────────┐
│侧栏菜单 │ │AppHeader │ │AppTabs + │ │登录鉴权 │
│activeMenu │ │面包屑 │ │keep-alive │ │守卫 │
│高亮 │ │ │ │ │ │ │
└────────────┘ └────────────┘ └──────────────┘ └──────────┘
十一、总结
- 单一事实来源:路由配置是唯一的配置入口,菜单、面包屑、Tab、缓存、鉴权都从
meta派生,不存在第二份配置 - 声明合并:通过
declare module 'vue-router'扩充RouteMeta,获得类型安全,不需要额外的类型定义文件 - 字段按职责划分:每个字段只做一件事,消费方也只取自己需要的字段,互不耦合
useMenuTree是转译层:路由配置 → 菜单树的转换由 composable 完成,侧栏组件不直接操作路由,职责清晰activeMenu解决详情页高亮:隐藏页面通过activeMenu指定要高亮的菜单项路径,无需在侧栏组件中维护额外映射affix保护固定 Tab:affix Tab 不可关闭,退出登录时自动恢复,不会丢失首页noCache控制缓存粒度:支持按路由开启/关闭 keep-alive,tabsStore.cachedNames是 computed,自动响应 Tab 变化
这个模式的核心价值不在于"用了多少字段",而在于路由配置成为中后台系统的唯一配置入口。无论未来新增菜单、面包屑、Tab 还是权限控制,都只需要修改一处。

浙公网安备 33010602011771号