JeecgBoot 动态路由踩坑(Vue2):刷新白屏、permissionList[0] 报 undefined,以及我最后为什么全删了

手上一个 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 整个 module
  • router/index.js 从「静态白名单 + 动态注册」改成纯静态,+36 −253
  • v-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 表示「不是这三个角色之一」,这个返回值是有语义的,路由守卫靠它决定把人跳回来源系统,而不是跳登录页。区分「没登录」和「登录了但没资格」很重要 ------ 后者跳登录页会让用户陷入死循环:登录成功 → 被拦 → 跳登录页 → 已登录直接进 → 被拦。

七、什么情况下别照抄

不要删动态路由那套,如果:

  1. 角色需要运营自己加。 这是硬边界。哪怕现在只有三个角色,只要产品说过「以后可能让客户自己配角色」,就别删,删了再加回来比一直留着贵。
  2. 同一个角色在不同租户下看到的菜单不同。 多租户场景下菜单几乎必然是数据。
  3. 菜单项超过二三十个,且有层级。 写死的配置文件在这个规模下会变得难维护,不如让它回到数据库里。
  4. 权限粒度细到字段级。 那已经不是路由问题了,需要一套真正的权限系统。

我这个项目四条都不沾:三个固定角色、单租户、十来个 tab、按钮级粒度且当前全放开。

最值得记的三条

  1. permissionList[0] 这类取值全项目扫一遍,异常页那种「报错时才走到」的地方最容易漏,结果是报错页自己再报一次错。
  2. 刷新白屏先定位在四层里的哪一层 :路由没注册(用 router.resolve().route.matched 看,别看 options.routes)/ 菜单接口返回空 / 转换丢了 component / 少了 next({ ...to, replace: true })
  3. 要删的话,保留 hasPerm 的签名只换实现,老代码的模板一行都不用动。

「后端下发菜单 + 动态路由」不是最佳实践,它是一个用复杂度换灵活性的交易。划不划算,只看一件事:角色和菜单,是数据还是规则。

相关推荐
默_笙1 小时前
😋 我让爬虫终于看到了我的网站,后端同事说"这也行?"(上):SEO 与渲染模式
前端·javascript
前进的程序员2 小时前
Codex破局:前端组件秒级生成|提速React/Vue开发,可复用提示词+实战调试经验
前端·vue.js·react.js·codex
JunjunZ2 小时前
Vue 3 文件预览的通用设计与实现
前端·vue.js
七牛开发者3 小时前
解读 Cordis:dsh 一切皆插件背后的运行时设计
前端·javascript·后端
雷工笔记3 小时前
KingFusion驾驶舱-产能信息模块开发实战
javascript
小弥儿6 小时前
GPT Image 2 提示词库,把散落的生图提示词变成工程资产
开发语言·javascript·gpt·学习
楠楠子呀6 小时前
独立站聊天转化全链路:从二维码引流到数据复盘
java·javascript·数据仓库·python·c#·自动化·etl
智商偏低6 小时前
bindFramebuffer
前端·javascript·html
BioRunYiXue7 小时前
RACE技术全攻略:从全长克隆到靶基因验证
java·javascript·网络·人工智能·科技·算法·eclipse