Vue3 中封装 Element Plus 无限级菜单的基本思路
前阵子重构一个后台的侧边菜单,我先给自己立了条规矩:递归组件本身不许超过 60 行。这不是为了炫技,而是因为上一版菜单组件快 400 行,模板里塞满 v-if,权限判断、图标判断、路由判断全揉在一起,改一个小需求都得提心吊胆,你不知道哪个分支又会牵动别的逻辑。
真动手才发现,把它压到 60 行以内不难,难的是想清楚哪些逻辑该留在组件里、哪些该在数据进组件之前就处理干净。无限级菜单看着只是 UI 递归,Vue3 加 Element Plus 里核心也就两点——统一数据结构、递归渲染菜单项。但实际项目里它牵着路由、高亮、权限、图标、折叠状态和接口数据清洗,职责一乱,组件立刻就重回四百行。这篇就把我这次拆分数据层和模板层的过程记下来,代码都是真在项目里跑过的。
先把数据结构定死
递归渲染的前提是数据结构稳定。我给菜单项定的结构是这样:
1export interface MenuItem { 2 title: string; 3 index: string; 4 icon?: string; 5 path?: string; 6 permission?: string; 7 external?: boolean; 8 children?: MenuItem[]; 9}
title 显示名称,index 对应 Element Plus 菜单项的唯一标识,path 用于跳转,children 是子菜单。结构一稳,递归模板就清楚了。
但后端返回的字段不一定刚好长这样,所以第一步不是渲染,是转换——把接口数据整理成组件要的结构:
1interface ApiMenu { 2 name: string; 3 routePath?: string; 4 code: string; 5 children?: ApiMenu[]; 6} 7 8function normalizeMenus(list: ApiMenu[]): MenuItem[] { 9 return list.map((item) => ({ 10 title: item.name, 11 index: item.routePath || item.code, 12 path: item.routePath, 13 children: item.children ? normalizeMenus(item.children) : undefined, 14 })); 15}
组件绝不直接依赖后端原始字段。这层缓冲是我吃过亏才认真做的:早期图省事在组件里直接用了后端的 name、routePath,有次后端把 routePath 改成 route_path 加了个下划线,整个菜单全空了。有这层转换函数,改一行就能搞定,影响锁在一个文件里。后端字段你控制不了,给它一个缓冲层,心里踏实。
转换时顺手把脏数据抹平也很合适——name 带前后空格、children 是空数组而不是 undefined,都在这里统一处理:
1function normalizeMenus(list: ApiMenu[]): MenuItem[] { 2 return (list || []).map((item) => { 3 const children = 4 item.children && item.children.length 5 ? normalizeMenus(item.children) 6 : undefined; 7 8 return { 9 title: item.name?.trim() ?? '', 10 index: item.routePath || item.code, 11 path: item.routePath, 12 children, 13 }; 14 }); 15}
外层的 list || [] 是因为接口偶尔返回 null,尤其某些角色一个菜单都没配的时候。不加这行,页面直接白屏报错。
如果菜单还驱动动态路由,就更不能信任后端传来的组件路径。后端可以给页面 code,但前端要维护组件白名单:
1const viewModules = import.meta.glob('@/views/**/*.vue'); 2 3function resolveView(componentPath: string) { 4 const loader = viewModules[`/src/views/${componentPath}.vue`]; 5 6 if (!loader) { 7 throw new Error(`Unknown menu component: ${componentPath}`); 8 } 9 10 return loader; 11}
这样后端只能选前端已经打包进来的页面,不能随便传个字符串让前端去 import。动态菜单和动态路由越灵活,前端能信任后端的范围就越要收紧。
有没有子级,判断一次用到底
Element Plus 里有子菜单渲染 el-sub-menu,没有渲染 el-menu-item。判断本身很简单:
1function hasChildren(item: MenuItem) { 2 return Array.isArray(item.children) && item.children.length > 0; 3}
这个判断会在递归组件里反复用到。顺手再过滤掉空子级,免得渲染出没有内容的 el-sub-menu:
1function getChildren(item: MenuItem) { 2 return item.children?.filter(Boolean) || []; 3} 4 5function hasChildren(item: MenuItem) { 6 return getChildren(item).length > 0; 7}
递归组件自己调用自己
把单个菜单项封成一个组件 MenuItem.vue,核心就是它内部再次调用自己:
1<template> 2 <el-sub-menu v-if="hasChildren(item)" :index="item.index"> 3 <template #title> 4 <span>{{ item.title }}</span> 5 </template> 6 7 <MenuItem 8 v-for="child in item.children" 9 :key="child.index" 10 :item="child" 11 /> 12 </el-sub-menu> 13 14 <el-menu-item v-else :index="item.index"> 15 <span>{{ item.title }}</span> 16 </el-menu-item> 17</template>
只要 children 还有数据,就继续往下渲染。Vue 3.3 之后 <script setup> 可以直接用 defineOptions 声明组件名,递归组件恰好需要一个明确的名字:
1<script setup lang="ts"> 2import type { MenuItem } from './types'; 3 4defineOptions({ 5 name: 'MenuItem', 6}); 7 8defineProps<{ 9 item: MenuItem; 10}>(); 11 12function hasChildren(item: MenuItem) { 13 return Array.isArray(item.children) && item.children.length > 0; 14} 15</script>
defineOptions({ name: 'MenuItem' }) 这行别省。<script setup> 里组件名如果没被明确推断出来,递归时模板引用自身那个标签会找不到组件,控制台报一个 Failed to resolve component 警告,子级直接渲染不出来。我第一次写递归组件就栽在这上面,盯着代码看半天,最后才想起名字这回事。Vue 也支持靠文件名推断组件名,但显式写最稳,不吃构建配置的亏。
如果项目还停在 Vue 3.2 或更早,defineOptions 用不了,额外保留一个普通 <script>:
1<script lang="ts"> 2export default { 3 name: 'MenuItem', 4}; 5</script>
递归组件还有个关键点是 key 要稳定,别用数组下标。具体会错乱成什么样,我遇到过:el-sub-menu 的展开/折叠是组件内部状态,用下标做 key 时,权限过滤砍掉前面一个菜单后,后面菜单下标整体前移,Vue 复用了 DOM 但状态对不上,结果就是你点开的是 A 菜单,刷新权限后展开的却是 B。用稳定的 index 做 key 能彻底绕开,反正前面已经保证 index 唯一了。
图标走白名单映射
菜单通常带图标。后端只返回图标名称时,别在模板里到处写判断,维护一个映射就好:
1import { House, Setting } from '@element-plus/icons-vue'; 2 3const iconMap = { 4 home: House, 5 setting: Setting, 6}; 7 8function resolveIcon(name?: string) { 9 return name ? iconMap[name] : null; 10}
模板里统一渲染:
1<el-icon v-if="resolveIcon(item.icon)"> 2 <component :is="resolveIcon(item.icon)" /> 3</el-icon>
图标白名单是明确的,后端也没法随便返回个字符串就影响前端渲染。这里有个体积细节:Element Plus 的图标是一个个组件,想把所有图标都注册进 iconMap 包体积会很可观。我们后台菜单图标其实就十来个,手动维护完全够用,设计稿上没有的图标根本不会被打进包。图标种类特别多,再考虑用 import.meta.glob 动态加载。
另外 resolveIcon 返回 null 时那个 v-if 要兜住,否则 <component :is="null" /> 在某些版本会报警告。折叠菜单时图标尤其要紧——标题被收起来了,用户只能靠图标认菜单,没图标的一级菜单折叠后就是一片空白。
外层只管整体配置
外层组件只负责 el-menu 的整体属性,职责一分开就清楚了:
1<template> 2 <el-menu :default-active="activeMenu" router> 3 <MenuItem 4 v-for="item in menus" 5 :key="item.index" 6 :item="item" 7 /> 8 </el-menu> 9</template>
外层管激活项、路由模式、折叠状态,递归项只管渲染每一项。当前激活项从路由里推导:
1import { computed } from 'vue'; 2import { useRoute } from 'vue-router'; 3 4const route = useRoute(); 5 6const activeMenu = computed(() => { 7 return route.meta.activeMenu || route.path; 8});
有些详情页不在菜单里,却希望高亮列表页菜单。举个真实场景:订单列表 /order/list 在菜单里,点进去的详情 /order/detail/123 不在菜单里。用户在详情页时左侧本该还高亮"订单列表",但 route.path 变成了详情页地址,default-active 匹配不上,高亮就掉了。这时候在详情页路由 meta 里写上 activeMenu 就解决:
1{ 2 path: '/order/detail/:id', 3 component: () => import('@/views/order/Detail.vue'), 4 meta: { activeMenu: '/order/list' }, 5}
我起初想在菜单组件里用正则匹配前缀来兜这类情况,写几条就发现完全没法维护——业务路由各式各样,规则越堆越多。后来全改成 meta 声明式配置,谁加的路由谁负责声明,菜单组件一个字都不用动。
权限过滤放在进组件之前
权限过滤最好在数据进入组件前完成,而不是在递归模板里到处判断:
1function filterMenusByPermission( 2 menus: MenuItem[], 3 permissions: Set<string> 4): MenuItem[] { 5 return menus 6 .filter((item) => !item.permission || permissions.has(item.permission)) 7 .map((item) => ({ 8 ...item, 9 children: item.children 10 ? filterMenusByPermission(item.children, permissions) 11 : undefined, 12 })) 13 .filter((item) => item.path || hasChildren(item)); 14}
最后一行去掉没有路径、也没有可见子菜单的空节点。权限过滤如果不处理空父级,侧边栏很容易出现点不开的菜单。
路由跳转、外链和 index 的约定
el-menu 的 router 模式下 index 通常当路由路径用,但 index 不一定总等于 path。我倾向于明确约定:开了 router,index 用可跳转路径;不开 router,点击事件里按 path 手动跳。别一会儿用 code、一会儿用 path,高亮和跳转逻辑一混乱,后面很难查。
1<el-menu-item :index="item.path || item.index"> 2 <span>{{ item.title }}</span> 3</el-menu-item>
path 为空说明它可能不是可点击项,要在数据层提前处理清楚。还有一个坑是 router 模式下的外链:后台菜单偶尔配个外部地址,比如跳监控平台。router 模式会拿 index 当路由 path 喂给 router.push,外链 https://... 直接就跳挂了。这种我在数据转换时打个 external 标记,渲染时单独用 window.open 跳出去:
1<el-menu-item 2 :index="item.index" 3 @click="item.external && openExternal(item.path)" 4> 5 <span>{{ item.title }}</span> 6</el-menu-item>
折叠和空数据都要兜住
后台布局基本都有侧边栏收起,el-menu 加个 :collapse="isCollapse" 就行。折叠切换时如果正展开着某个子菜单,动画有时会跳。我一般把 unique-opened 打开,同一时刻只展开一个子菜单,既好看也省得用户在深层级里迷路:
1<el-menu 2 :default-active="activeMenu" 3 :collapse="isCollapse" 4 unique-opened 5 router 6>
空数据兜底也别漏。菜单接口慢、或者某个角色压根没配菜单时,侧边栏不能就这么空着:
1<template> 2 <el-menu v-if="menus.length" ...> 3 <MenuItem v-for="item in menus" :key="item.index" :item="item" /> 4 </el-menu> 5 <el-empty v-else description="暂无菜单权限" :image-size="80" /> 6</template>
那些容易被忽略的细节
无限级菜单看着通用,实际项目里还有几处容易被忽略:菜单层级过深时交互体验会变差;菜单数据可能存在循环引用或重复 index。后者尤其阴险,重复 index 会让高亮、展开、跳转集体出问题,所以我在转换数据时加了一道校验,早点把错误抛出来:
1function assertUniqueMenuIndex(menus: MenuItem[], seen = new Set<string>()) { 2 menus.forEach((item) => { 3 if (seen.has(item.index)) { 4 throw new Error(`Duplicate menu index: ${item.index}`); 5 } 6 7 seen.add(item.index); 8 9 if (item.children) { 10 assertUniqueMenuIndex(item.children, seen); 11 } 12 }); 13}
接口异常也要统一兜底。菜单接口失败时不能让整个布局白屏,把错误收敛成统一结构,再决定跳登录、展示无权限还是展示重试:
1async function loadMenus() { 2 try { 3 menus.value = await fetchMenus(); 4 } catch (error) { 5 menuError.value = normalizeApiError(error); 6 menus.value = []; 7 } 8}
菜单是后台系统的入口,它的错误处理要比普通列表页更谨慎。用户没有菜单时,得能区分"没有权限"和"接口加载失败",这两种状态给的提示完全不同。
回到开头那条 60 行的规矩,这次真守住了。组件本身没写多少代码,真正决定它好不好维护的是数据结构转换、权限过滤、路由高亮这几步有没有在进组件之前处理干净。递归模板只管渲染,其余判断都挪到数据层——菜单需求再变,改的也只是那个转换函数,回头不用动组件一个字。