函数式编程重构多级菜单:从面条代码到模块化架构实战

发布时间:2026/8/23 1:58:07
函数式编程重构多级菜单:从面条代码到模块化架构实战 1. 项目概述从“面条式代码”到清晰架构的蜕变接手一个后台管理系统最让人头疼的往往不是核心业务逻辑而是那些看似简单、实则混乱的导航菜单。我最近就重构了一个这样的项目它的菜单管理代码最初的状态用行话来说就是典型的“面条式代码”——所有菜单的生成、渲染、权限判断、点击事件都揉在一个几百行甚至上千行的组件或页面文件里。每次产品经理提需求要加一个带二级、三级甚至更多层级的菜单项或者调整一下菜单的展示逻辑我都得在这个庞然大物里小心翼翼地寻找修改点生怕牵一发而动全身。这种开发体验效率低下且充满风险。所以我决定动手做一个彻底的改造目标就是实现“多级菜单的函数模块化”。这不仅仅是为了代码好看更是为了提升项目的可维护性、可测试性和团队协作效率。简单来说就是把生成和管理多级嵌套菜单这一整套逻辑从业务页面中彻底抽离出来封装成一个个职责单一、接口清晰的纯函数模块。无论菜单结构多复杂业务方只需要传入一份格式标准的配置数据就能得到一个完全处理好的菜单树直接用于渲染。这个过程中我深入实践了函数式编程的思想用高阶函数、递归和组合来优雅地解决菜单层级嵌套、权限过滤、状态管理等难题。经过这次重构不仅代码量减少了近40%后续添加新菜单功能的开发时间也缩短了70%以上。接下来我就把这套从实战中总结出来的模块化方案拆开揉碎了讲给你听无论你是前端新手想学习架构思想还是老手在寻找菜单组件的最佳实践相信都能有所收获。2. 核心思路拆解为何选择函数式模块化在动手写代码之前我们先要搞清楚为什么“函数模块化”是解决多级菜单问题的一剂良药。传统的面向对象OOP封装成一个Menu类当然也可以但函数式方案在处理这种数据转换流水线时往往更简洁、更可预测。2.1 传统方案的问题诊断在重构前我们的菜单代码通常混杂着以下问题高耦合菜单的UI渲染、数据格式化、权限校验、路由跳转逻辑全部纠缠在一起。修改UI样式可能会意外影响权限判断。低内聚同一个功能比如过滤无权限菜单的代码可能分散在组件的created、methods等多个生命周期或模块中。状态管理混乱菜单的激活状态、展开状态可能由组件内部的data、Vuex/Redux全局状态以及URL参数共同决定来源不清晰。难以测试因为依赖组件实例、DOM环境或全局状态想要单独测试菜单的权限过滤逻辑几乎不可能必须启动整个应用。数据结构僵化后端返回的菜单数据格式一旦变化前端需要大面积修改硬编码的解析逻辑。2.2 函数式模块化的优势针对以上痛点函数模块化方案的核心思想是将菜单视为纯粹的数据将处理过程视为一系列纯函数的组合。纯函数给定相同的输入菜单配置数据、用户权限列表永远得到相同的输出处理后的菜单树。没有副作用不修改外部状态。这使得逻辑极其可预测也方便单元测试。模块化将整个处理流程拆解为多个独立的小函数每个函数只做一件事。例如normalizeMenuData: 标准化原始数据。filterMenuByAuth: 根据权限过滤菜单项。injectRouteInfo: 为菜单项注入路由配置。markActiveMenu: 根据当前路由标记激活状态。组合与管道我们可以像组装乐高一样把这些小函数组合起来形成一个完整的数据处理管道原始数据 - 标准化 - 权限过滤 - 注入路由 - 标记激活 - 最终菜单树。这种流水线式的处理逻辑清晰每一步的结果都明确。2.3 技术选型考量这个方案不依赖于任何特定的UI框架React/Vue/Angular或状态管理库。它核心是一组JavaScript/TypeScript工具函数。我选择用TypeScript来实现因为它能通过接口Interface和泛型Generic完美地定义菜单数据的结构在编译阶段就捕获大部分数据类型错误这是大型项目维护的利器。对于递归处理树形结构递归函数是最自然的选择但需要注意尾递归优化或使用循环栈来避免深度嵌套可能导致的调用栈溢出问题。3. 数据结构设计与核心函数模块一切的基础在于一份定义良好的数据结构。这是模块之间沟通的“协议”。3.1 定义菜单项接口 (IMenuItem)首先我们用TypeScript定义一个核心接口描述一个菜单项应有的所有属性。// types/menu.ts export interface IMenuItem { id: string | number; // 唯一标识 title: string; // 菜单显示名称 path?: string; // 路由路径对应前端路由 icon?: string; // 图标类名或组件 children?: IMenuItem[]; // 子菜单实现多级嵌套的关键 meta?: { // 元信息用于扩展 requiresAuth?: boolean; // 是否需要权限 roles?: string[]; // 可访问的角色编码数组 keepAlive?: boolean; // 是否缓存组件 [key: string]: any; // 其他自定义元信息 }; // 运行时由处理函数添加的状态 isActive?: boolean; isOpen?: boolean; parentId?: string | number | null; }这个接口的设计有几个关键点children?: IMenuItem[]实现了无限层级的嵌套这是多级菜单的核心。meta字段是一个灵活的“袋子”用于存放权限、角色、路由元信息等避免污染主结构。isActive,isOpen等是运行时状态由我们的处理函数动态添加不属于原始数据。3.2 核心函数模块实现接下来我们实现几个最核心的纯函数模块。3.2.1 数据标准化模块后端返回的数据格式可能五花八门我们需要一个函数将其统一成IMenuItem格式。// utils/menu/normalize.ts import { IMenuItem } from /types/menu; /** * 标准化原始菜单数据 * param rawData 后端返回的原始数据 * param parentId 父级ID用于构建树形关系内部递归使用 * returns 标准化后的 IMenuItem 数组 */ export function normalizeMenuData( rawData: any[], parentId: string | number | null null ): IMenuItem[] { return rawData .filter(item item ! null) // 过滤空项 .map(item { // 这里根据后端字段名做映射例如 backId - id, name - title const normalizedItem: IMenuItem { id: item.id || item.backId, title: item.name || item.title, path: item.url || item.path, icon: item.icon, meta: { requiresAuth: item.requiresAuth ! false, // 默认需要权限 roles: item.roles || [], ...item.meta, // 合并其他元信息 }, parentId, // 记录父节点ID方便后续查找 }; // 递归处理子项 if (item.children Array.isArray(item.children) item.children.length 0) { normalizedItem.children normalizeMenuData(item.children, normalizedItem.id); } return normalizedItem; }); }注意这个函数是递归的。在实际项目中如果后端一次性返回了完整的扁平化列表并带有parentId你可能需要写一个buildMenuTreeFromFlatList的函数使用循环或reduce来构建树性能可能更好。递归更直观但需注意数据深度。3.2.2 权限过滤模块这是后台系统的核心。我们需要根据当前用户的权限角色或权限点列表过滤菜单。// utils/menu/filter.ts import { IMenuItem } from /types/menu; /** * 根据用户权限过滤菜单树 * param menuTree 标准化后的菜单树 * param userRoles 当前用户的角色数组 * returns 过滤后的菜单树 */ export function filterMenuByAuth(menuTree: IMenuItem[], userRoles: string[] []): IMenuItem[] { // 如果用户是超级管理员通常直接返回所有菜单 if (userRoles.includes(admin)) { return menuTree; } return menuTree.reduceIMenuItem[]((filtered, menuItem) { // 深拷贝当前项避免修改原数据 const itemCopy { ...menuItem }; // 检查当前菜单项是否对用户可见 const isItemVisible hasPermissionToAccess(itemCopy, userRoles); if (!isItemVisible) { // 当前项不可见直接跳过 return filtered; } // 处理子菜单 if (itemCopy.children itemCopy.children.length 0) { itemCopy.children filterMenuByAuth(itemCopy.children, userRoles); // 重要如果过滤后子菜单为空且当前菜单项本身没有独立路径只是一个分组目录 // 那么该项也应该被过滤掉。这里我们保留有子项或自身有路径的项。 if (itemCopy.children.length 0 !itemCopy.path) { return filtered; } } filtered.push(itemCopy); return filtered; }, []); } /** * 判断用户是否有权限访问某个菜单项 */ function hasPermissionToAccess(menuItem: IMenuItem, userRoles: string[]): boolean { const { meta } menuItem; // 如果菜单不需要认证则对所有用户可见 if (meta?.requiresAuth false) { return true; } // 如果菜单未配置角色限制则默认对所有认证用户可见这里假设需要认证 if (!meta?.roles || meta.roles.length 0) { return true; // 或者 return userRoles.length 0; 根据业务定 } // 检查用户角色是否与菜单要求的角色有交集 return meta.roles.some(role userRoles.includes(role)); }实操心得权限过滤的递归逻辑里有一个很容易踩的坑一个父级菜单项本身可能没有path仅作为分组其可见性完全依赖于子项。过滤后如果子项全没了这个父项也应该被移除。上面的代码通过if (itemCopy.children.length 0 !itemCopy.path)这个判断来处理这种情况避免了出现空的、不可点击的菜单分组。3.2.3 状态标记模块菜单需要知道当前哪个是激活的高亮以及哪些层级是展开的。// utils/menu/markStatus.ts import { IMenuItem } from /types/menu; /** * 根据当前路由路径标记菜单的激活和展开状态 * param menuTree 过滤后的菜单树 * param currentPath 当前路由的完整路径如 /user/list * returns 标记了状态的菜单树 */ export function markActiveMenu(menuTree: IMenuItem[], currentPath: string): IMenuItem[] { // 用于存储找到的激活项ID路径 let activeIdPath: (string | number)[] []; // 第一次遍历查找激活项并记录其从根到叶的ID路径 function findActivePath(items: IMenuItem[], parentPath: (string | number)[] []): boolean { for (const item of items) { const currentIdPath [...parentPath, item.id]; // 匹配逻辑路径完全相等或者当前路由以菜单路径开头对于嵌套路由 if (item.path (currentPath item.path || currentPath.startsWith(item.path /))) { activeIdPath currentIdPath; return true; // 找到即终止 } if (item.children item.children.length 0) { if (findActivePath(item.children, currentIdPath)) { return true; } } } return false; } findActivePath(menuTree); // 第二次遍历根据 activeIdPath 设置 isActive 和 isOpen function setStatus(items: IMenuItem[], depth 0): IMenuItem[] { return items.map(item { const itemCopy { ...item }; const isInActivePath activeIdPath.includes(item.id); const isLeafActive activeIdPath[activeIdPath.length - 1] item.id; // 激活状态当前项是激活路径的最后一个节点 itemCopy.isActive isLeafActive; // 展开状态当前项在激活路径上且不是叶子节点即有子项且子项在路径上 itemCopy.isOpen isInActivePath !isLeafActive !!itemCopy.children?.length; if (itemCopy.children itemCopy.children.length 0) { itemCopy.children setStatus(itemCopy.children, depth 1); } return itemCopy; }); } return setStatus(menuTree); }这个函数遍历了两次树。第一次是为了找到当前路由对应的那个最深层的菜单项并记录下从根到它的所有ID。第二次遍历则是利用这个ID路径给路径上的节点设置isActive只有最终叶子节点为true和isOpen路径上非叶子节点为true状态。分离查找和设置逻辑使函数更清晰。4. 函数组合与最终使用流程有了这些独立的模块我们需要把它们组合起来形成一个完整的处理流程。这里我推荐使用“函数管道”的概念。4.1 创建组合函数我们可以创建一个高阶函数或者简单地按顺序调用。// utils/menu/index.ts import { normalizeMenuData } from ./normalize; import { filterMenuByAuth } from ./filter; import { markActiveMenu } from ./markStatus; import { IMenuItem } from /types/menu; export interface MenuOptions { rawData: any[]; userRoles: string[]; currentPath: string; } /** * 生成最终菜单树的主函数 */ export function generateMenuTree(options: MenuOptions): IMenuItem[] { const { rawData, userRoles, currentPath } options; // 清晰的管道操作数据流经三个纯函数 const normalizedTree normalizeMenuData(rawData); const filteredTree filterMenuByAuth(normalizedTree, userRoles); const finalTree markActiveMenu(filteredTree, currentPath); return finalTree; } // 另一种更函数式的写法使用 lodash 的 flow 或自己实现 pipe // import { flow } from lodash; // export const generateMenuTree flow([normalizeMenuData, filterMenuByAuth, markActiveMenu]);4.2 在Vue/React组件中使用现在在UI组件中使用变得极其简单和纯粹。Vue 3 (Composition API) 示例template el-menu :default-activeactiveMenuId selecthandleSelect menu-item v-foritem in menuTree :keyitem.id :itemitem / /el-menu /template script setup langts import { computed, ref, watch } from vue; import { useRoute } from vue-router; import { generateMenuTree } from /utils/menu; import MenuItem from ./MenuItem.vue; // 一个递归渲染的自定义组件 const route useRoute(); const userStore useUserStore(); // 假设有一个存储用户信息的store // 假设从API获取的原始数据 const rawMenuData ref([]); // 使用计算属性这是关键。 // 当 rawMenuData、用户角色、当前路由变化时菜单树会自动、高效地重新计算。 const menuTree computed(() { if (rawMenuData.value.length 0) return []; return generateMenuTree({ rawData: rawMenuData.value, userRoles: userStore.roles, currentPath: route.path, }); }); // 当前激活菜单的ID可以从标记好的树中查找或直接使用route.path const activeMenuId computed(() { // 一个辅助函数从 menuTree 中查找 isActive 为 true 的项 function findActiveId(items) { for (const item of items) { if (item.isActive) return item.id; if (item.children) { const id findActiveId(item.children); if (id) return id; } } return ; } return findActiveId(menuTree.value); }); const handleSelect (key: string) { // 根据key找到对应菜单项的path进行路由跳转 }; /scriptReact Hooks 示例// MenuContainer.tsx import React, { useMemo } from react; import { useLocation } from react-router-dom; import { useSelector } from react-redux; import { generateMenuTree } from /utils/menu; import MenuItem from ./MenuItem; const MenuContainer: React.FC () { const location useLocation(); const userRoles useSelector((state) state.user.roles); const rawMenuData useSelector((state) state.app.menuData); // 从Redux获取 // 使用 useMemo 缓存计算结果依赖项变化时才重新计算 const menuTree useMemo(() { if (!rawMenuData || rawMenuData.length 0) return []; return generateMenuTree({ rawData: rawMenuData, userRoles, currentPath: location.pathname, }); }, [rawMenuData, userRoles, location.pathname]); const activeMenuId useMemo(() { // ... 查找逻辑同Vue示例 }, [menuTree]); return ( Menu selectedKeys{[activeMenuId]} {menuTree.map(item ( MenuItem key{item.id} item{item} / ))} /Menu ); };核心优势通过computed或useMemo我们将菜单树的生成变成了一个响应式的、声明式的过程。UI只关心最终处理好的menuTree数据。所有复杂的逻辑都被隔离在纯函数中组件变得非常轻薄且易于理解。5. 递归渲染组件与性能优化处理好了数据渲染一个无限层级的菜单树就需要一个递归组件。5.1 实现递归菜单项组件这是一个MenuItem.vue的简化示例它自己调用自己来渲染子菜单。template !-- 如果有子菜单渲染为可展开的 SubMenu -- el-sub-menu v-ifhasChildren :indexitem.id :popper-classmulti-level-menu template #title i v-ifitem.icon :classitem.icon/i span{{ item.title }}/span /template !-- 递归调用自身 -- menu-item v-forchild in item.children :keychild.id :itemchild / /el-sub-menu !-- 如果没有子菜单渲染为普通的 MenuItem -- el-menu-item v-else :indexitem.id :routeitem.path i v-ifitem.icon :classitem.icon/i template #title{{ item.title }}/template /el-menu-item /template script setup langts import { computed } from vue; import { IMenuItem } from /types/menu; const props defineProps{ item: IMenuItem; }(); const hasChildren computed(() { return props.item.children props.item.children.length 0; }); /script5.2 关键性能优化策略当菜单项很多比如几百个时递归渲染和状态计算可能成为性能瓶颈。以下是我实践中总结的优化点记忆化MemoizationgenerateMenuTree函数本身是纯函数其输入是rawData,userRoles,currentPath。一定要在组件中使用computed或useMemo进行缓存。只有这三个依赖项变化时才重新执行昂贵的树形数据处理。扁平化数据结构查询在markActiveMenu或查找activeMenuId时如果菜单树非常大递归遍历可能较慢。可以考虑在normalizeMenuData阶段额外生成一个Mapid, IMenuItem的扁平化索引。这样根据路径查找激活项就可以在 O(1) 或 O(n)扁平列表遍历内完成远快于树形递归。// 在 normalize 阶段或之后构建索引 const menuIndex new Mapstring | number, IMenuItem(); function buildIndex(items: IMenuItem[]) { items.forEach(item { menuIndex.set(item.id, item); if (item.children) { buildIndex(item.children); } }); }虚拟滚动如果菜单项真的多到需要滚动超过1000项考虑只渲染可视区域内的菜单项。不过对于后台管理系统菜单这个场景较少。按需加载子菜单对于超大型菜单树可以初始只加载第一层或前两层当用户点击展开时再动态加载该节点下的子菜单数据。这需要稍微修改数据流将children设为可选加载并给菜单项添加一个loading状态。6. 常见问题与排查实录在实现和推广这套方案的过程中我遇到了不少典型问题这里记录下排查思路和解决方案。6.1 菜单闪烁或重复渲染现象切换路由时菜单会短暂消失再出现或者控制台看到menuTree被重复计算多次。排查检查generateMenuTree的依赖项。确保传入的rawData,userRoles,currentPath是稳定的引用除非它们真的变了。特别是userRoles如果每次从store/getter中获取的都是一个新数组即使内容没变也会触发重新计算。在Vuex/Pinia或Redux中确保返回的是稳定引用。在Vue中检查组件是否被不必要的v-if或key变化导致重新挂载。在React中检查父组件是否频繁渲染导致useMemo的依赖项数组中的对象/数组每次都是新的。使用useSelector时选择精确的字段。解决规范化数据来源。例如将userRoles在store中定义为ref([])或useState([])只在登录/登出时更新。对于rawData在获取API数据后进行深比较只有真正变化时才更新状态。6.2 权限过滤后空的父级菜单项仍然显示现象一个名为“系统设置”的父菜单其下所有子菜单对当前用户都不可见但“系统设置”这个空项还显示在侧边栏。原因这就是前面提到的在filterMenuByAuth函数中只过滤了子项但没有处理过滤后子项为空且父项自身无路径的情况。解决确保过滤函数的逻辑包含了对“空文件夹”的清理。上面filterMenuByAuth函数中的if (itemCopy.children.length 0 !itemCopy.path)判断就是用于解决此问题。6.3 激活状态标记不准确现象当前路由是/user/detail/123但高亮的菜单却是/user而不是更匹配的/user/detail。排查检查markActiveMenu函数中的路径匹配逻辑。我使用的currentPath.startsWith(item.path /)是一种宽松的匹配适用于嵌套路由。确保你的路由设计与此逻辑匹配。检查currentPath传入的值是否正确。在Vue Router中route.path是完整的路径。在React Router中location.pathname同理。检查菜单数据中的path字段是否定义正确是否与路由配置中的path一致。解决你可能需要更精确的匹配策略。例如定义一个路由-菜单映射表或者为菜单项增加一个exact的 meta 字段用于区分是否需要精确匹配。然后修改markActiveMenu中的判断条件const isExactMatch currentPath item.path; const isNestedMatch item.meta?.exact ! true item.path currentPath.startsWith(item.path /); if (isExactMatch || isNestedMatch) { // 标记为激活路径 }6.4 递归渲染导致的最大堆栈调用溢出现象菜单层级极深比如超过1000层时浏览器报错“Maximum call stack size exceeded”。原因JavaScript引擎的调用栈有大小限制递归层级过深会导致溢出。解决业务层面审查是否真的需要如此深的菜单层级通常超过4层用户体验就很差了应考虑扁平化设计。技术层面将递归算法改为迭代算法。例如使用栈Stack或队列Queue来模拟递归过程。对于树的遍历有明确的前序、中序、后序迭代写法。虽然代码复杂度增加但解决了栈溢出问题。对于菜单处理数据量通常不至于触发此问题但了解这个解决方案很重要。7. 扩展与进阶思考这套函数模块化的菜单系统其价值远不止于解决菜单渲染本身。它提供了一种清晰的数据处理和状态管理范式可以扩展到其他类似场景。1. 动态路由集成在权限管理严格的后台菜单和路由通常是绑定的。我们可以很容易地扩展generateMenuTree函数使其不仅返回菜单树还能返回一个根据权限过滤后的、可用于vue-router.addRoutes或 React Router 配置的动态路由数组。只需在filterMenuByAuth的同时将过滤后的、带有组件信息的菜单项转换成路由配置即可。2. 菜单数据持久化与同步用户可能自定义菜单的折叠/展开状态、顺序甚至可见性。我们可以将处理后的menuTree与用户的个性化配置进行合并。设计一个applyUserPreference(menuTree, userPref)的纯函数它接收标准菜单树和用户配置输出个性化后的菜单树。用户配置可以保存在localStorage或后端。3. 单元测试变得极其简单由于所有核心函数都是纯函数编写单元测试轻而易举。你只需要准备输入数据和期望的输出数据。// menu.filter.test.ts import { filterMenuByAuth } from ./filter; import { normalizeMenuData } from ./normalize; describe(filterMenuByAuth, () { const mockData normalizeMenuData([...]); // 模拟数据 it(should filter out items without permission, () { const userRoles [editor]; const result filterMenuByAuth(mockData, userRoles); // 断言结果中不包含需要admin角色的菜单项 expect(result.find(item item.title Admin Panel)).toBeUndefined(); }); });4. 与TypeScript的深度结合使用TypeScript我们可以定义出非常精确的类型。例如可以定义一个泛型TreeT或者定义不同阶段的菜单树类型RawMenuItem,NormalizedMenuItem,FilteredMenuItem通过类型转换来明确数据流经每个函数后的形态变化让代码更加健壮。回过头看将多级菜单函数模块化本质上是在践行“关注点分离”和“单向数据流”的经典架构思想。它强迫你将数据转换、业务逻辑与UI渲染解耦。最初可能会觉得多写了一些函数有点“过度设计”但一旦项目开始迭代新需求到来或者你需要修复一个复杂的权限Bug时这种架构的优势就会淋漓尽致地体现出来——你可以像在实验室里做实验一样单独测试和调试每一个小函数而不用担心它们会意外地触发某个组件的渲染或修改全局状态。这种可控性和可维护性对于长期维护的中大型项目来说是无价的。