菜单架构设计:递归渲染、权限过滤、多级菜单与面包屑统一|权限与菜单架构篇

围绕菜单架构设计:递归渲染、权限过滤、多级菜单与面包屑统一,从设计思路到工程落地,系统掌握权限与菜单架构篇中的核心实现路径与高频避坑要点。

📑 文章目录

同学们好,我是 Eugene(尤金),一名多年中后台前端开发工程师。

(Eugene 发音 /juːˈdʒiːn/,大家怎么顺口怎么叫就好)

当你能写出规范、可维护的代码后,下一个真正的瓶颈,就是架构

面对大型项目、复杂业务,你是否也会遇到:组件越写越乱、重复开发越来越多;需求一变全链路改动;不知道怎么分层、怎么抽象、怎么设计才能支撑长期迭代;想晋升、想带项目,却缺少架构思维

这一系列《前端组件化与架构实战》,我会继续用大白话 + 真实业务场景 ,不讲玄学、不啃晦涩源码,只教你能落地、能抗复杂项目的架构思路。

帮你从「写页面的开发者」,真正升级为「能做架构、能带项目、能搞定复杂需求的前端工程师」。


一、为什么这篇值得看?

很多项目里的"菜单"一开始都很简单:写几个数组、循环渲染一下就完事。

但只要项目一变复杂,就会出现这些问题:

  • 菜单显示和路由配置是两套数据,改一个漏一个
  • 权限判断散落在各个组件里,后期根本维护不动
  • 多级菜单展开状态混乱,刷新页面全丢
  • 面包屑和菜单不一致,用户点进页面找不到导航路径
  • 新人接手时,不知道"到底以谁为准"

核心结论先说

菜单架构要做到"一个数据源,四处复用"------菜单渲染、权限过滤、路由守卫、面包屑统一从同一份树结构推导

这就是本文要落地的实践规范。


[⬆ 返回目录](#⬆ 返回目录)

二、先定目标:我们到底要解决什么?

我们这篇文章的目标不是炫技,而是"日常开发可落地":

  1. 支持多级菜单递归渲染
  2. 支持权限过滤(角色/按钮/页面权限)
  3. 支持和路由统一(菜单能跳转、路由可守卫)
  4. 支持面包屑自动生成
  5. 支持可维护(新增菜单不改一堆地方)

[⬆ 返回目录](#⬆ 返回目录)

三、架构总览(建议记住这张"脑图")

一个推荐的单向流:

后端/本地配置菜单树(原始)

前端标准化(补齐字段)

按权限过滤

→ 同时产出:

  • 可渲染菜单树
  • 可注册路由列表
  • 面包屑映射表(path -> crumb[])

关键词:树结构 + 纯函数转换 + 单一事实来源(Single Source of Truth)


[⬆ 返回目录](#⬆ 返回目录)

四、菜单节点规范(数据结构先定死)

先定义一个"够用且可扩展"的菜单节点类型(Vue3 + TS 示例):

ts 复制代码
// types/menu.ts
export type PermissionCode = string;

export interface MenuMeta {
  title: string;               // 菜单标题/面包屑名称
  icon?: string;               // 图标名(按你项目UI库来)
  hidden?: boolean;            // 是否在菜单中隐藏(但路由可能可访问)
  alwaysShow?: boolean;        // 只有一个子菜单时是否仍显示父节点
  affix?: boolean;             // 是否固定在标签栏(可选)
  breadcrumb?: boolean;        // 是否出现在面包屑(默认true)
  permission?: PermissionCode[]; // 访问该菜单所需权限(任一满足 or 全满足你可自定义)
}

export interface MenuNode {
  id: string;                  // 唯一id,别用 title 当 key
  path: string;                // 路由路径,建议完整路径或可拼接路径
  name: string;                // 路由name(建议唯一)
  component?: string;          // 组件路径(后端下发时常用字符串)
  redirect?: string;
  meta: MenuMeta;
  children?: MenuNode[];
}

为什么这样设计?

  • id 用于稳定渲染 key(避免展开状态错乱)
  • name 用于路由跳转和 keep-alive 管理
  • meta.permission 统一权限入口(不要散落 if 判断)
  • meta.hidden 可让"详情页不进菜单但可进路由"这种需求自然表达

[⬆ 返回目录](#⬆ 返回目录)

五、核心一:权限过滤(最容易踩坑)

1)先定义权限判断策略

很多人直接写死 includes,后面就崩了。建议明确策略:

  • OR:用户拥有任一权限即可访问
  • AND:用户必须拥有全部权限才访问

下面给出 OR 策略(最常见):

ts 复制代码
// utils/permission.ts
import type { MenuNode } from '@/types/menu';

export function hasPermission(
  userPerms: string[],
  requiredPerms?: string[]
): boolean {
  if (!requiredPerms || requiredPerms.length === 0) return true;
  return requiredPerms.some((p) => userPerms.includes(p));
}

/**
 * 递归过滤菜单树(默认采用"简洁优先"策略)
 * 规则:
 * 1. 当前节点无权限 → 整支剔除(包括其下所有子节点)
 * 2. 当前节点有权限 → 保留,并递归过滤子节点
 * 3. 如需"父无权限但子有权限时父保留并提升子节点",请在该函数基础上扩展
 *    (见下方"常见坑 - 父节点无权限但子节点有权限"的说明)
 */
export function filterMenuTreeByPermission(
  menuTree: MenuNode[],
  userPerms: string[]
): MenuNode[] {
  return menuTree
    .map((node) => {
      const selfVisible = hasPermission(userPerms, node.meta.permission);

      const children = node.children
        ? filterMenuTreeByPermission(node.children, userPerms)
        : [];

      // 当前节点不可见,直接剔除(按项目需求也可"提升子节点")
      if (!selfVisible) return null;

      // 返回新对象,避免污染原始菜单数据
      return {
        ...node,
        children,
      };
    })
    .filter((item): item is MenuNode => item !== null);
}

[⬆ 返回目录](#⬆ 返回目录)

2)常见坑

  • 坑1:直接改原树,导致多角色切换数据串味
  • 坑2:父节点无权限但子节点有权限时如何处理没定义清楚
  • 坑3:权限只做菜单过滤,不做路由守卫,用户可直接输 URL 越权访问

[⬆ 返回目录](#⬆ 返回目录)

六、核心二:递归渲染多级菜单(Vue 组件写法)

假设你用的是 Vue3 + script setup,递归组件示例:

html 复制代码
<!-- components/AppMenuItem.vue -->
<template>
  <template v-if="!node.meta.hidden">
    <li v-if="!hasChildren">
      <RouterLink :to="node.path">{{ node.meta.title }}</RouterLink>
    </li>

    <li v-else>
      <div class="menu-parent">{{ node.meta.title }}</div>
      <ul class="submenu">
        <AppMenuItem
          v-for="child in node.children"
          :key="child.id"
          :node="child"
        />
      </ul>
    </li>
  </template>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import type { MenuNode } from '@/types/menu';

// 递归组件自己引用自己,记得有 name(某些构建配置下需要)
defineOptions({ name: 'AppMenuItem' });

const props = defineProps<{
  node: MenuNode;
}>();

const hasChildren = computed(
  () => !!props.node.children && props.node.children.length > 0
);
</script>

<style scoped>
.menu-parent {
  font-weight: 600;
}
.submenu {
  padding-left: 16px;
}
</style>

菜单容器:

html 复制代码
<!-- components/AppMenu.vue -->
<template>
  <ul class="app-menu">
    <AppMenuItem v-for="node in menuTree" :key="node.id" :node="node" />
  </ul>
</template>

<script setup lang="ts">
import AppMenuItem from './AppMenuItem.vue';
import type { MenuNode } from '@/types/menu';

defineProps<{
  menuTree: MenuNode[];
}>();
</script>

递归渲染规范建议

  • key 必须稳定且唯一,用 id
  • 组件尽量只做"渲染",不要在里面写复杂权限逻辑
  • 展开/选中状态独立管理(Pinia 或 route 驱动)

[⬆ 返回目录](#⬆ 返回目录)

七、核心三:路由与菜单统一(避免两套配置)

推荐做法

  • 菜单树作为主数据(后端下发或本地配置)
  • 通过转换函数生成 RouteRecordRaw[],统一注册到 Vue Router
ts 复制代码
// utils/route-transform.ts
import type { RouteRecordRaw } from 'vue-router';
import type { MenuNode } from '@/types/menu';

// 按需改成你的 import 规则
const views = import.meta.glob('@/views/**/*.vue');

function resolveComponent(componentPath?: string) {
  if (!componentPath) return undefined;
  const key = `/src/views/${componentPath}.vue`;
  return views[key];
}

export function menuToRoutes(menuTree: MenuNode[]): RouteRecordRaw[] {
  const walk = (nodes: MenuNode[]): RouteRecordRaw[] => {
    return nodes.map((node) => ({
      path: node.path,
      name: node.name,
      redirect: node.redirect,
      meta: {
        title: node.meta.title,
        hidden: node.meta.hidden,
        permission: node.meta.permission || [],
        breadcrumb: node.meta.breadcrumb !== false,
      },
      component: resolveComponent(node.component),
      children: node.children ? walk(node.children) : [],
    }));
  };

  return walk(menuTree);
}

关键点:meta 字段保持一致,后续面包屑、权限守卫直接读路由 meta 就行。


[⬆ 返回目录](#⬆ 返回目录)

八、核心四:面包屑统一生成(别手写一堆映射)

两种常用方案:

  1. 基于当前路由 matched 自动生成(最推荐)
  2. 基于菜单树提前构建 path->crumb 映射(适合复杂路径)

先看 matched 方案(简单稳定):

ts 复制代码
// composables/useBreadcrumb.ts
import { computed } from 'vue';
import { useRoute } from 'vue-router';

export function useBreadcrumb() {
  const route = useRoute();

  const breadcrumbs = computed(() => {
    return route.matched
      .filter((r) => r.meta?.breadcrumb !== false)
      .map((r) => ({
        title: (r.meta?.title as string) || String(r.name || ''),
        path: r.path,
      }));
  });

  return { breadcrumbs };
}

页面里直接用:

html 复制代码
<template>
  <nav>
    <span v-for="(item, idx) in breadcrumbs" :key="item.path">
      <RouterLink v-if="idx !== breadcrumbs.length - 1" :to="item.path">
        {{ item.title }}
      </RouterLink>
      <span v-else>{{ item.title }}</span>
      <span v-if="idx !== breadcrumbs.length - 1"> / </span>
    </span>
  </nav>
</template>

<script setup lang="ts">
import { useBreadcrumb } from '@/composables/useBreadcrumb';
const { breadcrumbs } = useBreadcrumb();
</script>

[⬆ 返回目录](#⬆ 返回目录)

九、完整实战案例(可直接改造到项目)

1)原始菜单(模拟后端返回)

ts 复制代码
// mock/menu-data.ts
import type { MenuNode } from '@/types/menu';

export const rawMenuTree: MenuNode[] = [
  {
    id: 'dashboard',
    path: '/dashboard',
    name: 'Dashboard',
    component: 'dashboard/index',
    meta: { title: '仪表盘', icon: 'dashboard' },
  },
  {
    id: 'system',
    path: '/system',
    name: 'System',
    component: 'layout/RouterView',
    meta: { title: '系统管理', icon: 'setting', permission: ['sys:view'] },
    children: [
      {
        id: 'system-user',
        path: '/system/user',
        name: 'SystemUser',
        component: 'system/user/index',
        meta: { title: '用户管理', permission: ['user:view'] },
      },
      {
        id: 'system-role',
        path: '/system/role',
        name: 'SystemRole',
        component: 'system/role/index',
        meta: { title: '角色管理', permission: ['role:view'] },
      },
      {
        id: 'system-user-detail',
        path: '/system/user/detail',
        name: 'SystemUserDetail',
        component: 'system/user/detail',
        meta: { title: '用户详情', hidden: true, breadcrumb: true, permission: ['user:view'] },
      },
    ],
  },
];

[⬆ 返回目录](#⬆ 返回目录)

2)登录后流程(伪代码)

ts 复制代码
// store/permission.ts (Pinia示意)
import { defineStore } from 'pinia';
import { rawMenuTree } from '@/mock/menu-data';
import { filterMenuTreeByPermission } from '@/utils/permission';
import { menuToRoutes } from '@/utils/route-transform';
import router from '@/router';

export const usePermissionStore = defineStore('permission', {
  state: () => ({
    menuTree: [] as any[],
    loaded: false,
  }),
  actions: {
    async generate(userPerms: string[]) {
      const filtered = filterMenuTreeByPermission(rawMenuTree, userPerms);
      const routes = menuToRoutes(filtered);

      routes.forEach((r) => router.addRoute(r));

      this.menuTree = filtered;
      this.loaded = true;
    },
  },
});

[⬆ 返回目录](#⬆ 返回目录)

3)路由守卫(必须有)

ts 复制代码
// router/guard.ts
import router from './index';
import { useUserStore } from '@/store/user';
import { usePermissionStore } from '@/store/permission';

router.beforeEach(async (to, _from, next) => {
  const userStore = useUserStore();
  const permissionStore = usePermissionStore();

  if (!userStore.token && to.path !== '/login') {
    return next('/login');
  }

  if (userStore.token && !permissionStore.loaded) {
    await permissionStore.generate(userStore.permissions);
    // 确保动态路由已生效
    return next({ ...to, replace: true });
  }

  // 可再加一层 to.meta.permission 的兜底判断
  next();
});

[⬆ 返回目录](#⬆ 返回目录)

十、你在项目里应该坚持的"规范清单"

  • 菜单、权限、面包屑都从同一份树数据推导
  • 权限过滤必须是纯函数,不修改原始数据
  • 菜单显示过滤 != 路由访问控制,守卫必须做
  • meta 字段统一命名,不同模块别各写各的
  • 详情页/编辑页优先用 hidden: true + 可访问路由
  • 多级菜单务必使用递归组件,不要手写三层 if-else
  • id/name/path 三者职责分清,避免后期冲突
  • 刷新保留展开状态(可存 localStorage 或跟随路由)

[⬆ 返回目录](#⬆ 返回目录)

十一、常见面试/实战问题(顺手就能讲清)

Q1:为什么要"菜单和路由统一"?

因为维护成本最低。新增一个页面时,只改一处数据,菜单/跳转/面包屑同步生效,不容易漏。

[⬆ 返回目录](#⬆ 返回目录)

Q2:后端下发菜单和前端写死菜单怎么选?

  • 中后台、权限变化频繁:优先后端下发
  • 小项目、固定权限:前端写死更简单
    实际常见是"后端下发 + 前端标准化转换"。

[⬆ 返回目录](#⬆ 返回目录)

Q3:父菜单无权限但子菜单有权限怎么办?

必须产品和后端先定规则。常见有两种:

  1. 父无权限就整支隐藏(简单一致)
  2. 提升有权限子节点到上级(体验更灵活但逻辑复杂)

[⬆ 返回目录](#⬆ 返回目录)

十二、结语:基础不是"懂概念",是"能落地"

菜单架构看起来只是"左侧一栏导航",本质却是前端工程化能力 的缩影:

你是否能把"数据结构、权限模型、路由系统、UI 渲染"统一起来,并长期稳定维护。

如果你准备把基础打牢,建议按这个顺序练:

  1. 先把菜单节点结构定好(含 meta 规范)
  2. 写出稳定的递归渲染组件
  3. 写权限过滤纯函数(覆盖边界条件)
  4. 把路由注册和守卫补齐
  5. 最后接入面包屑和状态持久化

做到这一步,你的"菜单权限系统"基本就能从"能跑"升级为"可维护、可扩展、可交接"。


[⬆ 返回目录](#⬆ 返回目录)

附:一段可直接复用的最小流程口诀

"一份树,三处用:渲染菜单、注册路由、生成面包屑;
两道闸:菜单可见 + 路由可进;
零污染:任何过滤都返回新树。"


[⬆ 返回目录](#⬆ 返回目录)

🔍 系列模块导航

📝 权限与菜单架构

一、《前端权限架构设计:路由/菜单/按钮/数据 四级权限体系|权限与菜单架构篇》

二、《菜单架构设计:递归渲染、权限过滤、多级菜单与面包屑统一|权限与菜单架构篇》

三、《按钮级权限实现:自定义指令 + 权限 Store,统一权限控制|权限与菜单架构篇》

四、《数据级权限实现:行权限/列权限,前端过滤与后端协同|权限与菜单架构篇》

五、《多租户权限架构:租户隔离、权限继承,适配多租户场景|权限与菜单架构篇》

👉 跟着系列慢慢学,把技术功底扎扎实实地打牢~

[⬆ 返回目录](#⬆ 返回目录)

📚 系列总览

前端体系化学习完全体:基础 → 规范 → 架构 → 大厂面试

四套系列、百余篇高质量实战文,从入门到进阶,一站式补齐前端核心能力

每个系列完结后,都会整理成一篇完整导航文并附上直达链接,方便大家按顺序、体系化学习。

全套内容持续更新中,敬请期待~


前端的成长路径很清晰:

会写代码 → 写规范代码 → 做可扩展架构。

每一步,都是职业晋升的关键台阶。

后续我会持续输出组件化、配置驱动、权限架构、工程化、复杂业务实战干货,帮你真正建立架构思维,在工作与面试中更有竞争力。

觉得有用欢迎 点赞 + 收藏 + 关注,不错过每一篇硬核内容。

我是 Eugene,与你一起从业务走向架构,搞定复杂项目,我们下篇干货见~

[⬆ 返回目录](#⬆ 返回目录)

相关推荐
思诺学长1 小时前
微服务与分布式系统
微服务·云原生·架构
Python私教1 小时前
从主题闪烁到 Markdown 阅读体验:RuyiBlog v0.1.1 的前端实现复盘
前端·状态模式
边界条件╝1 小时前
Pinia 深度使用实战
前端·vue.js
英俊潇洒美少年1 小时前
前端 Jest 单元测试零基础实战:模板、提效、避坑、面试题(Vue 项目可用)
前端·vue.js·单元测试
和blue一起变得更好1 小时前
周三:Vue3高级组件特性
前端·javascript·vue.js
happyprince1 小时前
10-Hugging Face Transformers 量化系统深度分析
java·前端·数据库
AskHarries1 小时前
如何使用 OpenClaw Skill
前端
fengxin_rou1 小时前
【JUC第二章下】:锁机制&关键字
架构·事务·cas·juc·volatile
AI周红伟1 小时前
Agent Skills生产级Skills 案例实操-周红伟
前端·chrome·react.js·langchain