手上一个 JeecgBoot 底子的 Vue 2 老项目,要从里面抽个子系统出来独立部署。抽的过程里我把「后端下发菜单树 → 前端转成路由 → addRoutes 动态注册 → v-has 控制按钮」这一整套删了,换成前端按角色写死的配置。
JeecgBoot 是低代码平台,「菜单在后台点一点、不用发版」本来就是它的卖点之一。把这个能力拆掉听着像倒退,所以先说清两件事:
- 如果你是搜报错进来的,第一节就是那两个 ------ 刷新白屏 和
permissionList[0]报 undefined,怎么定位在哪一层。 - 如果你在纠结这套要不要留,第二节往后是判断标准和四条硬边界。
一、动态路由这套最常见的两个报错,怎么定位
1. Cannot read property 'path' of undefined
报错位置通常在 store/getters.js 或某个组件里,长这样:
js
store.getters.permissionList[0].path
permissionList 是后端下发的菜单存进 vuex 的结果。它为空的时候,[0] 就是 undefined,再取 .path 直接抛。
它为空的时机比想象的多:刷新页面、菜单接口报错、token 过期被拦、这个角色一个菜单都没配。
我这次踩到的具体位置是异常页的「回首页」按钮 ------ 500 / 403 页面上那个按钮取 permissionList[0].path 当跳转目标。而能走到异常页的场景,往往就是接口挂了、菜单没下发的场景。报错页自己再报一次错。
修法看你要不要保留动态跳转:
js
// 要保留:加兜底
const first = store.getters.permissionList
const target = (first && first[0] && first[0].path) || '/dashboard'
// 不要保留:直接写常量(我选的这个)
const target = '/dashboard'
📌 顺手扫一遍全项目的 permissionList[ 和 permissionList. ------ 这类取值一般不止一处。
2. 刷新页面白屏,或者跳到了错误的页面
这个不是一个 bug,是四层里任意一层断了都会出现同一个现象。所以别急着改代码,先定位是哪一层。
动态路由的链路是这样的:
markdown
登录/刷新 → 守卫拦住 → 查 vuex 里有没有菜单
→ 没有就调接口拿菜单树
→ generateIndexRouter 把菜单树转成路由配置
→ router.addRoutes 注册
→ 重新走一次导航
四层都可能断,逐层确认:
第一层,路由到底注册上没有。 这是最容易误判的一层,因为 vue-router 3 的 addRoutes 只写进内部的 matcher,不会更新 router.options.routes ------ 打印 options.routes 看不到新路由,那是正常的,不代表没注册上。要这么看:
js
console.log(this.$router.resolve('/user/list').route.matched.length) // 0 = 真没注册上
第二层,菜单接口返回了什么。 直接看 Network 里那个查菜单的请求。返回空数组 → 这个角色的菜单没配,是后台数据问题,不是前端的事。
第三层,转换有没有丢东西。 在 generateIndexRouter 的返回值上打个断点,对着菜单树数一下条数。JeecgBoot 这套对菜单表里的 component 字段有格式要求,路径写错了转出来的路由 component 是空的,注册上了也渲染不出东西 ------ 这种情况路由能匹配到,但页面是白的,和「没注册上」的表现一样,但原因完全不同。
第四层,重新导航的写法。 addRoutes 之后必须再走一次导航,当前这次导航是在路由还不存在的时候发起的。守卫里要这么放行:
js
next({ ...to, replace: true })
漏掉 replace: true,历史记录里会多出一条指向不存在路由的记录,用户点后退就白屏。直接写 next() 更糟 ------ 它放行的还是那次匹配不到的导航。
📌 这四层就是这套方案的真实调试成本。 页面打不开的时候,你要先分清是路由没注册、菜单没下发、转换丢了字段,还是导航写法不对。每一层的表现都可能是「白屏」。
二、这两个报错是同一个结构带来的
上面两个都能修,我也修了第一个。但修完之后我把整套删了,原因是它们不是独立的 bug:
- 报错 1 的根源是菜单成了运行时数据,所以到处都要防它为空
- 报错 2 的根源是路由成了异步注册的,所以每个入口都要重新过一遍那四层
只要这个结构在,这类问题就会以新的形态回来 ------ 加一个新入口、加一种登录方式、加一个「从别的系统跳进来」的场景,四层都要重新想一遍。
所以真正要回答的问题不是「怎么修」,是「这套复杂度换来的东西,我需不需要」。
三、判断依据:角色是数据,还是规则
动态路由解决的是一个具体问题:角色和菜单是运营时可配置的数据。
管理员能在后台新建一个角色、勾一批菜单、分配给某些用户,立刻生效,不用发版。这个能力值一整套复杂度 ------ 菜单表、角色菜单关联表、权限码、菜单转路由、按钮级指令,一个都不能少。
我这个系统的实际情况是:
| 角色 | 账号 | 可见页面 |
|---|---|---|
| 系统管理员 | sys_admin | 用户管理、部门管理、日志管理、自定义 |
| 安全管理员 | sec_admin | 用户管理、角色管理、日志管理、自定义 |
| 审计员 | audit_admin | 日志管理 |
三个角色,账号名固定,这三个角色的职责必须互相分立,不允许一个人全占。这不是运营数据,是需求文档里写死的规则。 它变化的频率和「加一个新页面」是同一个数量级,而加新页面本来就要发版。
判断标准可以总结成一句:
角色和菜单的变化频率,是不是明显高于代码发版的频率? 是,用动态路由;不是,那套复杂度就是纯负担。
四、换成什么
一个配置文件,src/config/roleConfig.js:
js
export const ROLE_SYSTEM = 'sys_admin' // 系统管理员
export const ROLE_SECURITY = 'sec_admin' // 安全管理员
export const ROLE_AUDIT = 'audit_admin' // 审计员
export const ROLE_CONFIG = {
[ROLE_SYSTEM]: {
name: '系统管理员',
tabs: ['user', 'depart', 'log', 'custom'],
customTabs: ['skill', 'pageConfig'],
// '*' 表示该角色在可见页面内不再做按钮级限制;
// 若后续要收紧,把 '*' 换成具体权限码数组即可,例如 ['user:search', 'user:add']
perms: ['*']
},
[ROLE_SECURITY]: {
name: '安全管理员',
tabs: ['user', 'role', 'log', 'custom'],
customTabs: ['security'],
perms: ['*']
},
[ROLE_AUDIT]: {
name: '审计员',
tabs: ['log'],
customTabs: [],
perms: ['*']
}
}
配套几个查询函数:hasTab(role, key) / hasCustomTab(role, key) / checkPerm(role, code) / defaultTab(role)。
具体删掉的东西:
utils/util.js里的generateIndexRouter/generateChildRouters,净删 116 行store/modules/permission.js整个 modulerouter/index.js从「静态白名单 + 动态注册」改成纯静态,+36 −253v-has指令(utils/hasPermission.js)
页面结构也跟着变了:不再是「一个菜单项对应一个路由」,而是一个聚合页里放左侧 tab,tab 按角色显隐。路由只有几条,全静态。
这个设计最大的好处是可读性。 产品问「审计员能看到什么」,我把那十行给他看,他自己就能读懂。原来那套要查三张表加一个菜单管理页面。
五、关键:换实现,不换接口
这是整个改动能低成本落地的原因。
从老项目搬过来的页面里,到处都是这种代码:
html
<a-button v-if="hasPerm('user:add')">新增</a-button>
<a-button v-if="hasPerm('user:delete')">删除</a-button>
hasPerm 是注册在 Vue 原型上的全局方法,老项目里的实现是去后端下发的 auth 列表里查。
我没有去改这些模板,只改了 hasPerm 的实现 ,让它去查 roleConfig:
js
// main.js
Vue.prototype.hasPerm = function (code) {
return checkPerm(store.getters.role, code)
}
于是 20 多个搬过来的列表页和弹框,v-if="hasPerm('user:add')" 这类判断一行都没动,全部继续工作。如果当时选择「把权限判断改成新写法」,那就是几十个文件的批量修改,还得逐个回归测试。
同名同签名的函数换掉内部实现,是重构里性价比最高的操作。 前提是原来那个函数的签名足够窄 ------ hasPerm(code) -> boolean 恰好很窄,它不关心权限是从哪来的。
反过来说,如果老项目里写的是 v-if="permissionList.includes('user:add')",把内部数据结构直接暴露在模板里,这次改造就得改几十个文件。这就是「一层薄封装」的价值:平时看着像脱裤子放屁,换实现的时候救命。
perms: ['*'] 也是同一个思路。现在三个角色在可见页面内都不做按钮级限制,但我没把 checkPerm 写成 return true,而是保留了权限码数组的形状:
js
export function checkPerm(role, code) {
const cfg = getRoleConfig(role)
if (!cfg) return false
if (cfg.perms.indexOf('*') > -1) return true
return cfg.perms.indexOf(code) > -1
}
哪天需求变成「安全管理员对用户管理只读」,把 '*' 换成具体权限码数组就行,代码不用动。留一个能力的入口但先不启用,比「以后再说」和「现在就做全」都划算。
六、角色从哪来:兼容两种返回
一个实际问题:后端返回的用户信息里,角色信息的形状不确定。可能是 username(这三个账号名固定),也可能是 roleCode,也可能是 roles 数组。
所以解析函数按优先级依次试:
js
export function resolveRole(userInfo) {
if (!userInfo) return null
// 1. 账号名直接命中
const username = userInfo.username || userInfo.userName || ''
if (ROLE_CONFIG[username]) return username
// 2. 从 roleCode / roles 里找
const codes = []
if (userInfo.roleCode) codes.push(...String(userInfo.roleCode).split(','))
if (Array.isArray(userInfo.roles)) {
userInfo.roles.forEach((r) => codes.push(typeof r === 'string' ? r : r && r.roleCode))
}
for (const c of codes) {
const role = ROLE_CODE_MAP[String(c).trim()]
if (role) return role
}
return null
}
username / userName 两种写法都取,roles 数组里的元素可能是字符串也可能是对象。这些不是瞎防的,是实际对接过程中遇到的,不同接口返回的形状真的不一样。
返回 null 表示「不是这三个角色之一」,这个返回值是有语义的,路由守卫靠它决定把人跳回来源系统,而不是跳登录页。区分「没登录」和「登录了但没资格」很重要 ------ 后者跳登录页会让用户陷入死循环:登录成功 → 被拦 → 跳登录页 → 已登录直接进 → 被拦。
七、什么情况下别照抄
不要删动态路由那套,如果:
- 角色需要运营自己加。 这是硬边界。哪怕现在只有三个角色,只要产品说过「以后可能让客户自己配角色」,就别删,删了再加回来比一直留着贵。
- 同一个角色在不同租户下看到的菜单不同。 多租户场景下菜单几乎必然是数据。
- 菜单项超过二三十个,且有层级。 写死的配置文件在这个规模下会变得难维护,不如让它回到数据库里。
- 权限粒度细到字段级。 那已经不是路由问题了,需要一套真正的权限系统。
我这个项目四条都不沾:三个固定角色、单租户、十来个 tab、按钮级粒度且当前全放开。
最值得记的三条
permissionList[0]这类取值全项目扫一遍,异常页那种「报错时才走到」的地方最容易漏,结果是报错页自己再报一次错。- 刷新白屏先定位在四层里的哪一层 :路由没注册(用
router.resolve().route.matched看,别看options.routes)/ 菜单接口返回空 / 转换丢了 component / 少了next({ ...to, replace: true })。 - 要删的话,保留
hasPerm的签名只换实现,老代码的模板一行都不用动。
「后端下发菜单 + 动态路由」不是最佳实践,它是一个用复杂度换灵活性的交易。划不划算,只看一件事:角色和菜单,是数据还是规则。