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}

组件绝不直接依赖后端原始字段。这层缓冲是我吃过亏才认真做的:早期图省事在组件里直接用了后端的 nameroutePath,有次后端把 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-menurouter 模式下 index 通常当路由路径用,但 index 不一定总等于 path。我倾向于明确约定:开了 routerindex 用可跳转路径;不开 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 行的规矩,这次真守住了。组件本身没写多少代码,真正决定它好不好维护的是数据结构转换、权限过滤、路由高亮这几步有没有在进组件之前处理干净。递归模板只管渲染,其余判断都挪到数据层——菜单需求再变,改的也只是那个转换函数,回头不用动组件一个字。