AIGC标识 项目实践: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-routerRouteMeta 接口。

// 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_modulesvue-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 的路由不展示
  • 只取 titleicon,不关心路由的 componentpath 等细节
  • 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    │  │守卫      │
    │高亮        │   │            │  │              │  │          │
    └────────────┘   └────────────┘  └──────────────┘  └──────────┘

十一、总结

  1. 单一事实来源:路由配置是唯一的配置入口,菜单、面包屑、Tab、缓存、鉴权都从 meta 派生,不存在第二份配置
  2. 声明合并:通过 declare module 'vue-router' 扩充 RouteMeta,获得类型安全,不需要额外的类型定义文件
  3. 字段按职责划分:每个字段只做一件事,消费方也只取自己需要的字段,互不耦合
  4. useMenuTree 是转译层:路由配置 → 菜单树的转换由 composable 完成,侧栏组件不直接操作路由,职责清晰
  5. activeMenu 解决详情页高亮:隐藏页面通过 activeMenu 指定要高亮的菜单项路径,无需在侧栏组件中维护额外映射
  6. affix 保护固定 Tab:affix Tab 不可关闭,退出登录时自动恢复,不会丢失首页
  7. noCache 控制缓存粒度:支持按路由开启/关闭 keep-alive,tabsStore.cachedNames 是 computed,自动响应 Tab 变化

这个模式的核心价值不在于"用了多少字段",而在于路由配置成为中后台系统的唯一配置入口。无论未来新增菜单、面包屑、Tab 还是权限控制,都只需要修改一处。

posted @ 2026-07-24 16:24  bleemyoung  阅读(16)  评论(0)    收藏  举报