ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Vue Vben Admin 前端动态路由权限过滤的隐性 Bug:filterTree 修改源树导致跨会话路由丢失的修复剖析

Vue Vben Admin 前端动态路由权限过滤的隐性 Bug:filterTree 修改源树导致跨会话路由丢失的修复剖析 Vue Vben Admin 前端动态路由权限过滤的隐性 BugfilterTree 修改源树导致跨会话路由丢失的修复剖析【免费下载链接】vue-vben-adminA modern vue admin panel built with Vue3, Shadcn UI, Vite, TypeScript, and Monorepo. Its fast!项目地址: https://gitcode.com/GitHub_Trending/vu/vue-vben-admin本篇技术指南聚焦 Vue Vben Admin 中vben-core/shared包的一次关键缺陷修复树形过滤工具函数filterTree因将过滤结果写回源节点导致前端模式frontend动态路由在“低权限用户登录 → 管理员登录”的同一会话场景下永久丢失被过滤的子路由典型现象是/dashboard子菜单为空必须整页刷新才能恢复。文章以 changeset 修复记录 为骨架结合 tree.ts 实现、回归测试 与路由生成调用链源码完整还原 Bug 的触发链路、修复方案的设计取舍以及纯函数树过滤的正确使用方式。读完你将理解为什么“看起来只是过滤一下树”的工具函数会在多角色动态路由系统中酿成数据污染事故以及如何用引用语义 浅拷贝构建既安全又不破坏性能的纯函数树操作。一、一次 patch 级修复背后的高影响场景.changeset/quiet-trees-filter.md是一条标准的 Changesets 变更记录声明的版本影响为vben-core/shared: patch修复内容一句话概括filterTree不再修改源树keepfilterTreefrom mutating the source tree。这条记录同时指出了问题的严重性——它并非孤立工具函数的行为瑕疵而是会真实影响前端动态路由权限系统的数据完整性。要理解这次修复的价值必须先还原filterTree在路由系统中所处的位置。二、场景还原filterTree 在动态路由链路中的角色Vue Vben Admin 的前端权限路由生成采用“过滤路由表 → 生成菜单 → 注入 router”的流水线。核心入口是 generateRoutesByFrontendasync function generateRoutesByFrontend( routes: RouteRecordRaw[], roles: string[], forbiddenComponent?: RouteRecordRaw[component], ): PromiseRouteRecordRaw[] { // 根据角色标识过滤路由表,判断当前用户是否拥有指定权限 const finalRoutes filterTree(routes, (route) { return hasAuthority(route, roles); }); if (!forbiddenComponent) { return finalRoutes; } // 如果有禁止访问的页面将禁止访问的页面替换为403页面 return mapTree(finalRoutes, (route) { if (menuHasVisibleWithForbidden(route)) { route.component forbiddenComponent; } return route; }); }过滤依据来自每个路由meta.authority与当前用户角色的交集判断hasAuthority无authority声明的路由默认放行命中角色则放行未命中但在meta.menuVisibleWithForbidden中显式声明时保留菜单但访问重定向 403。而filterTree的输入routes在各 Web 应用中是模块级常量。以 apps/web-antd/src/router/routes/index.ts 为例/** 有权限校验的路由列表包含动态路由和静态路由 */ const accessRoutes [...dynamicRoutes, ...staticRoutes]; export { accessRoutes, coreRouteNames, routes };其中dynamicRoutes通过import.meta.glob(./modules/**/*.ts, { eager: true })在模块加载期一次性收集合并后导出。这意味着只要页面不刷新accessRoutes数组在内存中只有一份。三、Bug 根因过滤结果被写回源树源数据被永久污染changeset 中直接点出了缺陷代码的形态// 修复前把过滤后的子节点数组写回源节点 node[childProps] _filterTree(node[childProps]);filterTree递归遍历树时对每个命中的节点执行“就地赋值”把过滤后的子节点数组直接挂回源节点。这在“一次性使用”的场景下看似无碍但放到动态路由系统中问题立刻放大每次登录/角色刷新都会重新过滤同一棵源树。路由守卫 setupAccessGuard 在accessStore.isAccessChecked为 false 时调用generateAccess携带routes: accessRoutes重新生成可访问路由与菜单低权限用户先登录过滤时所有未命中的子路由如仅 admin 可见的/dashboard/analysis被写回后的源节点删除同一页面内切换为管理员再次执行generateRoutesByFrontend输入仍是那份已被污染的内存常量。filterTree基于已损坏的树继续过滤被低权限用户“删掉”的节点永远无法重新出现直到整页刷新重建模块。changeset 对症状的描述非常具体管理员登录后/dashboard子菜单为空。因为只要有一层父级节点的子数组被清空/缩减该父节点的所有子路由在后续过滤中都会丢失菜单也随之坍缩。这正是一个典型的“可变共享状态 多次消费”引发的数据污染事故。四、修复方案让 filterTree 成为纯函数用引用语义保护性能修复后的filterTree完整实现位于 packages/core/base/shared/src/utils/tree.tsfunction filterTreeT extends Recordstring, any( tree: T[], filter: (node: T) boolean, options?: TreeConfigOptions, ): T[] { const { childProps } options || { childProps: children, }; const _filterTree (nodes: T[]): T[] { const result: T[] []; for (const node of nodes) { if (!filter(node)) { continue; } const children (node as Recordstring, any)[childProps]; if (!children) { result.push(node); continue; } const filteredChildren _filterTree(children); // 子节点序列没有变化时按引用返回原节点保持原有的引用语义 // 只有确实需要替换子节点时才复制节点避免污染源数据 const childrenUnchanged filteredChildren.length children.length filteredChildren.every((child, index) child children[index]); result.push( childrenUnchanged ? node : { ...node, [childProps]: filteredChildren }, ); } return result; }; return _filterTree(tree); }修复的设计要点有三层第一层杜绝写回核心修复。过滤结果只进入新建的result数组任何情况下都不再对node[childProps]赋值。源树在整个调用过程中只读。第二层按需浅拷贝childrenUnchanged判定。当某个命中节点的子节点序列在过滤前后完全一致长度相同且每个子节点引用相同时说明该分支没有被裁剪直接按引用返回原节点只有当确实有子节点被剔除时才通过{ ...node, [childProps]: filteredChildren }浅拷贝出一个新对象。这保证了未受影响的分支零拷贝递归过程不产生无谓的对象分配只有“被删除节点的祖先路径”上的分支会被复制副本数严格受控。第三层引用语义的精细保持。叶子节点无childProps数组始终按引用返回子节点未变化的父节点也按引用返回。从源码注释tree.ts可以读出这一设计对调用方的契约承诺按引用缓存或比较节点的调用方其行为保持不变。换句话说修复不是粗暴地“每次都深拷贝整棵树”而是精确地在“不污染源数据”与“不破坏引用稳定性”之间取平衡。顺带一提当前源码中路由生成入口 generateAccessible 还在进入generateRoutes前对options.routes执行了一次cloneDeep与纯函数化的filterTree构成双重防护——即便未来有新的调用方直接向filterTree传入模块级常量源数据也是安全的。五、回归测试用测试用例锁死“不可变性”契约这次修复的验证并未停留在代码层面tree.test.ts 中专门新增了filterTree immutability测试组逐条锁定上述契约1. 源树不被修改快照对比it(should not mutate the source tree, () { const tree buildTree(); const snapshot clone(tree); // JSON 深拷贝快照 filterTree(tree, keepEven); expect(tree).toEqual(snapshot); // 过滤前后源树完全一致 });2. 过滤结果不写回源节点对象身份对比it(should not write filtered children back onto the source node, () { const tree buildTree(); const result filterTree(tree, keepEven); const sourceParent tree.find((node) node.id 8); const resultParent result.find((node) node.id 8); // 源节点的 children 仍是 [9, 10]未被缩减 expect(sourceParent?.children?.map((child) child.id)).toEqual([9, 10]); // 结果中的父节点是副本而非源对象 expect(resultParent).not.toBe(sourceParent); expect(resultParent?.children?.map((child) child.id)).toEqual([10]); });3. 引用语义保持叶子与未变化分支按引用返回// 叶子节点保持引用 expect(result.find((node) node.id 11)).toBe(leaf); // 全命中时分支节点保持引用 expect(result[0]).toBe(tree[0]); expect(result[0]?.children?.[1]).toBe(tree[0]?.children?.[1]);4. 只复制“受影响路径”上的分支it(should only copy the branches on the path of a dropped node, () { const result filterTree(tree, (node) node.id ! 6); // 受影响分支被复制源节点不受影响 expect(resultBranch).not.toBe(sourceBranch); // 未受影响的兄弟分支与其余根节点保持原引用 expect(result[0]?.children?.[0]).toBe(tree[0]?.children?.[0]); expect(result[1]).toBe(tree[1]); expect(result[2]).toBe(tree[2]); });5. 直接复现生产事故关键回归用例it(should keep returning nodes that an earlier filter run dropped, () { // 模拟低权限用户登录先以较窄条件过滤一次丢弃 id 为 9 的子节点 filterTree(tree, (node) node.id ! 9); // 再放宽条件模拟更高权限用户被丢弃的节点应当重新出现 const widened filterTree(tree, () true); expect(widened).toEqual(buildTree()); });这个用例用两次连续过滤精确还原了 changeset 中描述的场景——“修复前上一次被过滤掉的子节点会被永久写回源数据导致后续过滤即使是更宽松的条件再也拿不到它们”修复后第二次放宽条件的过滤能完整恢复整棵树。测试还覆盖了自定义childProps如items下的不可变性确保修复对可配置子属性名同样生效。六、对使用者的启示正确使用 filterTree 与树工具这次修复对在项目中直接使用vben-core/shared树工具filterTree、mapTree、sortTree、traverseTreeValues的开发者有几点明确的实践指引1. 不要假设树操作函数会保护你的数据尤其是模块级常量。即使本次修复让filterTree变为纯函数任何对共享树结构的消费都应谨慎若上游数据同时被多处消费如路由表、菜单生成、面包屑优先考虑在入口处cloneDeep隔离可参考 accessible.ts 的做法。2. 理解filterTree的参数契约。签名是filterTree(tree, filter, options?)filter接收每个节点并返回布尔值决定去留options.childProps用于指定子节点数组的属性名默认childrentree.ts当你的树结构使用其他字段名如items、routes时必须显式传入。注意过滤语义是“节点自身命中才保留”若某子节点未命中其整棵子树都会被剪除若需要“父命中但子被剪”只需让父节点条件恒真参考测试中node.name root的写法。3. 依赖引用语义做缓存/比较是安全的。修复后过滤不会破坏未受影响节点的引用稳定性因此依赖对象身份做记忆化、缓存失效判断的代码可以放心基于filterTree的结果继续工作。4. 权限路由的“可见但 403”机制独立于过滤。filterTree只负责按meta.authority裁剪而meta.menuVisibleWithForbidden控制的是“菜单保留、访问被替换为 403 组件”由generateRoutesByFrontend后续的mapTree环节处理二者职责分离排查问题时不要混淆。从 use-access.ts 可知访问模式accessMode支持backend/frontend/mixed见 app.d.ts 类型定义默认frontendconfig.ts即默认走generateRoutesByFrontend→filterTree这条链路。这也解释了为何该 patch 修复对绝大多数开箱即用的部署都是实质性改进——它不是边角场景而是默认配置路径上的必经环节。结语回顾整个事件一个工具函数中看似无害的“就地赋值”因为输入是模块级共享常量、消费方是多次执行的登录/刷新流程最终演变为跨会话的路由数据污染。filterTree的纯函数化修复给出了一个教科书级的解决方案——用“子节点未变化则按引用返回、有变化则浅拷贝”的精细策略同时满足了源数据不可变与引用语义稳定两个看似矛盾的需求并以完备的回归测试将这一契约固化下来。对于任何需要多次基于同一棵源树做过滤、且节点对象身份敏感的调用方而言这套“纯函数 按需复制”的模式都值得直接借鉴。【免费下载链接】vue-vben-adminA modern vue admin panel built with Vue3, Shadcn UI, Vite, TypeScript, and Monorepo. Its fast!项目地址: https://gitcode.com/GitHub_Trending/vu/vue-vben-admin创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进