news 2026/9/10 18:30:45

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

作者头像

张小明

前端开发工程师

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

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

【免费下载链接】vue-vben-adminA modern vue admin panel built with Vue3, Shadcn UI, Vite, TypeScript, and Monorepo. It's 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”的流水线。核心入口是 generateRoutesByFrontend:

async function generateRoutesByFrontend( routes: RouteRecordRaw[], roles: string[], forbiddenComponent?: RouteRecordRaw['component'], ): Promise<RouteRecordRaw[]> { // 根据角色标识过滤路由表,判断当前用户是否拥有指定权限 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递归遍历树时,对每个命中的节点执行“就地赋值”,把过滤后的子节点数组直接挂回源节点。这在“一次性使用”的场景下看似无碍,但放到动态路由系统中问题立刻放大:

  1. 每次登录/角色刷新都会重新过滤同一棵源树。路由守卫 setupAccessGuard 在accessStore.isAccessChecked为 false 时调用generateAccess,携带routes: accessRoutes重新生成可访问路由与菜单;
  2. 低权限用户先登录:过滤时所有未命中的子路由(如仅 admin 可见的/dashboard/analysis)被写回后的源节点删除;
  3. 同一页面内切换为管理员:再次执行generateRoutesByFrontend,输入仍是那份已被污染的内存常量。filterTree基于已损坏的树继续过滤,被低权限用户“删掉”的节点永远无法重新出现,直到整页刷新重建模块。

changeset 对症状的描述非常具体:管理员登录后/dashboard子菜单为空。因为只要有一层父级节点的子数组被清空/缩减,该父节点的所有子路由在后续过滤中都会丢失,菜单也随之坍缩。这正是一个典型的“可变共享状态 + 多次消费”引发的数据污染事故。

四、修复方案:让 filterTree 成为纯函数,用引用语义保护性能

修复后的filterTree完整实现位于 packages/@core/base/shared/src/utils/tree.ts:

function filterTree<T extends Record<string, 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 Record<string, 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树工具(filterTreemapTreesortTreetraverseTreeValues)的开发者有几点明确的实践指引:

1. 不要假设树操作函数会保护你的数据,尤其是模块级常量。即使本次修复让filterTree变为纯函数,任何对共享树结构的消费都应谨慎:若上游数据同时被多处消费(如路由表、菜单生成、面包屑),优先考虑在入口处cloneDeep隔离(可参考 accessible.ts 的做法)。

2. 理解filterTree的参数契约。签名是filterTree(tree, filter, options?)filter接收每个节点并返回布尔值决定去留;options.childProps用于指定子节点数组的属性名,默认'children'(tree.ts),当你的树结构使用其他字段名(如itemsroutes)时必须显式传入。注意过滤语义是“节点自身命中才保留”,若某子节点未命中,其整棵子树都会被剪除;若需要“父命中但子被剪”,只需让父节点条件恒真(参考测试中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 类型定义),默认frontend(config.ts),即默认走generateRoutesByFrontendfilterTree这条链路。这也解释了为何该 patch 修复对绝大多数开箱即用的部署都是实质性改进——它不是边角场景,而是默认配置路径上的必经环节。

结语

回顾整个事件:一个工具函数中看似无害的“就地赋值”,因为输入是模块级共享常量、消费方是多次执行的登录/刷新流程,最终演变为跨会话的路由数据污染。filterTree的纯函数化修复给出了一个教科书级的解决方案——用“子节点未变化则按引用返回、有变化则浅拷贝”的精细策略,同时满足了源数据不可变引用语义稳定两个看似矛盾的需求,并以完备的回归测试将这一契约固化下来。对于任何需要多次基于同一棵源树做过滤、且节点对象身份敏感的调用方而言,这套“纯函数 + 按需复制”的模式都值得直接借鉴。

【免费下载链接】vue-vben-adminA modern vue admin panel built with Vue3, Shadcn UI, Vite, TypeScript, and Monorepo. It's fast!项目地址: https://gitcode.com/GitHub_Trending/vu/vue-vben-admin

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 18:28:34

用金字塔原则做项目计划:从目标到任务树的结构化方法

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 18:26:17

JAVA计算机毕设之基于 SpringBoot 的健身房运营管理平台的设计与实现 基于 SpringBoot 的健身教练与课程管理系统(完整前后端代码+说明文档+LW,调试定制等)

博主介绍&#xff1a;✌️码农一枚 &#xff0c;专注于大学生项目实战开发、讲解和毕业&#x1f6a2;文撰写修改等。全栈领域优质创作者&#xff0c;博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于Java、小程序技术领域和毕业项目实战 ✌️技术范围&#xff1a;&am…

作者头像 李华
网站建设 2026/9/10 18:26:10

ECG心电图5分类实战:TCN+Restormer混合模型与Python信号预处理

简介&#xff1a;本资源是一套面向高校本科生及人工智能初学者的心电图&#xff08;ECG&#xff09;信号五分类深度学习完整实践方案&#xff0c;聚焦心血管疾病早期筛查中的心律失常识别问题&#xff0c;适用于期末大作业、毕业设计与课程设计等工程实践场景。资源包共36个文件…

作者头像 李华