尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

函数模块化重构多级菜单:从意大利面条代码到清晰架构的实践

函数模块化重构多级菜单:从意大利面条代码到清晰架构的实践 1. 项目概述从“面条式代码”到清晰架构的蜕变在任何一个有一定复杂度的后台管理系统、电商平台或者内容管理工具里你大概率都见过“多级菜单”这个东西。它可能长成侧边栏导航也可能是后台的权限配置树或者是商品分类的级联选择器。乍一看功能简单明了点击父级展开子级再点再展开如此而已。但如果你真的动手去实现尤其是当菜单层级不确定、数据动态变化、还需要与各种业务逻辑如权限校验、状态管理耦合时代码很容易就变成一锅“意大利面条”——逻辑缠绕牵一发而动全身。“2021-12-16 多级菜单 函数模块化”这个标题记录的正是我在一个实际项目中对这类问题进行系统性重构和优化的实践。它不是一个新技术的炫技而是一次针对“历史债务”的深度清理和架构升级。核心目标非常明确将原本散落在各处、高度耦合的菜单处理逻辑通过函数模块化的思想拆解成职责单一、可复用、易测试的独立单元。最终我们得到的不仅是一个功能稳定的多级菜单组件更是一套清晰的数据处理流程和可维护的代码架构。这次重构的价值远不止于让菜单“能工作”。它解决了几个长期痛点首先是可维护性新同事接手后能快速理解菜单数据的流转路径而不用在数千行代码里“捉迷藏”其次是可扩展性当业务方提出“需要在第三级菜单上增加一个角标”或“根据用户角色动态隐藏某个分支”的需求时我们不再需要修改核心逻辑只需在相应的模块“插拔”功能即可最后是可测试性每个独立的函数都可以进行单元测试确保了基础功能的稳定性。无论你是前端工程师在处理Vue/React的组件树还是后端开发在构建权限树接口抑或是全栈开发者面临类似的数据递归处理场景这套模块化的设计思路都具有普适的参考价值。接下来我将完整拆解这次重构的核心思路、技术细节、实操步骤以及那些只有踩过坑才知道的宝贵经验。2. 核心痛点与模块化设计思路拆解在重构之前我们的菜单代码状态堪称“经典反面教材”。所有逻辑——数据获取、格式化、权限过滤、状态生成、HTML渲染——全部堆砌在一个巨大的、超过500行的函数或Vue组件里。这个“巨无霸”函数接收一堆参数内部充满了if-else嵌套和for循环的混编用来处理不同业务线的特殊逻辑。当需要增加一个菜单图标类型时我们需要在这个函数里找到一个“合适”的位置插入几行代码并祈祷不会影响到其他地方的逻辑。2.1 原始架构的典型问题单一职责原则被严重破坏一个函数/组件做了太多事情违反了设计模式中最基本的原则。这导致代码难以阅读、理解和修改。高度耦合菜单的UI渲染逻辑与后端数据格式强绑定。一旦后端接口数据结构微调前端就需要在渲染逻辑里“打补丁”风险极高。难以复用项目中其实有多处需要类似的树形结构展示如部门选择器、城市选择器但因为逻辑被写死在了菜单组件里无法直接复用导致重复开发。测试困难由于逻辑混杂为这个“巨无霸”编写单元测试几乎是不可能的任务只能依赖粗粒度的端到端测试bug隐藏极深。2.2 模块化设计的核心思想我们的重构目标不是重写一个更漂亮的UI而是重构数据处理流程。我们将整个菜单从数据到视图的过程视为一条清晰的流水线。原始数据从入口进入依次经过多个“加工站”即模块化函数每个加工站只完成一个特定的、明确的职责最终输出视图层可以直接消费的、结构化的数据。这个流水线可以抽象为以下几个核心阶段数据获取与标准化从API获取原始数据并转换成内部统一的节点格式。数据树构建将扁平的节点列表递归构建成嵌套的树形结构。业务逻辑处理对树形数据进行“加工”如根据权限过滤节点、注入状态信息、计算路径等。视图模型生成将处理后的业务数据树转换为UI组件所需的特定格式例如添加isOpen,isActive等状态字段。通过这样的拆分每个阶段都是一个独立的纯函数或接近纯函数的模块。它们有明确的输入和输出不依赖外部副作用因此极易测试和复用。2.3 技术选型与考量在实现层面我们主要基于ES6的JavaScript环境。选择纯函数和模块化的方式而非直接使用一个状态管理库如Vuex或Redux来管理菜单状态主要基于以下考量复杂度匹配我们的菜单状态虽然是应用级状态但其变化逻辑相对自包含与系统中其他状态的关联度不高。引入一个完整的状态管理库会带来额外的概念复杂性和样板代码。更佳的封装性将菜单的数据处理逻辑封装成一系列函数对外只暴露一个清晰的API如generateMenuTree(rawData, userPermissions)使用起来更简单直观也便于进行Tree Shaking优化。框架无关性这套函数模块可以在Vue、React甚至原生JS项目中使用复用成本极低。如果未来技术栈迁移这部分核心逻辑可以几乎无缝移植。注意这并不是说状态管理库不好。如果你的应用非常庞大菜单状态需要与数十个其他模块进行复杂交互那么使用Pinia、Zustand等现代状态管理工具可能是更优解。我们的选择是基于当前项目规模和复杂度的权衡。3. 核心模块解析与函数设计接下来我们深入每个核心模块看看它们具体如何设计和实现。我会用伪代码和关键逻辑来解释你可以很容易地将其适配到自己的技术栈中。3.1 数据标准化模块这是流水线的第一站。后端API返回的数据格式可能五花八门字段名也不统一比如有的叫id有的叫key有的叫children有的叫subMenu。这个模块的职责就是将各种格式的原始数据统一转换成我们内部约定的标准节点对象。// utils/menuDataNormalizer.js /** * 将原始菜单节点数据标准化 * param {Array|Object} rawNodes - 后端返回的原始菜单数据 * param {Object} options - 配置项如字段映射 * returns {Array} 标准化后的节点数组 */ export function normalizeMenuNodes(rawNodes, options {}) { const { idField id, parentIdField parentId, nameField name, childrenField children, // ... 其他字段映射 } options; if (!Array.isArray(rawNodes)) { rawNodes [rawNodes]; } return rawNodes.map(node ({ // 核心字段标准化 id: node[idField], parentId: node[parentIdField] || null, // 根节点的parentId为null name: node[nameField], path: node.path || , // 路由路径 icon: node.icon || , order: node.order || 0, // 用于排序 meta: { ...node.meta }, // 保留其他元数据 // 注意这里先不处理children构建树时会专门处理 _raw: node // 保留原始数据以备不时之需调试或特殊业务逻辑 })); }设计要点灵活性通过options参数支持字段映射可以适配不同的后端接口而无需修改核心逻辑。数据完整性使用_raw字段保留原始数据。这是一个很有用的技巧当某个特殊业务逻辑需要访问原始字段时可以直接从这里获取避免了标准化过程的信息丢失。纯函数输入输出确定没有副作用便于测试。3.2 树形结构构建模块这是核心中的核心。我们通常从后端拿到的是一个带有id和parentId的扁平数组。这个模块的职责是将其构建成嵌套的树形结构。这里有两种经典算法递归法和哈希表法。递归法直观但性能在深度很大时可能不佳。哈希表法一次遍历效率更高是我们采用的主要方案。// utils/treeBuilder.js /** * 将扁平列表构建成嵌套树使用哈希表O(n)复杂度 * param {Array} flatNodes - 标准化后的节点数组 * returns {Array} 嵌套的树形结构 */ export function buildTreeFromFlatList(flatNodes) { // 1. 创建哈希映射id - node并为每个node初始化children数组 const nodeMap new Map(); const tree []; flatNodes.forEach(node { node.children []; // 初始化children nodeMap.set(node.id, node); }); // 2. 遍历每个节点将其放入父节点的children中 flatNodes.forEach(node { const parentId node.parentId; if (parentId null || parentId undefined || parentId ) { // 没有父节点是根节点 tree.push(node); } else { const parentNode nodeMap.get(parentId); if (parentNode) { parentNode.children.push(node); } else { // 处理异常数据节点的parentId指向了不存在的节点 // 通常选择将其作为根节点或记录错误日志 console.warn(Node ${node.id} has non-existent parentId: ${parentId}. Treated as root.); tree.push(node); } } }); // 3. 对每个节点的children进行排序如果需要 tree.forEach(root sortChildrenRecursively(root)); return tree; } /** * 递归地对树节点的子节点进行排序 */ function sortChildrenRecursively(node) { if (node.children node.children.length 0) { node.children.sort((a, b) a.order - b.order); node.children.forEach(child sortChildrenRecursively(child)); } }实操心得性能哈希表法的时间复杂度是O(n)远优于递归法的O(n^2)最坏情况尤其适合成百上千的菜单节点。容错处理代码中处理了parentId指向不存在的节点的情况。在实际项目中数据可能不总是完美的这种防御性编程至关重要。我们选择将其提升为根节点并打上警告日志既保证了功能不崩溃也便于后期排查数据问题。排序时机在树构建完成后再进行递归排序比在插入每个子节点时排序更清晰、高效。3.3 业务逻辑处理模块树形结构构建好后还是“纯净”的数据。我们需要根据业务规则对其进行处理。最常见的业务逻辑就是基于权限的菜单过滤。这个模块应该是一个“过滤器”接收一棵树和用户权限返回一棵过滤后的新树注意不修改原树。// utils/menuPermissionFilter.js /** * 根据权限过滤菜单树 * param {Array} menuTree - 原始的菜单树 * param {Array} userPermissions - 用户拥有的权限标识数组 * returns {Array} 过滤后的菜单树 */ export function filterMenuByPermission(menuTree, userPermissions) { // 使用递归进行深度过滤 function filterNode(node) { // 如果当前节点需要权限校验且用户没有该权限则过滤掉该节点及其整个子树 if (node.meta?.requiresAuth !userPermissions.includes(node.meta.requiresAuth)) { return null; } // 处理子节点 if (node.children node.children.length 0) { const filteredChildren node.children .map(child filterNode(child)) .filter(child child ! null); // 过滤掉返回null的子节点 // 如果过滤后子节点为空且当前节点本身没有独立路径或功能即只是一个分组节点 // 那么当前节点也应该被过滤掉 if (filteredChildren.length 0 !node.path) { return null; } node.children filteredChildren; } return node; } // 对整棵树应用过滤 return menuTree.map(root filterNode(root)).filter(root root ! null); }注意事项深拷贝与纯函数上面的函数直接修改了传入的node.children。在实际生产中为了确保纯函数特性输入不变输出一定不变我们通常会在函数入口处对menuTree进行一次深拷贝例如使用JSON.parse(JSON.stringify(...))或lodash.cloneDeep然后在副本上操作。这里为了代码清晰省略了这一步但你必须意识到其重要性。“空文件夹”问题代码中处理了“分组节点”被掏空的情况。如果一个菜单项如“系统设置”本身没有可点击的路由!node.path仅仅是一个容器当其所有子项都被权限过滤掉后它自身也应该被隐藏。这个逻辑需要根据具体的UI设计来调整。3.4 视图模型生成模块经过过滤的树数据已经包含了业务逻辑但可能还缺少UI渲染所需的一些状态字段。例如我们需要知道当前哪个菜单项是激活的isActive或者哪些分支是默认展开的isOpen。这个模块负责注入这些视图状态。// utils/menuViewModelGenerator.js /** * 为菜单树生成视图模型注入UI状态 * param {Array} menuTree - 处理后的业务菜单树 * param {String} currentPath - 当前活动的路由路径 * returns {Array} 注入状态后的视图模型树 */ export function generateMenuViewModel(menuTree, currentPath ) { function injectViewState(node, parentPath ) { const nodeFullPath parentPath ? ${parentPath}/${node.path} : node.path; // 判断是否激活当前路径以该节点的完整路径开头适用于嵌套路由 const isActive currentPath.startsWith(nodeFullPath); // 判断是否默认展开如果节点被激活或者其子节点有被激活的则应展开 let isOpen node.meta?.defaultOpen || false; // 处理子节点并判断是否需要展开 if (node.children node.children.length 0) { const childrenWithState node.children.map(child injectViewState(child, nodeFullPath)); const hasActiveChild childrenWithState.some(child child.isActive); isOpen isOpen || hasActiveChild || isActive; // 自己或子节点激活则展开 node.children childrenWithState; } return { ...node, isActive, isOpen, // 可以在这里注入更多UI相关字段如 className, style 等 }; } return menuTree.map(root injectViewState(root)); }核心逻辑解析激活状态判断使用currentPath.startsWith(nodeFullPath)是一个常见且实用的策略。它确保了当你在/user/profile页面时父级菜单/user也会被高亮显示。展开状态逻辑展开逻辑是递归的。一个节点是否展开取决于1. 配置的默认展开(defaultOpen)2. 自身是否激活(isActive)3. 其子节点中是否有激活的(hasActiveChild)。这个逻辑保证了导航树的正确展开提升用户体验。4. 组装与集成构建完整的菜单服务有了以上一个个独立的“乐高积木”模块我们现在需要把它们组装起来形成一个易用的“菜单服务”。这个服务对外提供一个简洁的API内部则按顺序调用各个模块。// services/menuService.js import { normalizeMenuNodes } from /utils/menuDataNormalizer; import { buildTreeFromFlatList } from /utils/treeBuilder; import { filterMenuByPermission } from /utils/menuPermissionFilter; import { generateMenuViewModel } from /utils/menuViewModelGenerator; /** * 菜单服务整合所有模块提供一站式菜单生成功能 */ class MenuService { constructor(options {}) { this.options options; } /** * 生成完整的、可供视图层直接使用的菜单树 * param {Array} rawData - 原始API数据 * param {Array} userPermissions - 用户权限列表 * param {String} currentPath - 当前路由路径 * returns {PromiseArray} 处理后的菜单视图模型树 */ async generateFullMenu(rawData, userPermissions, currentPath) { try { // 1. 数据标准化 const normalizedList normalizeMenuNodes(rawData, this.options.normalize); // 2. 构建树形结构 const rawTree buildTreeFromFlatList(normalizedList); // 3. 权限过滤 const filteredTree filterMenuByPermission(rawTree, userPermissions); // 4. 生成视图模型 const viewModelTree generateMenuViewModel(filteredTree, currentPath); return viewModelTree; } catch (error) { console.error([MenuService] Failed to generate menu:, error); // 优雅降级返回一个空数组或基础菜单避免页面白屏 return []; } } // 可以在此处添加其他方法如更新单个菜单项状态、清空缓存等 } // 导出一个默认配置的实例也可以导出类供自定义 export const defaultMenuService new MenuService({ normalize: { idField: menuId, nameField: menuName, // ... 其他映射 } }); // 在Vue/React组件或Store中使用 // const menuList await defaultMenuService.generateFullMenu(apiData, user.permissions, route.path);设计模式思考 这里我们采用了“服务Service”模式。它将复杂的模块链封装在一个清晰的接口背后。这样做的好处是使用简单调用者无需关心内部有多少个步骤。易于维护所有菜单相关的逻辑都集中在此修改流程或替换某个模块非常方便。便于扩展未来如果需要增加“数据缓存”或“国际化标题处理”等步骤只需在generateFullMenu方法中添加而不会影响现有调用方。5. 在Vue/React项目中的实际集成理论再好也需要落地。我们看看如何在现代前端框架中使用这套模块化服务。5.1 在Vue 3 (Composition API) 中的集成!-- components/AppMenu.vue -- template div classmenu-container menu-item v-fornode in menuTree :keynode.id :nodenode :level0 / /div /template script setup import { ref, watchEffect } from vue; import { useRoute } from vue-router; import { defaultMenuService } from /services/menuService; import MenuItem from ./MenuItem.vue; const route useRoute(); const menuTree ref([]); const userPermissions ref([user_view, order_manage]); // 应从全局状态如Pinia获取 // 监听路由和权限变化重新生成菜单 watchEffect(async () { try { // 1. 模拟或真实获取API数据 const rawMenuData await fetchMenuDataFromAPI(); // 2. 使用服务生成菜单 const tree await defaultMenuService.generateFullMenu( rawMenuData, userPermissions.value, route.path ); menuTree.value tree; } catch (err) { console.error(Failed to load menu:, err); menuTree.value []; // 或显示错误状态 } }); async function fetchMenuDataFromAPI() { // 这里应该是真实的API调用例如 // const response await axios.get(/api/user/menus); // return response.data; // 以下是模拟数据 return [ { menuId: 1, menuName: 仪表盘, parentId: null, path: /dashboard, order: 0 }, { menuId: 2, menuName: 用户管理, parentId: null, path: /user, order: 1 }, { menuId: 3, menuName: 用户列表, parentId: 2, path: list, order: 0, meta: { requiresAuth: user_view } }, { menuId: 4, menuName: 订单管理, parentId: null, path: /order, order: 2, meta: { defaultOpen: true } }, { menuId: 5, menuName: 订单列表, parentId: 4, path: list, order: 0, meta: { requiresAuth: order_manage } }, ]; } /script!-- components/MenuItem.vue (递归组件) -- template div classmenu-item :class{ is-active: node.isActive, has-children: hasChildren } a clickhandleClick span{{ node.name }}/span span v-ifhasChildren{{ node.isOpen ? ▲ : ▼ }}/span /a div v-ifhasChildren node.isOpen classchildren menu-item v-forchild in node.children :keychild.id :nodechild :levellevel 1 / /div /div /template script setup import { computed } from vue; const props defineProps({ node: Object, level: Number, }); const hasChildren computed(() props.node.children props.node.children.length 0); function handleClick() { // 如果有路径则跳转 if (props.node.path) { // 使用路由跳转这里需要组合完整路径实际项目中可能由服务层或父组件计算好 // router.push(...) } // 如果没有路径只是分组则切换展开状态 // 注意在我们的视图模型中isOpen状态由服务根据路由计算通常不需要手动切换。 // 如果业务需要手动开合可以在这里emit一个事件让父组件更新数据。 } /script5.2 在React (Hooks) 中的集成// hooks/useMenu.js import { useState, useEffect } from react; import { useLocation } from react-router-dom; import { defaultMenuService } from /services/menuService; export function useMenu() { const location useLocation(); const [menuTree, setMenuTree] useState([]); const [userPermissions] useState([user_view, order_manage]); // 应从Context/Redux获取 useEffect(() { const fetchAndGenerateMenu async () { const rawData await fetchMenuDataFromAPI(); const tree await defaultMenuService.generateFullMenu( rawData, userPermissions, location.pathname ); setMenuTree(tree); }; fetchAndGenerateMenu(); }, [location.pathname, userPermissions]); // 依赖项路径和权限变化时更新菜单 return menuTree; } // components/MenuItem.jsx function MenuItem({ node, level }) { const hasChildren node.children node.children.length 0; return ( div className{menu-item level-${level} ${node.isActive ? is-active : }} div onClick{handleClick} span{node.name}/span {hasChildren span{node.isOpen ? ▲ : ▼}/span} /div {hasChildren node.isOpen ( div classNamechildren {node.children.map(child ( MenuItem key{child.id} node{child} level{level 1} / ))} /div )} /div ); }集成关键点状态管理菜单数据menuTree应该被视为派生状态。它由原始API数据、用户权限和当前路由路径这三个源头计算而来。因此在Vue的watchEffect或React的useEffect中监听这些源头的变更并重新调用服务生成新菜单是最佳实践。递归组件MenuItem组件调用自身来渲染子节点这是渲染树形结构的标准做法。注意要设置好终止条件当hasChildren为false时不再渲染子节点容器。性能在根组件监听变化并生成整棵树是合理的因为菜单数据量通常不大。如果菜单极其庞大如超过1000个节点可以考虑虚拟滚动或分步加载但那属于UI优化范畴不影响我们核心的数据处理架构。6. 常见问题、优化策略与避坑指南在实际开发和后续迭代中我们遇到了不少问题也总结出一些优化策略。6.1 数据不一致与缓存策略问题菜单数据并非完全静态可能因为后台配置更改而更新。如果每次路由变化都重新调用API并走完整个处理流程会造成不必要的网络请求和计算。解决方案引入缓存机制。但要注意菜单数据与用户权限和路由强相关不能简单地进行全局缓存。// services/menuService.js (增强版) class MenuService { constructor(options {}) { this.options options; this.cache new Map(); // 使用Map作为简单缓存 } // 生成一个基于输入参数的缓存键 _generateCacheKey(rawData, userPermissions, currentPath) { // 简单示例将关键参数序列化作为键 // 注意rawData可能是大型对象直接JSON.stringify性能不佳。 // 实际项目中可以使用rawData的版本号、更新时间戳或一个唯一hash。 const permissionKey userPermissions.sort().join(,); return menu_${permissionKey}_${currentPath}; } async generateFullMenu(rawData, userPermissions, currentPath, forceRefresh false) { const cacheKey this._generateCacheKey(rawData, userPermissions, currentPath); // 强制刷新或缓存不存在时重新计算 if (forceRefresh || !this.cache.has(cacheKey)) { try { // ... 原有的处理流程 ... const viewModelTree generateMenuViewModel(filteredTree, currentPath); this.cache.set(cacheKey, viewModelTree); // 存入缓存 return viewModelTree; } catch (error) { // ... 错误处理 ... } } // 返回缓存结果 return this.cache.get(cacheKey); } // 提供清除缓存的方法例如当用户权限变更或主动刷新菜单时调用 clearCache() { this.cache.clear(); } }避坑提示缓存键的设计至关重要。如果rawData很大对其进行序列化JSON.stringify来生成缓存键会消耗性能。更好的做法是让后端API为菜单数据提供一个version或lastUpdated字段将其与userPermissions和currentPath一起组成缓存键。6.2 无限递归与循环引用问题在构建树或处理数据时如果数据本身有误比如某个节点的parentId指向了自己的id或者形成了A-B-C-A的循环递归函数就会陷入死循环导致栈溢出。解决方案在树构建和递归处理函数中增加安全措施。// utils/treeBuilder.js (增强版) export function buildTreeFromFlatList(flatNodes, maxDepth 20) { const nodeMap new Map(); const tree []; const depthCounter new Map(); // 记录节点深度 flatNodes.forEach(node { node.children []; depthCounter.set(node.id, 0); nodeMap.set(node.id, node); }); flatNodes.forEach(node { const parentId node.parentId; if (!parentId) { tree.push(node); } else { const parentNode nodeMap.get(parentId); if (parentNode) { // 检查是否会造成循环引用 let currentId parentId; while (currentId) { if (currentId node.id) { console.error(Circular reference detected! Node ${node.id} is ancestor of itself.); tree.push(node); // 或忽略此节点 return; } const parent nodeMap.get(currentId); currentId parent ? parent.parentId : null; } // 检查深度 const parentDepth depthCounter.get(parentId); if (parentDepth maxDepth) { console.warn(Max depth (${maxDepth}) exceeded for node ${node.id}. Treating as root.); tree.push(node); } else { parentNode.children.push(node); depthCounter.set(node.id, parentDepth 1); } } else { console.warn(Node ${node.id} has non-existent parentId: ${parentId}. Treated as root.); tree.push(node); } } }); // ... 排序等后续操作 }实操心得在开发阶段这些防御性代码能帮你快速定位脏数据问题。在生产环境除了记录错误日志还应该有一个降级方案比如跳过有问题的节点保证主功能可用。6.3 性能优化避免不必要的重渲染问题在Vue/React中当菜单树的顶层状态变化时即使某个深层子节点的数据没变整个递归组件树也可能重新渲染。优化策略精细化状态管理确保menuTree这个状态只在API数据、权限、路由变化时才更新避免将其放在可能频繁变化的全局状态中。组件记忆化使用React.memo(React)或computed/watch(Vue)来避免子组件不必要的渲染。确保MenuItem组件的props主要是node对象在内容未变化时保持引用稳定。扁平化数据结构对于超大型菜单可以考虑在服务层生成视图模型后额外生成一个id - node的扁平化映射表。在UI层通过ID来查找节点而不是总是遍历树。这在处理节点选中状态时特别有用。6.4 动态菜单与实时更新问题在某些协同办公或实时性要求高的场景菜单可能需要动态增删例如收到新消息某个菜单项需要显示红点。解决方案我们的模块化架构为此提供了良好基础。不要直接修改已生成的视图模型树。正确的做法是更新原始的rawData或其中的某个节点的meta信息。调用menuService.generateFullMenu(...)方法传入新的rawData重新生成整棵树。由于我们的处理函数大多是纯函数或幂等的重新计算的开销是可接受的。如果担心性能可以结合上述缓存策略仅当rawData确实变化时才清除缓存。这种“数据驱动”的方式比直接操作DOM或组件状态要清晰和可靠得多。7. 测试策略如何保证模块的可靠性函数模块化的一大优势就是可测试性。我们应该为每个核心模块编写单元测试。// utils/treeBuilder.test.js (使用Jest示例) import { buildTreeFromFlatList } from ./treeBuilder; describe(buildTreeFromFlatList, () { test(should build correct tree from flat list, () { const flatList [ { id: 1, parentId: null, name: Root1 }, { id: 2, parentId: 1, name: Child1 }, { id: 3, parentId: 1, name: Child2 }, { id: 4, parentId: null, name: Root2 }, ]; const tree buildTreeFromFlatList(flatList); expect(tree).toHaveLength(2); expect(tree[0].children).toHaveLength(2); expect(tree[0].children[0].name).toBe(Child1); expect(tree[1].children).toHaveLength(0); }); test(should handle circular reference gracefully, () { const flatListWithCircle [ { id: 1, parentId: 2, name: Node1 }, { id: 2, parentId: 1, name: Node2 }, // 形成循环 ]; // 测试不会栈溢出并且有错误处理如console.error const tree buildTreeFromFlatList(flatListWithCircle); // 可以断言树被正确构建例如两个节点都成为了根节点 expect(tree).toHaveLength(2); }); test(should sort children by order field, () { const flatList [ { id: 1, parentId: null, name: Root, order: 0 }, { id: 2, parentId: 1, name: ChildB, order: 2 }, { id: 3, parentId: 1, name: ChildA, order: 1 }, ]; const tree buildTreeFromFlatList(flatList); expect(tree[0].children[0].name).toBe(ChildA); expect(tree[0].children[1].name).toBe(ChildB); }); });测试重点正常流程输入标准数据验证输出树结构正确。边界情况空数组、单个节点、深层嵌套测试递归深度限制。异常数据循环引用、不存在的parentId、缺失的字段。纯函数特性相同的输入是否总是得到相同的输出函数是否修改了原始输入数据应避免为normalizeMenuNodes,filterMenuByPermission,generateMenuViewModel等每个模块都编写类似的测试。最后可以为整合的MenuService.generateFullMenu编写集成测试模拟整个流水线。经过这样一番从混沌到秩序的模块化改造我们的多级菜单代码从一坨难以维护的“泥球”变成了一个条理清晰、职责分明、易于测试和扩展的“乐高套装”。这个过程中最大的收获不是某个具体的函数怎么写而是如何用分而治之的思想将复杂问题分解为一系列简单、可组合的步骤。这套方法论完全可以迁移到项目中其他复杂的数据处理场景比如表单联动、工作流配置、报表生成等等。当你下次再面对看似混乱的业务逻辑时不妨先停下来想想这条数据流水线应该有哪些“加工站”
返回列表