ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue3 + Element Plus实现动态面包屑导航:route.matched与meta.title核心实践

Vue3 + Element Plus实现动态面包屑导航:route.matched与meta.title核心实践 做后台管理系统导航这块真的别小看。用户点了三四层菜单进来要是没有面包屑他根本不知道自己坐在哪个页面想往上退一级都不知道点哪。今天就说清楚 vue3 Element Plus 这个组合里怎么快速做一个能跟着路由自动变化的面包屑功能。我会把组件的最小实现、路由 meta 的约定、动态路由场景下的坑一次讲明白文章写给正在搭后台框架、或者准备在现有项目里补面包屑的同学不管你是刚接触 Vue3 还是已经写过两三个后台应该都能直接抄作业。1. 面包屑在后台系统里的定位以及为什么值得单独写个组件1.1 用户路径反馈这件事其实比你想的重要很多新手觉得面包屑不就是一行“首页 / 列表 / 详情”嘛有什么好写的。但你打开任何一个成熟后台系统比如若依、jeecgboot它们的顶栏位置几乎都会放面包屑。你想一下用户从左侧菜单点进一个三级页面左侧菜单是有高亮可页面顶部没有当前路径提示用户一旦点击浏览器刷新或者从详情页返回到列表再点进另一个详情这时候左侧菜单高亮还在吗还在但用户已经晕了。面包屑的本质是给用户一条“可回退的路径」。它解决的不光是定位问题更是导航的“倒车”问题。用户不需要理解整棵菜单树他只需要知道“我从哪里来还能回到哪里去”。所以这个功能在后台管理系统里属于基础体验组件不是锦上添花。1.2 数据从哪里来三种方案想清楚再动手我见过不少项目里的面包屑是写死的。比如页面组件里写一行首页 / 用户管理 / 用户列表这种方案的毛病很明显菜单结构调整了面包屑不会跟着变从别的入口跳进来面包屑可能完全不对。代码冗余非常大每个页面都得写一遍维护成本直线上升。第二种方案是自己维护一个当前路由到面包屑数据的映射也就是拿route.path去匹配一份常量配置。这种比写死好一点但依然要手动维护两份数据路由配置一份、面包屑配置一份两边容易不同步。第三种方案就是我要重点讲的直接利用route.matched。Vue Router 4 的route.matched会返回当前路由匹配到的所有路由记录从根路由到当前路由一层层完整的父子链路。配合路由meta里的标题信息面包屑组件只需要几十行代码就能实现而且路由怎么配面包屑就怎么显示菜单和路由天然就是一个数据源不会出现维护两份配置的情况。1.3 为什么用 route.matched 而不是自己计算这里有人会问我自己拿到route.path再按/切分拼出来不也行吗确实有同学这么干过但很快会发现两个问题。第一嵌套路由的父级路径可能不是简单的前缀拼接如果父路由带参数、或者子路由是相对路径纯字符串切割出来的层级和真实路由树就对不上。第二你根本拿不到父级的meta信息父级路由叫“用户中心”子路由叫“用户列表”用字符串切割你只能拿到子路由这段。而route.matched是路由库帮你算好的结果它知道你当前命中的是路由树的哪一条链每一层的path、meta、name全都有拿到就能直接用。这是最稳的做法。2. 环境准备和最小化实现2.1 搭建 Vue3 Element Plus 项目基础如果你还没有项目可以先通过 Vite 创建一个 Vue3 项目npm create vitelatest breadcrumb-demo -- --template vue cd breadcrumb-demo npm install npm install element-plus vue-router4我习惯用 Vite 来初始化启动速度快配置也简单。如果你用的是 Webpack 项目核心思路完全一样组件库和路由的引入方式稍微调整一下就行。安装完 Element Plus 之后在你的main.js里做全局注册import { createApp } from vue import ElementPlus from element-plus import element-plus/dist/index.css import App from ./App.vue import router from ./router const app createApp(App) app.use(router) app.use(ElementPlus) app.mount(#app)这里要注意Element Plus 的样式文件必须引入不然el-breadcrumb组件出来就是一堆没有样式的裸标签看起来像是失效了。按需导入也可以用unplugin-auto-import和unplugin-vue-components能自动按需加载但为了代码看得清楚这篇文章先用全量引入实际大型项目建议按需处理。2.2 meta.title 是面包屑的“数据源”先在路由配置里定好约定面包屑要显示文字就得有一个地方告诉它每一层叫什么。我的约定很简单凡是想出现在面包屑里的路由就给对应路由记录配置一个meta.title。不需要出现在面包屑里的路由比如布局容器页、重定向页就不配meta.title或者配一个meta.breadcrumb: false。看一下路由配置的示例import { createRouter, createWebHistory } from vue-router const router createRouter({ history: createWebHistory(), routes: [ { path: /, component: () import(/layout/index.vue), redirect: /home, children: [ { path: home, name: Home, component: () import(/views/home/index.vue), meta: { title: 首页, icon: HomeFilled } }, { path: user, name: User, component: () import(/views/user/index.vue), meta: { title: 用户管理, icon: User }, children: [ { path: list, name: UserList, component: () import(/views/user/list.vue), meta: { title: 用户列表 } }, { path: detail/:id, name: UserDetail, component: () import(/views/user/detail.vue), meta: { title: 用户详情 } } ] } ] } ] })注意这里的层级/是根路由/user是二级目录/user/list和/user/detail/:id是三级页面。当用户访问/user/list时route.matched会返回三条记录根路由、用户管理、用户列表。这就是面包屑的三个层级来源。2.3 第一版可用的面包屑组件先跑通再说有了路由配置组件其实就非常简单了。创建一个Breadcrumb.vuetemplate el-breadcrumb separator/ el-breadcrumb-item v-for(item, index) in breadcrumbList :keyitem.path span v-ifindex breadcrumbList.length - 1 classcurrent {{ item.meta.title }} /span router-link v-else :toitem.path {{ item.meta.title }} /router-link /el-breadcrumb-item /el-breadcrumb /template script setup import { computed } from vue import { useRoute } from vue-router const route useRoute() const breadcrumbList computed(() { return route.matched.filter((item) item.meta item.meta.title) }) /script style scoped .current { color: #606266; font-weight: 500; } /style然后在布局组件里引入它template div classapp-layout div classheader Breadcrumb / /div div classcontent router-view / /div /div /template script setup import Breadcrumb from /components/Breadcrumb.vue /script到这里一个最基础的面包屑就跑通了。你访问/user/list面包屑会显示首页 / 用户管理 / 用户列表。访问/user/detail/123显示首页 / 用户管理 / 用户详情。但是我必须说这只是“能用”离“好用”还有一段路。下面把核心逻辑逐段拆开讲顺便把那些只有真正做项目时才会踩到的坑抖出来。3. 从“能用”到“好用”核心逻辑的逐段拆解3.1 route.matched 到底给了我们什么先把你对route.matched的印象具体化。在 Vue Router 4 里route是当前激活的路由的响应式状态route.matched是一个数组数组里是按嵌套层级从外层到内层排列的RouteRecordNormalized对象。每个对象里有path、name、meta、redirect、children等属性。注意path是完整路径不是子路由的相对路径。如果你在UserDetail页面打印route.matched会得到类似这样的数据[ { path: /, name: undefined, meta: {}, redirect: /home }, { path: /user, name: User, meta: { title: 用户管理 }, children: [...] }, { path: /user/detail/:id, name: UserDetail, meta: { title: 用户详情 } } ]所以filter(item item.meta item.meta.title)就能去掉没有标题的根记录和 redirect 记录。剩下的数组正好是面包屑要展示的层级。3.2 过滤规则不是简单 filter 就完事刚才的过滤规则看起来够用但实际项目里你会发现几个场景处理不了。第一个场景父路由是一个布局容器本身有meta.title但你并不想它出现在面包屑里。比如一个路由组专门承载 iframe 页面父级只是壳子没有实际菜单意义。这种情况我给父路由加一个meta: { breadcrumb: false }过滤时把这种记录也去掉const breadcrumbList computed(() { return route.matched.filter( (item) item.meta item.meta.title item.meta.breadcrumb ! false ) })第二个场景同一个路由记录既被菜单引用也被作为某个页面的父级但你希望菜单里的名字和面包屑里的名字不完全一样。这时候可以在路由上额外配置meta.breadcrumbTitle优先级高于meta.titleconst breadcrumbList computed(() { return route.matched .filter((item) item.meta item.meta.title item.meta.breadcrumb ! false) .map((item) ({ path: item.path, title: item.meta.breadcrumbTitle || item.meta.title })) })第三个场景带参数的路由。比如详情页path是/user/detail/:id如果不处理面包屑点击“用户详情”时会跳回/user/detail/:id这个原始模式串Vue Router 会把:id当作字面量去匹配结果是找不到路由或者跳到一个错误页面。实际上我们点击中间层级的“用户详情”通常是没有意义的因为详情页每次的 id 都不一样返回应该回到列表页而不是停在半空。所以最后一个面包屑不带链接这个处理是很重要的。如果你确实希望中间某个动态路径可以回跳那就要用route.params把真实参数拼回去比如跳转/user/detail/${route.params.id}这属于特殊情况一般不建议这么干。3.3 点击行为的细节push、replace 还是 router-link面包屑里的中间项应当可以点击跳转这个跳转用什么方式也有讲究。直接用router-link是最省事的框架内部会自动生成a标签而且对target、active状态的处理都相对完善。但我个人在实际项目中更倾向于给中间项绑定click手动调用router.push。为什么不直接用router-link因为后台系统里经常会遇到“当前父级菜单只是重定向到第一个子页面”的情况。举个例子用户管理这个父级路由配置了redirect: /user/list当用户点击面包屑的“用户管理”时router.push(/user)会触发一次重定向跳到/user/list。这本身没问题但如果用户是从/user/detail/123返回的那么历史堆栈里会留下一连串记录详情页 - 用户管理 - 用户列表用户按浏览器后退按钮时反而会感觉“怎么退了两三步还在列表”。这种情况下用router.replace会更好替换掉当前历史记录不让跳级留下冗余的痕迹。所以我的推荐做法是中间项用router-link配replace属性或者手动router.replace(item.path)。两者都能实现替换跳转。示例里用router-link加一个计算好的to就行因为它天然支持replacerouter-link v-for(item, index) in breadcrumbList :keyitem.path :toitem.path replace {{ item.title }} /router-link不过要注意el-breadcrumb-item本身也支持to属性写成下面这样el-breadcrumb-item :toitem.path replace{{ item.title }}/el-breadcrumb-item它内部会渲染成router-link的行为效果是一样的。区别只是最后一个面包屑不传to让它变成纯文本样式。3.4 国际化标题和带参数路由的兼容后台系统很多要接国际化如果你的meta.title存的是中文原文那切语言时面包屑就不会跟着变。比较规范的做法是在meta里存 i18n 的 key并在面包屑组件里做一次翻译import { useI18n } from vue-i18n const { t } useI18n() const breadcrumbList computed(() { return route.matched .filter((item) item.meta item.meta.title item.meta.breadcrumb ! false) .map((item) ({ path: item.path, title: t(item.meta.title) })) })这样路由配置里写meta: { title: menu.userManagement }页面显示时会根据当前语言渲染成中文或英文。如果你项目里没接入 i18n那就不用管这一步。带参数路由还有一个细节是key不要用index或者path本身。因为动态路由的path里有:id虽然 Vue Router 会把item.path解析成匹配到的实际路径但多个详情页切换时路由记录本身是不变的只有参数在变。用path作为key会造成组件复用标题审查刷新不及时。我一般用item.name item.path route.params.id来生成唯一 key或者干脆用item.path加上当前路由完整路径。这个不算大问题但遇到了会很困惑。4. 结合动态路由和权限系统的实战改造4.1 动态添加路由后面包屑为何会失效很多项目不是写死路由的而是登录后根据用户角色动态addRoute。比如管理员能看到用户管理、订单管理普通用户只能看到首页。这种情况下route.matched一定是在路由已经被动态添加之后才能准确匹配到。很多人遇到的问题是登录后第一次进入到某个页面面包屑正常一刷新面包屑就没了或者显示成了空层级。原因很简单刷新时整个应用重新初始化动态路由还没有来得及添加路由已经开始匹配了这时候route.matched里自然少了动态路由那几层。解决思路有两个方向。第一个方向在路由全局前置守卫里在进入任何页面之前判断当前用户是否已经加载过动态路由如果没有先根据用户权限递归生成路由表并addRoute再next()放行或者用next({ ...to, replace: true })重新触发一次导航。这是若依、jeecg 这类框架的标准做法也是面试常问的vue-router 动态路由刷新丢失的答案。第二个方向如果你不想做那么重的动态路由也可以把所有的路由都静态注册好在菜单层做权限过滤。这样route.matched任何时候都能匹配到面包屑自然也不会丢。代价是路由表全部在 bundle 里安全性差一些但中小型后台完全够用。这里我个人倾向于优先用静态注册加菜单过滤代码简单且刷新问题天然不存在等真的要对页面级做权限隔离时再切换成动态addRoute。4.2 父路由 redirect 时的“重复标题”问题这个坑很隐蔽但几乎每个做嵌套路由的面包屑都会遇到。看下面这个路由配置{ path: /product, component: () import(/layout/ProductLayout.vue), redirect: /product/list, meta: { title: 产品管理 }, children: [ { path: list, name: ProductList, component: () import(/views/product/list.vue), meta: { title: 产品列表 } } ] }用户访问/product/list时route.matched会返回两条记录/product和/product/list对应的标题分别是“产品管理”和“产品列表”显示出来是产品管理 / 产品列表没问题。但是如果子路由自己也配置了和父级一样的redirect或者一个页面同时挂在两个不同父级下面就可能出现产品管理 / 产品管理 / 产品列表这种重复标题。最典型的情况是父级有meta.title同时下一个层级又是一个纯分组路由分组路由也有同样的title两层重叠了。解决办法是在过滤时检查相邻两项的 meta 是否相同const breadcrumbList computed(() { const matched route.matched.filter( (item) item.meta item.meta.title item.meta.breadcrumb ! false ) return matched.filter((item, index) { if (index matched.length - 1) return true return item.meta.title ! matched[index 1].meta.title }) })这个过滤逻辑的意思是如果当前层级和下一层级的标题一样就认为它是冗余的重定向包装层直接丢掉。4.3 隐藏项、目录项、跳转项混在一起时怎么配 meta后台系统的菜单里不是每个路由都想在菜单里显示但面包屑又希望它出现。典型场景是菜单里你只有一个“用户管理”没有单独给“用户列表”做菜单项用户点进去后页面内容其实是列表。正常的面包屑应该是首页 / 用户管理而不是首页 / 用户管理 / 用户列表。但有时候产品就觉得应该显示成三级因为从功能架构上说列表确实是一个独立页面。这种时候菜单控制用meta.hidden面包屑控制用meta.breadcrumb两者是独立的配置项不要混用。我见过不少同学用hidden去过滤面包屑结果把想显示的层悄悄滤掉了还找不出原因。建议在路由 meta 设计上明确约定title页面标题面包屑和浏览器的 document.title 都用它。icon菜单图标面包屑不关心。hidden是否在左侧菜单中隐藏。breadcrumb是否在面包屑中显示默认 true。activeMenu当不想让当前菜单高亮时可以指定另一个路由的高亮路径这个配置和面包屑无关但常和隐藏页一起出现。这样把关注点拆开之后面包屑组件永远只根据breadcrumb和title来决定显示不会被菜单的显示逻辑带着跑。5. 常见问题排查清单和性能细节5.1 面包屑消失了从这三个方向查第一个方向看路由是静态的还是动态的。动态路由就检查刷新时是否重新addRoute了节奏上一定是先 addRoute 再跳转。如果你在根组件setup里同步 addRoute刷新时组件还没创建路由就匹配完了必然丢。正确办法是在全局守卫里做或者使用router.isReady()后再挂载应用。Vue Router 4 里router.isReady()返回一个 Promise可以等动态路由添加完成后再app.mount(#app)这也是官方推荐的启动方式。第二个方向看route.matched是否真的返回了完整链路。在浏览器控制台里打印一下当前路由的matched数组就一目了然。第三个方向看meta.title有没有拼写错误。很多人会写成meta: { titile: 首页 }这种错误在菜单里可能没影响但面包屑一 filter 就全没了排查的时候很容易忽略。5.2 标题变成 undefined 或直接显示路径这个问题多半是过滤条件没写完整。有些路由记录的meta是{}meta.title自然取不到。建议在过滤时对meta做一次空值判断像我前面代码里写的item.meta item.meta.title这样哪怕某条记录没有meta也不会报错。如果显示成路径说明你用了item.path作为显示内容但当前记录没有标题。我不建议拿路径做兜底因为服务端返回的路径经常是/api/user/list这种展示出来很不友好。比较好的做法是没有meta.title就不展示这一层同时在开发环境下用console.warn提示开发者“路由缺少 title 配置”帮助尽早发现问题。5.3 分隔符、样式层级和文本溢出的处理el-breadcrumb的默认分隔符是/你可以通过separator属性改成或者任意字符串el-breadcrumb separator分隔符用图标也行separator支持插槽用起来很灵活。样式上我习惯把最后一层文字调深并加粗中间层级用主题色点击时加一点 hover 变色。这些样式在 Element Plus 里覆盖起来并不复杂注意要使用:deep()才能穿透到el-breadcrumb内部的 span:deep(.el-breadcrumb__inner) { font-weight: 400; color: #909399; } :deep(.el-breadcrumb__inner:hover) { color: #409eff; }文本溢出问题在面包屑里也很常见。当页面标题很长比如“2024年度第三季度华东区域销售数据分析报告”再来一个“详情”面包屑整体撑得老宽。解决思路是限制中间项的max-width并加省略号最后一项优先保证完整显示。如果你的产品接受也可以让中间项设一个max-width: 160px超出部分用text-overflow: ellipsis。这个在代码层面是给span或router-link加 class再写一点样式就行。5.4 和 tabs 标签页的联动思路最后说一个很多人做后台时会遇到的扩展需求面包屑和顶部标签页联动。有些系统里面包屑只是标签页的一个辅助展示用户切换到某个标签页时面包屑要跟着变。如果你的标签页状态不是基于路由生成的而是保存在 Pinia 里那就需要让面包屑数据源和标签页保持同步。我的做法是面包屑组件里同时监听route.path和标签页 store 的当前激活标签显示时以激活标签的 meta 为准而不是直接使用route.matched。因为当用户从标签页切换回来时路由确实是激活标签对应的路由route.matched其实也会变不需要额外处理。真正要处理的是从标签页关闭某个详情页后再次访问相同路由但参数不同面包屑标题需要更新。这种情况不要依赖组件内部状态确保breadcrumbList是一个computed而不是在watch里手动赋值给一个ref。computed天然会根据route的变化重新计算不会出现缓存没刷新的问题。这是我在实际项目里踩过一次的坑调了半天才发现自己在watch里把计算值赋给了普通数组导致路由参数变了面包屑还显示上一次的标题。还有一个细节是面包屑组件不要和菜单组件共用同一个computed函数。因为菜单数据可能来自后端接口里面有菜单级id、排序字段而面包屑只需要路由链上的标题。强行复用一套数据会让组件之间的数据依赖变得很重。分开维护各取所需代码反而好读。写在最后的一点经验做面包屑组件技术上没有任何难度难的是从一开始就把路由 meta 的约定定清楚。标题字段、隐藏字段、是否参与面包屑这些不影响跑不跑得通但决定了项目半年后好不好维护。我见过一个项目面包屑写了一百多行还维护了一张映射表原因就是当初没有用route.matched走了弯路。按照这篇文章的思路先约定meta.title再用route.matched过滤生成后面不管加多少层子路由面包屑都自动跟着长出来这部分精力省下来拿来补别的功能不香吗。如果你已经照着实现出来我建议你顺手再测几个场景打开一个详情页刷新看面包屑是否还在从列表进入详情再返回面包屑是否还正确切换语言后标题是否跟着变。这三关过了你的面包屑在权限系统、多级菜单、国际化项目里基本都可以放心用。
返回列表