阅读视图

发现新文章,点击刷新页面。

Vue 3 统一面包屑导航系统:从配置地狱到单一数据源

本文分享我们在 Vue 3 + TypeScript 项目中重构面包屑导航系统的实践经验,通过将面包屑配置迁移到路由 meta 中,实现了配置的单一数据源,大幅降低了维护成本。

一、问题背景

1.1 原有架构的痛点

在重构之前,我们的面包屑系统采用独立的配置文件 breadcrumb.ts,存在以下问题:

// 旧方案:独立的面包屑配置文件(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: '编辑订舱' },
      // ... 更多子页面
    ],
  },
  // ... 几十个类似的配置
]

主要痛点:

  1. 配置分散:路由定义在 router/modules/*.ts,面包屑配置在 config/breadcrumb.ts,新增页面需要修改两处
  2. 维护成本高:配置文件超过 700 行,嵌套结构复杂,容易出错
  3. 同步困难:路由变更后容易忘记更新面包屑配置,导致显示异常
  4. 类型安全差:配置与路由之间缺乏类型关联

1.2 期望目标

  • 单一数据源:面包屑配置与路由定义合并,一处修改全局生效
  • 类型安全:利用 TypeScript 确保配置正确性
  • 易于维护:新增页面只需在路由配置中添加一行
  • 向后兼容:平滑迁移,不影响现有功能

二、技术方案

2.1 核心思路

将面包屑路径配置到路由的 meta 字段中,通过 Composable 自动解析生成面包屑导航。

路由配置 (meta.breadcrumb) → useBreadcrumb() → BreadCrumb.vue

2.2 扩展路由 Meta 类型

首先,扩展 Vue Router 的 RouteMeta 接口:

// 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:

// 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 中:

// 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 即可:

<!-- 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 渐进式迁移

为了确保平滑过渡,我们采用渐进式迁移策略:

  1. 阶段一:新增 useBreadcrumb Composable,支持从路由 meta 读取配置
  2. 阶段二:逐个模块添加 breadcrumb meta 字段
  3. 阶段三:验证所有页面面包屑正常后,删除旧配置文件

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 国际化配置

确保所有菜单分类节点都有对应的国际化配置:

// 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 新增页面对比

重构前:

// 1. 修改路由配置
{ path: '/new-page', name: 'NewPage', component: ... }

// 2. 修改面包屑配置(容易遗漏!)
{ path: '/new-page', name: 'NewPage', title: '新页面', ... }

重构后:

// 只需修改路由配置
{
  path: '/new-page',
  name: 'NewPage',
  component: ...,
  meta: {
    title: '新页面',
    breadcrumb: ['ParentMenu', 'NewPage'],
  },
}

五、最佳实践

5.1 面包屑配置规范

// ✅ 推荐:使用路由名称数组
breadcrumb: ['Export', 'BookingManage', 'BookingCreate']

// ❌ 避免:使用路径
breadcrumb: ['/export', '/export/booking', '/export/booking/create']

5.2 虚拟节点使用场景

当菜单分类本身不是一个可访问的页面时,使用虚拟节点:

// "出口" 是菜单分类,不是实际页面
const VIRTUAL_ROUTES = {
  Export: { title: '出口', i18nKey: 'export' },
}

// 路由配置
breadcrumb: ['Export', 'BookingManage'] // 出口 > 订舱管理

5.3 动态路由处理

对于带参数的动态路由,Composable 会自动使用当前路由的完整路径:

// 路由定义
{ path: '/export/lading/edit/:id', name: 'BookingLadingEdit', ... }

// 面包屑配置
breadcrumb: ['Export', 'BookingLadingManagement', 'BookingLadingEdit']

// 实际显示:出口 > 提单管理 > 编辑提单
// 最后一项路径:/export/lading/edit/123(保留实际 ID)

六、总结

通过将面包屑配置迁移到路由 meta 中,我们实现了:

  1. 单一数据源:路由配置即面包屑配置,消除了配置分散的问题
  2. 维护成本降低:删除了 700+ 行的独立配置文件
  3. 开发效率提升:新增页面只需修改一处
  4. 类型安全增强:TypeScript 类型检查确保配置正确性
  5. 国际化支持:无缝集成 vue-i18n

这种方案特别适合中大型 Vue 3 项目,尤其是菜单结构复杂、页面数量多的企业级应用。


相关技术栈:

  • Vue 3.5+ (Composition API)
  • Vue Router 4
  • TypeScript 5+
  • vue-i18n

参考资料:

前端基础数据中心:从混乱到统一的架构演进

本文记录了我们团队在 Vue 3 + TypeScript 项目中,如何将散乱的基础数据管理逻辑重构为统一的「基础数据中心」。如果你的项目也有类似的痛点,希望这篇文章能给你一些参考。

一、问题是怎么来的

做过 B 端系统的同学应该都有体会——基础数据无处不在。港口、船舶、航线、货币、字典……这些数据在几乎每个页面都会用到,要么是下拉选择,要么是代码翻译,要么是表格筛选。

我们项目一开始的做法很「朴素」:哪里用到就哪里请求。后来发现这样不行,同一个港口列表接口一个页面能请求三四次。于是开始加缓存,问题是加着加着,代码变成了这样:

store/basicData/cache.ts      <- Pinia 实现的缓存
composables/basicData/cache.ts  <- VueUse + localStorage 实现的缓存
store/port.ts                   <- 独立的港口缓存(历史遗留)

三套缓存系统,各自为政。更要命的是 CACHE_KEYS 这个常量在两个地方都有定义,改一处忘一处是常态。

某天排查一个 bug:用户反馈页面显示的港口名称和实际不一致。查了半天发现是两套缓存系统的数据版本不同步——A 组件用的 Pinia 缓存已经过期刷新了,B 组件用的 localStorage 缓存还是旧数据。

是时候重构了。

二、想清楚再动手

重构之前,我们先梳理了需求优先级:

需求 优先级 说明
跨组件数据共享 P0 同一份数据,全局只请求一次
缓存 + 过期机制 P0 减少请求,但数据要能自动刷新
请求去重 P1 并发请求同一接口时,只发一次
持久化 P1 关键数据存 localStorage,提升首屏速度
DevTools 调试 P2 能在 Vue DevTools 里看到缓存状态

基于这些需求,我们确定了架构原则:

Store 管状态,Composable 封业务,Component 只消费。

三、分层架构设计

最终的架构分三层:

┌─────────────────────────────────────────────────┐
│               Component Layer                    │
│              (Vue 组件/页面)                     │
│  只使用 Composables,不直接访问 Store            │
└─────────────────────────────────────────────────┘
                        │
                        ▼
┌─────────────────────────────────────────────────┐
│              Composable Layer                    │
│           (composables/basicData/)              │
│  usePorts / useVessels / useDict / ...          │
│  封装 Store,提供业务友好的 API                  │
└─────────────────────────────────────────────────┘
                        │
                        ▼
┌─────────────────────────────────────────────────┐
│                Store Layer                       │
│             (store/basicData/)                  │
│  useBasicDataStore                              │
│  统一缓存、加载状态、请求去重、持久化            │
└─────────────────────────────────────────────────┘

为什么要分这么多层?

  • Store 层:单一数据源,解决「数据从哪来」的问题
  • Composable 层:业务封装,解决「数据怎么用」的问题
  • Component 层:纯消费,只关心「界面怎么展示」

这样分层之后,职责边界就清晰了。组件开发者不用关心缓存策略,只管调 usePorts() 拿数据就行。

四、核心实现

4.1 Store 层:请求去重是关键

Store 层最核心的逻辑是 loadData 方法。这里要处理三种情况:

  1. 缓存命中 → 直接返回
  2. 有相同请求正在进行 → 复用已有 Promise
  3. 发起新请求 → 请求完成后写入缓存
// store/basicData/useBasicData.ts
export const useBasicDataStore = defineStore('basic-data', () => {
  const cacheMap = ref<Map<BasicDataType, CacheEntry>>(new Map())
  const pendingRequests = new Map<BasicDataType, Promise<unknown>>()

  async function loadData<T>(
    type: BasicDataType,
    fetcher: () => Promise<T>,
    config?: CacheConfig
  ): Promise<T | null> {
    // 1. 缓存命中
    const cached = getCache<T>(type)
    if (cached !== null) return cached

    // 2. 请求去重——这是关键
    const pending = pendingRequests.get(type)
    if (pending) return pending as Promise<T | null>

    // 3. 发起新请求
    const request = (async () => {
      try {
        const data = await fetcher()
        setCache(type, data, config)
        return data
      } finally {
        pendingRequests.delete(type)
      }
    })()

    pendingRequests.set(type, request)
    return request
  }

  return { loadData, getCache, setCache, clearCache }
})

请求去重的实现很简单:用一个 Map 存储正在进行的 Promise。当第二个请求进来时,直接返回已有的 Promise,不发新请求。

这样即使页面上 10 个组件同时调用 usePorts(),实际 API 请求也只有 1 次。

4.2 Composable 层:工厂函数批量生成

港口、船舶、航线……这些 Composable 的逻辑高度相似,用工厂函数批量生成:

// composables/basicData/hooks.ts
function createBasicDataComposable<T extends BaseDataItem>(
  type: BasicDataType,
  fetcher: () => Promise<T[]>,
  config?: CacheConfig
) {
  return () => {
    const store = useBasicDataStore()

    // 响应式数据
    const data = computed(() => store.getCache<T[]>(type) || [])
    const loading = computed(() => store.getLoadingState(type).loading)
    const isReady = computed(() => data.value.length > 0)

    // 自动加载
    store.loadData(type, fetcher, config)

    // 业务方法
    const getByCode = (code: string) => 
      data.value.find(item => item.code === code)

    const options = computed(() => 
      data.value.map(item => ({
        label: item.nameCn,
        value: item.code
      }))
    )

    return { data, loading, isReady, getByCode, options, refresh }
  }
}

// 一行代码定义一个 Composable
export const usePorts = createBasicDataComposable('ports', fetchPorts, { ttl: 15 * 60 * 1000 })
export const useVessels = createBasicDataComposable('vessels', fetchVessels, { ttl: 15 * 60 * 1000 })
export const useLanes = createBasicDataComposable('lanes', fetchLanes, { ttl: 30 * 60 * 1000 })

这样做的好处是:

  • 新增一种基础数据,只需加一行代码
  • 所有 Composable 的 API 完全一致,学习成本低
  • 类型安全,TypeScript 能正确推断返回类型

4.3 字典数据:特殊处理

字典数据稍微复杂一些,因为它是按类型分组的。我们单独封装了 useDict

export function useDict() {
  const store = useBasicDataStore()

  // 加载全量字典数据
  store.loadData('dict', fetchAllDict, { ttl: 30 * 60 * 1000 })

  const getDictItems = (dictType: string) => {
    const all = store.getCache<DictData>('dict') || {}
    return all[dictType] || []
  }

  const getDictLabel = (dictType: string, value: string) => {
    const items = getDictItems(dictType)
    return items.find(item => item.value === value)?.label || value
  }

  const getDictOptions = (dictType: string) => {
    return getDictItems(dictType).map(item => ({
      label: item.label,
      value: item.value
    }))
  }

  return { getDictItems, getDictLabel, getDictOptions }
}

使用起来非常直观:

<script setup>
const dict = useDict()
const cargoTypeLabel = dict.getDictLabel('CARGO_TYPE', 'FCL') // "整箱"
</script>

<template>
  <el-select>
    <el-option 
      v-for="opt in dict.getDictOptions('CARGO_TYPE')" 
      :key="opt.value" 
      v-bind="opt" 
    />
  </el-select>
</template>

五、实际使用场景

场景一:下拉选择器

最常见的场景。以前要自己请求数据、处理格式,现在一行搞定:

<script setup>
import { usePorts } from '@/composables/basicData'

const { options: portOptions, loading } = usePorts()
const selectedPort = ref('')
</script>

<template>
  <el-select v-model="selectedPort" :loading="loading" filterable>
    <el-option v-for="opt in portOptions" :key="opt.value" v-bind="opt" />
  </el-select>
</template>

场景二:表格中的代码翻译

订单列表里显示港口代码,用户看不懂,要翻译成中文:

<script setup>
import { usePorts } from '@/composables/basicData'

const { getByCode } = usePorts()

// 翻译函数
const translatePort = (code: string) => getByCode(code)?.nameCn || code
</script>

<template>
  <el-table :data="orderList">
    <el-table-column prop="polCode" label="起运港">
      <template #default="{ row }">
        {{ translatePort(row.polCode) }}
      </template>
    </el-table-column>
  </el-table>
</template>

场景三:字典标签渲染

状态、类型这类字段,通常要显示成带颜色的标签:

<script setup>
import { useDict } from '@/composables/basicData'

const dict = useDict()
</script>

<template>
  <el-tag :type="dict.getDictColorType('ORDER_STATUS', row.status)">
    {{ dict.getDictLabel('ORDER_STATUS', row.status) }}
  </el-tag>
</template>

场景四:数据刷新

用户修改了基础数据,需要刷新缓存:

import { usePorts, clearAllCache } from '@/composables/basicData'

const { refresh: refreshPorts } = usePorts()

// 刷新单个
await refreshPorts()

// 刷新全部
clearAllCache()

六、缓存策略

不同数据的变化频率不同,缓存策略也不一样:

数据类型 TTL 持久化 原因
国家/货币 1 小时 几乎不变
港口/码头 15-30 分钟 偶尔变化
船舶 15 分钟 数据量大(10万+),不适合 localStorage
航线/堆场 30 分钟 相对稳定
字典 30 分钟 偶尔变化

持久化用的是 localStorage,配合 TTL 一起使用。数据写入时记录时间戳,读取时检查是否过期。

船舶数据量太大,存 localStorage 会导致写入超时,所以不做持久化,每次刷新页面重新请求。

七、调试支持

用 Pinia 还有一个好处:Vue DevTools 原生支持。

打开 DevTools,切到 Pinia 面板,能看到:

  • 当前缓存了哪些数据
  • 每种数据的加载状态
  • 数据的具体内容

排查问题时非常方便。

另外我们还提供了 getCacheInfo() 方法,可以在控制台查看缓存统计:

import { getCacheInfo } from '@/composables/basicData'

console.log(getCacheInfo())
// {
//   ports: { cached: true, size: 102400, remainingTime: 600000 },
//   vessels: { cached: false, size: 0, remainingTime: 0 },
//   ...
// }

八、踩过的坑

坑 1:响应式丢失

一开始我们这样写:

// ❌ 错误写法
const { data } = usePorts()
const portList = data.value // 丢失响应式!

datacomputed,取 .value 之后就变成普通值了,后续数据更新不会触发视图刷新。

正确做法是保持响应式引用:

// ✅ 正确写法
const { data: portList } = usePorts()
// 或者
const portList = computed(() => usePorts().data.value)

坑 2:循环依赖

Store 和 Composable 互相引用导致循环依赖。解决办法是严格遵守分层原则:Composable 可以引用 Store,Store 不能引用 Composable。

坑 3:SSR 兼容

localStorage 在服务端不存在。如果你的项目需要 SSR,持久化逻辑要加判断:

const storage = typeof window !== 'undefined' ? localStorage : null

九、总结

重构前后的对比:

维度 重构前 重构后
缓存系统 3 套并存 1 套统一
代码复用 到处复制粘贴 工厂函数批量生成
请求优化 无去重,重复请求 自动去重
调试 只能打 log DevTools 原生支持
类型安全 部分 any 完整类型推断

核心收益:

  1. 开发效率提升:新增基础数据类型从半天缩短到 10 分钟
  2. Bug 减少:数据不一致问题基本消失
  3. 性能优化:重复请求减少 60%+

如果你的项目也有类似的基础数据管理问题,可以参考这个思路。关键是想清楚分层,把「状态管理」和「业务封装」分开,剩下的就是体力活了。


本文基于实际项目经验整理,代码已做脱敏处理。欢迎讨论交流。

❌