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

📑 文章目录
- 一、为什么这篇值得看?
- 二、先定目标:我们到底要解决什么?
- 三、架构总览(建议记住这张"脑图")
- 四、菜单节点规范(数据结构先定死)
- 五、核心一:权限过滤(最容易踩坑)
- [六、核心二:递归渲染多级菜单(Vue 组件写法)](#六、核心二:递归渲染多级菜单(Vue 组件写法))
- 七、核心三:路由与菜单统一(避免两套配置)
- 八、核心四:面包屑统一生成(别手写一堆映射)
- 九、完整实战案例(可直接改造到项目)
- 十、你在项目里应该坚持的"规范清单"
- 十一、常见面试/实战问题(顺手就能讲清)
- 十二、结语:基础不是"懂概念",是"能落地"
- 附:一段可直接复用的最小流程口诀
- [🔍 系列模块导航](#🔍 系列模块导航)
- [📝 权限与菜单架构](#📝 权限与菜单架构)
- [📚 系列总览](#📚 系列总览)
同学们好,我是 Eugene(尤金),一名多年中后台前端开发工程师。
(Eugene 发音 /juːˈdʒiːn/,大家怎么顺口怎么叫就好)
当你能写出规范、可维护的代码后,下一个真正的瓶颈,就是架构。
面对大型项目、复杂业务,你是否也会遇到:组件越写越乱、重复开发越来越多;需求一变全链路改动;不知道怎么分层、怎么抽象、怎么设计才能支撑长期迭代;想晋升、想带项目,却缺少架构思维。
这一系列《前端组件化与架构实战》,我会继续用大白话 + 真实业务场景 ,不讲玄学、不啃晦涩源码,只教你能落地、能抗复杂项目的架构思路。
帮你从「写页面的开发者」,真正升级为「能做架构、能带项目、能搞定复杂需求的前端工程师」。
一、为什么这篇值得看?
很多项目里的"菜单"一开始都很简单:写几个数组、循环渲染一下就完事。
但只要项目一变复杂,就会出现这些问题:
- 菜单显示和路由配置是两套数据,改一个漏一个
- 权限判断散落在各个组件里,后期根本维护不动
- 多级菜单展开状态混乱,刷新页面全丢
- 面包屑和菜单不一致,用户点进页面找不到导航路径
- 新人接手时,不知道"到底以谁为准"
核心结论先说 :
菜单架构要做到"一个数据源,四处复用"------菜单渲染、权限过滤、路由守卫、面包屑统一从同一份树结构推导 。
这就是本文要落地的实践规范。
[⬆ 返回目录](#⬆ 返回目录)
二、先定目标:我们到底要解决什么?
我们这篇文章的目标不是炫技,而是"日常开发可落地":
- 支持多级菜单递归渲染
- 支持权限过滤(角色/按钮/页面权限)
- 支持和路由统一(菜单能跳转、路由可守卫)
- 支持面包屑自动生成
- 支持可维护(新增菜单不改一堆地方)
[⬆ 返回目录](#⬆ 返回目录)
三、架构总览(建议记住这张"脑图")
一个推荐的单向流:
后端/本地配置菜单树(原始)
→ 前端标准化(补齐字段)
→ 按权限过滤
→ 同时产出:
- 可渲染菜单树
- 可注册路由列表
- 面包屑映射表(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 就行。
[⬆ 返回目录](#⬆ 返回目录)
八、核心四:面包屑统一生成(别手写一堆映射)
两种常用方案:
- 基于当前路由 matched 自动生成(最推荐)
- 基于菜单树提前构建 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:父菜单无权限但子菜单有权限怎么办?
必须产品和后端先定规则。常见有两种:
- 父无权限就整支隐藏(简单一致)
- 提升有权限子节点到上级(体验更灵活但逻辑复杂)
[⬆ 返回目录](#⬆ 返回目录)
十二、结语:基础不是"懂概念",是"能落地"
菜单架构看起来只是"左侧一栏导航",本质却是前端工程化能力 的缩影:
你是否能把"数据结构、权限模型、路由系统、UI 渲染"统一起来,并长期稳定维护。
如果你准备把基础打牢,建议按这个顺序练:
- 先把菜单节点结构定好(含 meta 规范)
- 写出稳定的递归渲染组件
- 写权限过滤纯函数(覆盖边界条件)
- 把路由注册和守卫补齐
- 最后接入面包屑和状态持久化
做到这一步,你的"菜单权限系统"基本就能从"能跑"升级为"可维护、可扩展、可交接"。
[⬆ 返回目录](#⬆ 返回目录)
附:一段可直接复用的最小流程口诀
"一份树,三处用:渲染菜单、注册路由、生成面包屑;
两道闸:菜单可见 + 路由可进;
零污染:任何过滤都返回新树。"
[⬆ 返回目录](#⬆ 返回目录)
🔍 系列模块导航
📝 权限与菜单架构
一、《前端权限架构设计:路由/菜单/按钮/数据 四级权限体系|权限与菜单架构篇》
二、《菜单架构设计:递归渲染、权限过滤、多级菜单与面包屑统一|权限与菜单架构篇》
三、《按钮级权限实现:自定义指令 + 权限 Store,统一权限控制|权限与菜单架构篇》
四、《数据级权限实现:行权限/列权限,前端过滤与后端协同|权限与菜单架构篇》
五、《多租户权限架构:租户隔离、权限继承,适配多租户场景|权限与菜单架构篇》
👉 跟着系列慢慢学,把技术功底扎扎实实地打牢~
[⬆ 返回目录](#⬆ 返回目录)
📚 系列总览
前端体系化学习完全体:基础 → 规范 → 架构 → 大厂面试
四套系列、百余篇高质量实战文,从入门到进阶,一站式补齐前端核心能力
- 前端基础实战系列 : 《前端基础实战:JS/TS与Vue体系化扫盲(47 篇完整目录 + 避坑)》
- 前端规范实战系列 : 《JS/TS/Vue 前端规范实战:从写对到写优,搞定中后台规范落地,打造可维护代码(40 篇全目录)》
- 前端架构实战系列:聚焦工程化、性能优化、可维护架构、中后台体系设计(持续更新中)
- 前端大厂面试系列:覆盖高频考点、手写题、项目深挖、简历与面试技巧(规划中)
每个系列完结后,都会整理成一篇完整导航文并附上直达链接,方便大家按顺序、体系化学习。
全套内容持续更新中,敬请期待~
前端的成长路径很清晰:
会写代码 → 写规范代码 → 做可扩展架构。
每一步,都是职业晋升的关键台阶。
后续我会持续输出组件化、配置驱动、权限架构、工程化、复杂业务实战干货,帮你真正建立架构思维,在工作与面试中更有竞争力。
觉得有用欢迎 点赞 + 收藏 + 关注,不错过每一篇硬核内容。
我是 Eugene,与你一起从业务走向架构,搞定复杂项目,我们下篇干货见~
[⬆ 返回目录](#⬆ 返回目录)