本文分享我们在 Vue 3 + TypeScript 项目中重构面包屑导航系统的实践经验,通过将面包屑配置迁移到路由 meta 中,实现了配置的单一数据源,大幅降低了维护成本。
一、问题背景
1.1 原有架构的痛点
在重构之前,我们的面包屑系统采用独立的配置文件 breadcrumb.ts,存在以下问题:
typescript
// 旧方案:独立的面包屑配置文件(700+ 行)
export const breadcrumbConfigs: BreadcrumbItemConfig[] = [
{
path: '/export/booking',
name: 'BookingManage',
title: '订舱管理',
showInBreadcrumb: true,
children: [
{ path: '/export/booking/create', name: 'BookingCreate', title: '新建订舱' },
{ path: '/export/booking/edit', name: 'BookingEdit', title: '编辑订舱' },
// ... 更多子页面
],
},
// ... 几十个类似的配置
]
主要痛点:
- 配置分散 :路由定义在
router/modules/*.ts,面包屑配置在config/breadcrumb.ts,新增页面需要修改两处 - 维护成本高:配置文件超过 700 行,嵌套结构复杂,容易出错
- 同步困难:路由变更后容易忘记更新面包屑配置,导致显示异常
- 类型安全差:配置与路由之间缺乏类型关联
1.2 期望目标
- 单一数据源:面包屑配置与路由定义合并,一处修改全局生效
- 类型安全:利用 TypeScript 确保配置正确性
- 易于维护:新增页面只需在路由配置中添加一行
- 向后兼容:平滑迁移,不影响现有功能
二、技术方案
2.1 核心思路
将面包屑路径配置到路由的 meta 字段中,通过 Composable 自动解析生成面包屑导航。
scss
路由配置 (meta.breadcrumb) → useBreadcrumb() → BreadCrumb.vue
2.2 扩展路由 Meta 类型
首先,扩展 Vue Router 的 RouteMeta 接口:
typescript
// src/router/types.ts
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
/** 页面标题 */
title?: string
/** 国际化 key */
i18nKey?: string
/** 面包屑路径(路由名称数组) */
breadcrumb?: string[]
/** 是否缓存 */
keepAlive?: boolean
// ... 其他字段
}
}
/** 面包屑项类型 */
export interface BreadcrumbItem {
title: string
path: string
name: string
i18nKey?: string
isClickable: boolean
}
2.3 路由配置示例
在路由模块中添加 breadcrumb meta:
typescript
// src/router/modules/export.ts
export const exportRoutes: RouteRecordRaw[] = [
{
path: '/export/booking',
name: 'BookingManage',
component: () => import('~/views/export/booking/index.vue'),
meta: {
title: '订舱管理',
keepAlive: true,
breadcrumb: ['Export', 'BookingManage'], // 出口 > 订舱管理
},
},
{
path: '/export/booking/create/:mode',
name: 'BookingCreate',
component: () => import('~/views/export/booking/create.vue'),
meta: {
title: '新建订舱',
breadcrumb: ['Export', 'BookingManage', 'BookingCreate'], // 出口 > 订舱管理 > 新建订舱
},
},
]
配置规则:
- 数组元素为路由名称(
name)或虚拟节点名称 - 按层级顺序排列:
[一级菜单, 二级菜单, 当前页面] - 空数组
[]表示不显示面包屑(如首页)
2.4 useBreadcrumb Composable
核心逻辑封装在 Composable 中:
typescript
// src/composables/useBreadcrumb.ts
import type { BreadcrumbItem } from '~/router/types'
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
/**
* 虚拟路由配置(菜单分类节点)
* 这些节点在路由系统中不存在,但需要在面包屑中显示
*/
const VIRTUAL_ROUTES: Record<string, { title: string, i18nKey: string }> = {
Mine: { title: '我的', i18nKey: 'mine' },
Export: { title: '出口', i18nKey: 'export' },
Import: { title: '进口', i18nKey: 'import' },
Finance: { title: '财务', i18nKey: 'finance' },
BoxManage: { title: '箱管', i18nKey: 'boxManage' },
}
export function useBreadcrumb() {
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
/** 根据路由名称获取路由信息 */
function getRouteByName(name: string) {
return router.getRoutes().find(r => r.name === name)
}
/** 获取面包屑项的标题(支持国际化) */
function getTitle(name: string, routeRecord?: RouteRecordNormalized): string {
// 优先使用虚拟路由配置
if (VIRTUAL_ROUTES[name]) {
return t(`system.routes.${VIRTUAL_ROUTES[name].i18nKey}`, VIRTUAL_ROUTES[name].title)
}
// 使用路由 meta 配置
if (routeRecord?.meta?.i18nKey) {
return t(`system.routes.${routeRecord.meta.i18nKey}`, routeRecord.meta.title || name)
}
return routeRecord?.meta?.title || name
}
/** 计算面包屑列表 */
const breadcrumbs = computed<BreadcrumbItem[]>(() => {
const routeName = route.name as string
if (!routeName) return []
// 从路由 meta 获取面包屑配置
const breadcrumbPath = route.meta?.breadcrumb as string[]
if (!breadcrumbPath || breadcrumbPath.length === 0) {
return []
}
// 构建面包屑列表
return breadcrumbPath.map((name, index) => {
const routeRecord = getRouteByName(name)
const isLast = index === breadcrumbPath.length - 1
const isVirtual = !!VIRTUAL_ROUTES[name]
return {
title: getTitle(name, routeRecord),
path: isLast ? route.path : (routeRecord?.path || ''),
name,
i18nKey: isVirtual ? VIRTUAL_ROUTES[name].i18nKey : routeRecord?.meta?.i18nKey,
isClickable: !isLast && !isVirtual && !!routeRecord,
}
})
})
/** 是否应该显示面包屑 */
const shouldShow = computed<boolean>(() => {
// 首页、登录页等不显示面包屑
const hiddenPaths = ['/', '/index', '/dashboard', '/login', '/register']
if (hiddenPaths.includes(route.path)) {
return false
}
return breadcrumbs.value.length > 0
})
return { breadcrumbs, shouldShow }
}
2.5 面包屑组件
组件只需调用 Composable 即可:
vue
<!-- src/layout/components/BreadCrumb/BreadCrumb.vue -->
<script setup lang="ts">
import { useBreadcrumb } from '~/composables'
const { breadcrumbs, shouldShow } = useBreadcrumb()
</script>
<template>
<el-breadcrumb v-if="shouldShow" separator="/">
<el-breadcrumb-item
v-for="item in breadcrumbs"
:key="item.name"
:to="item.isClickable ? item.path : undefined"
>
{{ item.title }}
</el-breadcrumb-item>
</el-breadcrumb>
</template>
三、迁移策略
3.1 渐进式迁移
为了确保平滑过渡,我们采用渐进式迁移策略:
- 阶段一 :新增
useBreadcrumbComposable,支持从路由 meta 读取配置 - 阶段二 :逐个模块添加
breadcrumbmeta 字段 - 阶段三:验证所有页面面包屑正常后,删除旧配置文件
3.2 迁移清单
| 模块 | 文件 | 页面数 |
|---|---|---|
| 核心页面 | core.ts |
4 |
| 用户管理 | user.ts |
3 |
| 查询服务 | search_service.ts |
6 |
| 出口业务 | export.ts |
25+ |
| 进口业务 | import.ts |
4 |
| 财务结算 | payment_settlement.ts |
6 |
| 箱管业务 | equipment-control.ts |
12 |
3.3 国际化配置
确保所有菜单分类节点都有对应的国际化配置:
typescript
// src/i18n/zh/system.ts
export default {
routes: {
// 菜单分类节点
mine: '我的',
export: '出口',
import: '进口',
finance: '财务',
boxManage: '箱管',
// 具体页面
bookingManage: '订舱管理',
bookingCreate: '新建订舱',
// ...
},
}
四、效果对比
4.1 代码量对比
| 指标 | 重构前 | 重构后 | 变化 |
|---|---|---|---|
| 配置文件行数 | 737 行 | 0 行(已删除) | -100% |
| 新增页面修改文件数 | 2 个 | 1 个 | -50% |
| 类型安全 | 弱 | 强 | ✅ |
4.2 新增页面对比
重构前:
typescript
// 1. 修改路由配置
{ path: '/new-page', name: 'NewPage', component: ... }
// 2. 修改面包屑配置(容易遗漏!)
{ path: '/new-page', name: 'NewPage', title: '新页面', ... }
重构后:
typescript
// 只需修改路由配置
{
path: '/new-page',
name: 'NewPage',
component: ...,
meta: {
title: '新页面',
breadcrumb: ['ParentMenu', 'NewPage'],
},
}
五、最佳实践
5.1 面包屑配置规范
typescript
// ✅ 推荐:使用路由名称数组
breadcrumb: ['Export', 'BookingManage', 'BookingCreate']
// ❌ 避免:使用路径
breadcrumb: ['/export', '/export/booking', '/export/booking/create']
5.2 虚拟节点使用场景
当菜单分类本身不是一个可访问的页面时,使用虚拟节点:
typescript
// "出口" 是菜单分类,不是实际页面
const VIRTUAL_ROUTES = {
Export: { title: '出口', i18nKey: 'export' },
}
// 路由配置
breadcrumb: ['Export', 'BookingManage'] // 出口 > 订舱管理
5.3 动态路由处理
对于带参数的动态路由,Composable 会自动使用当前路由的完整路径:
typescript
// 路由定义
{ path: '/export/lading/edit/:id', name: 'BookingLadingEdit', ... }
// 面包屑配置
breadcrumb: ['Export', 'BookingLadingManagement', 'BookingLadingEdit']
// 实际显示:出口 > 提单管理 > 编辑提单
// 最后一项路径:/export/lading/edit/123(保留实际 ID)
六、总结
通过将面包屑配置迁移到路由 meta 中,我们实现了:
- 单一数据源:路由配置即面包屑配置,消除了配置分散的问题
- 维护成本降低:删除了 700+ 行的独立配置文件
- 开发效率提升:新增页面只需修改一处
- 类型安全增强:TypeScript 类型检查确保配置正确性
- 国际化支持:无缝集成 vue-i18n
这种方案特别适合中大型 Vue 3 项目,尤其是菜单结构复杂、页面数量多的企业级应用。
相关技术栈:
- Vue 3.5+ (Composition API)
- Vue Router 4
- TypeScript 5+
- vue-i18n
参考资料: