Skip to content

按钮权限与 Vue3 入门(三) ​

本页对应 按钮权限与 Vue3 入门,共 6 段视频,约 47 分钟。

学习目标 ​

  • 能够说明选项式API与组合式API的适用场景与取舍,并在完整构建应用中选择组合式API编写组件。
  • 能够说明setup作为组合式API入口的执行时机(beforeCreate之前)与this不可用特性,并将模板所需变量或函数通过return暴露。
  • 能够使用reactive和ref创建对象、数组及简单类型的响应式数据,验证修改后视图更新,并说明reactive仅支持引用类型、ref在脚本中需通过.value读写而模板中自动解包。
  • 能够对比ref与reactive并合理选型,解释ref返回对象及.value机制、const不可重新赋值但可修改内部属性;能够使用computed派生数据并验证依赖更新重算、缓存特性及只读无副作用原则。
  • 能够完成退出登录状态清理:触发mutation、改写菜单渲染数据、清除user模块用户信息、重置menu模块并重置路由。

知识点 ​

这段守卫在解决什么问题 ​

这段代码放在 router.beforeEach 里,目标不是“简单判断登录”,而是把登录态、用户信息、权限列表、动态路由和左侧菜单串起来:有 token 时,按需拉取用户权限,生成可访问菜单,动态注册路由,最后把菜单数据提交到 Vuex。这里的“触发 mutation”指最后一行 store.commit('menu/setMenuList', filterRoutes),它通过 Vuex 的 mutation 修改菜单状态。

为什么需要:左侧菜单和动态路由都依赖用户权限,不能全部写死;同时 Vuex 要求修改 state 走 mutation,便于追踪状态变化。
怎么想:先判断 token,再判断用户信息是否已加载;没有加载才请求权限,避免每次路由跳转都重复请求。
怎么做:保留笔记代码,按“取权限 → 生成菜单权限 → 过滤动态路由 → addRoute → commit”顺序阅读。
易错点:不要直接写 store.state.menu.menuList = ...;mutation 应是同步提交;commit 的 payload 要和 mutation 定义一致。

javascript
router.beforeEach(async(to, from, next) => {
  const token = getCookie('park_token')
  // 有token
  if (token) {
    next()
    // 获取用户信息
    if (!store.state.user.profile.id) {
      // 1. 获取原始权限列表
      const permissions = await store.dispatch('user/getProfile')
      // 2. 获取菜单权限列表
      const menuPerms = getMenuList(permissions)
      console.log(menuPerms)
      // 3. 通过菜单权限过滤一级和二级
      const filterRoutes = getFilterRoutes(menuPerms, asyncRoutes)
      console.log(filterRoutes)
      // 4. addRoute动态添加
      filterRoutes.forEach(route => router.addRoute(route))
      // 5. 存入Vuex渲染左侧菜单
      store.commit('menu/setMenuList', filterRoutes)
    }
  } else {
    // 没有token
    if (WHITE_LIST.includes(to.path)) {
      next()
    } else {
      next('/login')
    }
  }
})

有 token 时:先判断用户信息是否已加载 ​

为什么需要:token 只能说明“登录过”,并不等于用户信息和权限已经拿到;而权限请求不应在每次路由跳转时重复执行。
怎么想:用 store.state.user.profile.id 作为“是否已加载用户信息”的判断条件,没有 id 才进入权限初始化。
怎么做:if (!store.state.user.profile.id) { ... } 包住后续逻辑。
易错点:profile 的结构要与 Vuex 中定义一致;await store.dispatch('user/getProfile') 的 await 不能漏,否则 permissions 可能是 Promise,后续 getMenuList 会拿到错误数据。

拉取权限并生成菜单权限 ​

为什么需要:后端返回的权限列表可能包含按钮权限、接口权限等,菜单只取其中一部分,所以要先转换。
怎么想:先拿到原始 permissions,再用 getMenuList(permissions) 转成菜单权限 menuPerms。
怎么做:代码中先 console.log(menuPerms) 确认结构,再进入路由过滤。
易错点:getMenuList 的入参、返回值结构要和项目约定一致;不确定时就像转写稿里讲 ref 时强调的那样——先打印出来看,不要凭感觉猜。转写稿里的“赛大”应写作 setup,“绕函数”应理解为 ref 函数;这些是组合式 API 内容,本节只迁移其“打印确认结构、按框架入口修改状态”的调试思路。

过滤动态路由并 addRoute ​

为什么需要:asyncRoutes 是全部动态路由,用户不一定都有权限,必须按 menuPerms 过滤。
怎么想:getFilterRoutes(menuPerms, asyncRoutes) 得到当前用户可访问的一级、二级路由;再用 router.addRoute(route) 注册。
怎么做:filterRoutes.forEach(route => router.addRoute(route))。
易错点:权限字段和路由配置字段要能匹配;filterRoutes 为空时要能接受;重复进入守卫时要注意避免重复添加路由。另外,笔记代码把 next() 放在拉取权限之前,阅读时要意识到它是“先放行”;如果项目要求首次导航就命中新加路由,需要检查这个顺序是否满足需求。

提交 mutation 存入 Vuex 渲染左侧菜单 ​

为什么需要:左侧菜单组件通常从 Vuex 读取菜单数据,commit 后状态变化可被响应式使用,也方便调试。
怎么想:filterRoutes 既是动态路由来源,也是菜单来源;注册路由后,把它提交给 menu/setMenuList。
怎么做:store.commit('menu/setMenuList', filterRoutes)。
易错点:mutation 名要带命名空间且与定义一致;mutation 内应同步修改 state;不要绕过 mutation 直接改 state;提交的数据结构要与菜单组件读取的字段一致。调试时依次打印 permissions、menuPerms、filterRoutes,确认每一步的数据形状,再定位是权限转换、路由过滤还是 mutation 提交的问题。

一、先看清这段代码在做什么 ​

本节要改的地方只有一处,就是组件里 computed 中的 routes:

vue
computed: {
  routes() {
    // return this.$router.options.routes
    return this.$store.state.menu.menuList
  }
}

这个 routes 是给模板里的菜单渲染用的,模板里大概率是 v-for="item in routes" 这样循环出菜单项。改动前,它返回的是 this.$router.options.routes,也就是路由配置文件里那份静态的路由表;改动后,注释掉了原来那行,改成返回 this.$store.state.menu.menuList,即 Vuex 里 menu 模块的 menuList。

所以这一节真正改的不是“写法”,而是菜单数据的来源:从路由配置搬到了 store 的状态里。代码只有两行,但要理解为什么这么改、改完怎么验证,得把计算属性这件事说清楚。

二、为什么要改:菜单数据不该再写死在路由表里 ​

为什么需要:this.$router.options.routes 是创建 router 实例时传进去的那份配置,它在应用启动时就固定了。用它渲染菜单,等于“路由表里有什么,菜单就显示什么”。一旦菜单需要按登录用户、按权限、按后端下发的数据变化,静态路由表就撑不住了——你不可能在运行时去改 options.routes 来刷新菜单。

怎么想:菜单本质上是“一份数据”,不是“一份配置”。数据就应该放在状态容器里统一管理,谁需要谁去取。store 里的 menu 模块正好承担这个角色:登录后把菜单数据放进 state.menu.menuList,组件只管从 store 读,不关心它是怎么来的。这样菜单的“生产方”和“消费方”就解耦了。

怎么做:把原来读路由表的那行注释掉(保留着方便回退和对比),改成读 store。注意 state.menu.menuList 这个路径要和 store 里模块的注册方式对得上——如果 menu 是带命名空间的模块,state.menu 这一层仍然成立,因为模块的 state 都会挂到根 state 下。

易错点:路径写错是最常见的,比如模块实际叫 menus 你写了 menu,或者 store 里根本没有 menu 这个模块,此时 this.$store.state.menu 是 undefined,再取 .menuList 就直接报错。改完先在控制台打印一下 this.$store.state.menu,确认结构再往下写。

三、为什么用 computed,而不是 data 或 method ​

为什么需要:这里要的是“store 变了,菜单跟着变”。用 data 存一份拷贝,store 更新后组件不会知道;用 method 每次渲染都重新取,虽然结果对,但失去了缓存。

怎么想:转写稿里对计算属性的定义说得很清楚——它必须有一个前置依赖的已存在属性,基于一定的计算公式得到一个新的数据,并且保持这个公式一直在,依赖的数据发生变化时会重新计算。这里的依赖就是 this.$store.state.menu.menuList,公式就是“原样返回”。转写稿还提到一种用法叫映射计算:把 store 里的数据映射到组件上,比如从 store 里取 token,写一个计算属性直接 return this.$store.state.xxx,看起来没有“计算”,但它同样是计算属性,因为它建立了依赖关系。本节这个 routes 就属于映射计算,它没有副作用,是纯数据层面的映射。

怎么做:保持 computed 不动,只换 return 的内容。因为 store 的 state 是响应式的,menuList 一变,routes 会重新求值,模板自动更新,不需要手动刷新。

易错点:一是别在 computed 里做副作用。转写稿强调计算属性“只做数据转换”,不应该发请求、不该改 DOM、不该改其他变量,这些都属于副作用,应该放到生命周期或 watch 里。二是 computed 默认只读,别去给 this.routes 赋值,想改菜单就去改 store,而且要走 mutation,不要直接改 state。

四、缓存特性在这里意味着什么 ​

转写稿专门演示过:计算属性第一次执行后会把结果缓存下来,模板里连续用三次,计算函数只执行一次;只有依赖变化时才重新执行。本节这个场景里,routes 在模板里可能被循环、被判断多次使用,有缓存就不会重复取值。

调试时可以利用这一点:在 routes() 里 console.log 一句,刷新页面看打印几次。如果每次渲染都打印,说明你可能把它写成了 method;如果依赖变化后不打印,说明依赖没有真正建立起来——比如你取的不是响应式数据,或者取的是某个普通变量。

五、改完不生效,按这个顺序排查 ​

  1. 先打印 this.$store.state.menu.menuList,确认有数据、结构是数组。转写稿里反复讲的一个思路就是:不明白它长什么样,就把它打出来看看。
  2. 确认模板里用的确实是 routes,别是别的名字。转写稿里提到过一种警告:模板里用了某个属性,但实例上根本没有定义——名字写错就是这个现象。
  3. 确认 store 模块已注册、命名路径正确。
  4. 确认数据变化是通过 mutation 提交的,直接改 state 在某些情况下视图不会跟着更新。

六、易错点小结 ​

  • computed 里忘记 return,返回 undefined,模板循环直接报错。
  • routes 与 data、methods 里的名字重名,后者会覆盖前者。
  • 想“顺手”在 routes() 里过滤或排序,可以,但要保证是纯计算;如果过滤依赖了别的响应式变量,那个变量也要是响应式的,否则不会重算。
  • 把 routes 当可写属性去 push,既违反 computed 只读,也绕过了 Vuex 的修改流程。
  • 回退时别忘了把那行注释恢复回来对比,否则排查“到底改了什么”会很费劲。

本节目标与代码位置 ​

本节讲 user 模块中的“清除用户信息”。给定代码是一个典型的按 state 修改用户状态的方法:

javascript
clearUserInfo(state) {
    // 清除Token
    state.token = ''
    state.profile = {}
    removeCookie(TOKEN_KEY)
}

从写法看,clearUserInfo(state) 的第一个参数是状态对象,函数内部没有返回值,而是直接修改 state 并调用 removeCookie。因此理解这段代码要抓住三件事:清内存状态、清持久化 Cookie、保证清理顺序和调用位置正确。转写稿主要围绕组合式 API 展开,但其中反复强调“修改响应式数据要落到具体属性上”,这一点正好对应这里的 state.token 和 state.profile,而不是把 state 整体覆盖掉。

为什么需要清除用户信息 ​

为什么需要:用户登录后,token 和 profile 往往同时存在于状态层和 Cookie 中。如果退出登录或切换账号时只跳转页面,不清理这些数据,下一次请求可能继续携带旧 token,页面也可能短暂显示上一个用户的信息。

怎么想:清除用户信息不是“把页面关掉”,而是把用户身份相关的数据源全部复位。代码里涉及两个数据源:state 中的内存状态,以及 Cookie 中的持久化令牌。

怎么做:在 clearUserInfo 中先处理 state,再调用 removeCookie(TOKEN_KEY)。这样函数执行完后,内存和 Cookie 都回到未登录状态。

易错点:不要写成 state = {}。state 是传入的响应式对象,整体重新赋值可能切断响应式引用;应修改它内部的属性。也不要把 state.profile 清成 null,如果模板或组件按对象读取 profile.xxx,null 会引发访问错误,置为 {} 更稳。

清除 Token:state.token = '' ​

为什么需要:token 是请求身份凭证。退出登录后如果它还留在 state 里,请求拦截器可能继续把旧 token 带到后端。

怎么想:这里不是删除 token 属性,而是把它复位成空字符串。空字符串表示“当前没有令牌”,同时保留 token 字段,避免其他代码读取时出现 undefined。

怎么做:执行 state.token = ''。如果项目里有请求拦截器,它通常会判断 token 是否为空,再决定是否加认证头。

易错点:不要只清 Cookie 不清 state.token,否则内存里仍有旧令牌;也不要只清 state.token 不清 Cookie,否则刷新页面后可能又从 Cookie 恢复登录态。

清除用户资料:state.profile = {} ​

为什么需要:profile 通常保存昵称、头像、角色等用户资料。退出后如果还保留,页面头部可能继续显示旧用户信息。

怎么想:资料对象用空对象 {} 复位,表示“没有用户资料”,但结构仍然是对象,模板读取时不会因为 null 或 undefined 直接报错。

怎么做:执行 state.profile = {}。在 Vuex/Pinia 这类状态管理中,给已有属性赋新对象通常能触发响应式更新。

易错点:不要用 delete state.profile 代替置空。删除属性后,其他组件如果直接访问 state.profile.avatar 就可能报错;置为 {} 更安全。也不要只重置部分字段而漏掉整个资料对象,具体要按项目实际字段决定。

为什么需要:Cookie 是持久化存储,页面刷新后状态层会重新初始化,但 Cookie 里的 token 可能还在。只清 state 不清 Cookie,等于“内存清了、磁盘没清”。

怎么想:TOKEN_KEY 是令牌在 Cookie 中的键名,必须和写入时使用的键名一致。removeCookie 是项目封装的删除方法,调用它才算完成持久化清理。

怎么做:在 state.token 和 state.profile 复位后调用 removeCookie(TOKEN_KEY)。顺序上先清内存再清 Cookie,可以避免中间状态被其他逻辑读取。

易错点:TOKEN_KEY 不要手写成普通字符串,否则容易和写入时的常量不一致;删除 Cookie 时如果封装方法没有处理 path、domain,可能出现“代码执行了但 Cookie 还在”的情况,需要检查 removeCookie 的实现是否与写入时匹配。

调试与调用检查 ​

为什么需要:这段代码很短,但清理不彻底时问题往往在刷新后才暴露,所以要做一次调用后的检查。

怎么想:验证三件事:state.token 是否为空字符串,state.profile 是否为空对象,Cookie 中对应键是否消失。

怎么做:在调用 clearUserInfo 后打印 state.token、state.profile,并在浏览器开发者工具中查看 Cookie。若 Cookie 仍在,优先检查 TOKEN_KEY 和 removeCookie 的路径配置。

易错点:不要只验证页面跳转成功就认为清理完成;跳转成功不等于状态和 Cookie 都清干净。另外,本段代码只处理 token、profile 和对应 Cookie,如果项目还有其他用户态缓存,应按项目实际情况同步处理,不能凭这段代码推断。

一、这段代码要解决什么问题 ​

javascript
import { contantsRoutes, resetRouter } from '@/router'
export default {
  mutations: {
    resetMenu(state) {
      // 重置左侧菜单
      state.menuList = contantsRoutes
      // 重置路由系统
      resetRouter()
    }
  }
}

为什么需要:菜单和路由往往是一组联动的状态。用户退出登录、切换角色或权限变化后,如果只清菜单不重置路由,可能出现“左侧菜单没了,但旧路由还能访问”的不一致;如果只重置路由不恢复菜单,又可能出现页面能进但菜单为空。因此需要一个统一的“重置入口”。

怎么想:重置逻辑不要散落在组件里,而应放进 menu 模块的 mutation 中。mutation 的职责是集中修改状态,这样在 Vue Devtools 里能看到一次明确的 resetMenu 提交记录,排查时知道是谁改了 state.menuList。

怎么做:在 resetMenu(state) 中做两件事:先把左侧菜单恢复为常量路由 contantsRoutes,再调用 resetRouter() 重置路由系统。两件事放在同一个 mutation 里,保证调用方只提交一次。

易错点:不要只写 state.menuList = contantsRoutes 而漏掉 resetRouter();也不要在组件里直接 state.menuList = ...,那样绕过 mutation,状态变化不可追踪。还要确认 contantsRoutes 的导出名与源模块完全一致,不要想当然改成 constantsRoutes,除非源模块确实如此。

二、重置左侧菜单:state.menuList = contantsRoutes ​

为什么需要:menuList 是驱动左侧菜单渲染的数据。重置时把它赋回常量路由,相当于恢复“基础菜单集合”,避免上一次登录或上一次权限留下的菜单残留。

怎么想:这里不是“清空数组”,而是“恢复默认常量”。所以直接赋值 contantsRoutes 是最直接的写法。但要注意,这是引用赋值:如果后续有人直接修改 state.menuList 里的对象,可能影响常量本身。若项目需要按权限过滤菜单,应生成新数组,而不是在原常量上改。

怎么做:保留笔记中的赋值语句,并在调用重置流程前确认 state.menuList 当前结构。调试时可以临时打印:

javascript
resetMenu(state) {
  console.log('before menuList', state.menuList)
  state.menuList = contantsRoutes
  console.log('after menuList', state.menuList)
  resetRouter()
}

转写稿里反复强调“不清楚就打印出来看看”,这里同样适用:先确认赋值前后菜单结构是否符合预期。

易错点:一是拼写错误导致导入失败;二是把常量路由直接交给业务代码随意修改;三是误以为赋值后页面一定立刻更新,实际还要确认组件是否响应了 menuList。

三、重置路由系统:resetRouter() ​

为什么需要:路由系统里可能已经动态添加过权限路由。退出或切换角色后,如果不移除旧路由,旧权限可能仍然可访问,形成状态残留。

怎么想:菜单重置和路由重置是两件事,不能互相替代。resetRouter() 负责路由侧清理,state.menuList = contantsRoutes 负责菜单侧恢复。二者放在同一个 mutation 中,调用方只需提交 resetMenu。

怎么做:确认 resetRouter 从 @/router 正确导入,并在菜单赋值后调用。调试时可在调用前后打印标记:

javascript
console.log('resetRouter before')
resetRouter()
console.log('resetRouter after')

如果控制台先报模块导入错误,应先检查 @/router 是否导出 resetRouter,而不是继续怀疑菜单逻辑。

易错点:resetRouter() 如果内部有异步操作,mutation 本身应保持同步,不要把请求塞进 mutation;调用重置后若当前页面仍停留在旧路由,需要按项目现有流程处理跳转,不能想当然认为路由会自动回到首页;重复调用时还要确认 resetRouter 是否可重复执行。

四、模块导出、调用与调试 ​

为什么需要:这段代码导出的是 menu 模块对象,只有通过 commit('resetMenu') 触发 mutation,重置逻辑才会执行。若命名空间或提交名写错,代码看着对但不会生效。

怎么想:先确认模块是否开启命名空间,再确认提交名。转写稿里提到“执行时机”和“自动执行”的排查思路,这里对应的是:先确认 resetMenu 是否被提交,再确认 resetRouter 是否被调用。

怎么做:在需要清理菜单和路由的流程中提交该 mutation;打开 Vue Devtools 查看 mutation 记录;在 resetMenu 内打日志;刷新页面验证菜单和路由是否都恢复。

易错点:提交名写成 menu/resetMenu 或 resetMenu 要与命名空间匹配;mutation 内不要写异步请求;不要只验证菜单显示而忽略路由访问;调试日志记得在上线前清理。

退出登录为什么要重置路由 ​

为什么需要:退出登录不只是清 token。若只清登录凭证,不清菜单和动态路由,下一个账号登录后可能还看到上一个账号的菜单入口,甚至访问到不该出现的路由。笔记在 logout() 中增加重置菜单提交,正是为了解决“退出后状态残留”。

怎么想:把登录态拆成两部分看:凭证是“能不能进”,菜单/路由是“进来后能看到什么”。退出时两者都要回到初始状态。menu/resetMenu 从命名看是 menu 模块下的重置 mutation,调用点放在退出登录方法里,保证退出动作和菜单清理同步发生。

怎么做:在原有退出逻辑中追加提交,不把清理逻辑散落在组件里。笔记给出的改动只有一行:

javascript
logout() {
  // 增加代码
  this.$store.commit('menu/resetMenu')
}

易错点:不要只清 token 而漏掉菜单;不要写成普通 commit('resetMenu') 而漏掉 menu/ 命名空间;不要假设 resetMenu 内部一定删了动态路由——笔记未给出内部实现,本文不虚构,调试时以退出后菜单是否清空、换账号是否残留为准。

在 logout 中提交 resetMenu ​

为什么需要:退出登录是用户主动结束会话的入口,放在这里提交 resetMenu,能保证“退出”与“重置菜单”是一个动作,避免用户点退出后仍停留在旧菜单状态。

怎么想:组件只负责触发退出动作,具体重置由 Vuex mutation 完成。this.$store.commit('menu/resetMenu') 表示提交 menu 命名空间模块中的 resetMenu。这样组件不直接操作菜单数据结构,职责更清楚。

怎么做:保留原有退出逻辑,在合适位置追加该行;若原方法后续还有跳转登录页等动作,应保证重置提交在跳转前执行。笔记只展示了这一行新增代码,未展示完整退出流程,因此不要补写不存在的跳转代码。

易错点:commit 拼写、模块名 menu、mutation 名 resetMenu 三者要一致;若项目使用 mapMutations 或封装 action,不能照抄成不匹配的写法;不要在该行前后随意加未验证的 router.removeRoute 等代码。

权限点对照表与菜单/路由的关系 ​

为什么需要:退出时重置的是菜单/路由状态,而菜单能否显示、按钮能否出现,通常由权限点控制。笔记补充的对照表给出了“功能—权限点”的映射,便于核对菜单和按钮权限是否一致。

怎么想:权限点字符串是前后端约定值,不能自行改写前缀。表中既有 park:,也有 property:、parking:、pole:、sys:,说明不同业务模块前缀不同。重置菜单后,下次登录应重新按当前账号权限生成可见菜单。

怎么做:把对照表作为权限配置的参考,保留原值:

xml
添加/编辑楼宇	park:building:add_edit
楼宇管理	park:building:list
删除楼宇	park:building:remove
添加/编辑企业	park:enterprise:add_edit
企业管理	park:enterprise:list
查看企业详情	park:enterprise:query
删除企业	park:enterprise:remove
添加账单	park:propertyFee:add
物业费管理	property:propertyFee:list
查看账单详情	park:propertyFee:query
删除账单	park:propertyFee:remove
添加、续签、退租合同	park:rent:add_surrender
删除合同	park:rent:remove
添加/编辑区域	parking:area:add_edit
区域管理	parking:area:list
删除区域	parking:area:remove
添加/编辑月卡	parking:card:add_edit
月卡管理	parking:card:list
查看月卡	parking:card:query
续费月卡	parking:card:recharge
删除/批量删除月卡	parking:card:remove
停车缴费管理	parking:payment:list
添加或编辑规则	parking:rule:add_edit
计费规则管理	parking:rule:list
删除规则	parking:rule:remove
添加或者编辑一体杆	pole:info:add_edit
一体杆管理	pole:info:list
删除或批量删除一体杆	pole:info:remove
告警记录	pole:warning:list
查看详情	pole:warning:query
删除记录	pole:warning:remove
派单	pole:warning:send
添加/编辑角色	sys:role:add_edit
角色管理	sys:role:list
删除角色	sys:role:remove
添加/编辑员工	sys:user:add_edit
员工管理	sys:user:list
删除员工	sys:user:remove
重置密码	sys:user:resetPwd

易错点:不要因为看到 park:propertyFee:add 和 property:propertyFee:list 就擅自统一前缀,表里原样是什么就用什么;不要用中文名称当权限判断;对照表不是路由实现,不能替代 resetMenu。

验证与易错点 ​

为什么需要:这类改动很容易“代码加上了但页面没变化”。必须验证退出后菜单是否清空、换低权限账号登录是否还残留旧菜单。

怎么想:调试时先看提交是否执行,再看菜单状态是否变化,最后换账号看动态路由是否重新生成。笔记和转写稿没有给出 resetMenu 内部实现,因此不能虚构“它一定删除了某条路由”的细节,只能按页面表现验证。

怎么做:退出登录后观察菜单区域;重新登录不同权限账号,核对上表权限点对应的菜单/按钮是否按当前账号出现;若仍残留,检查 commit 的模块名和 mutation 名是否匹配。

易错点:只测同一账号会掩盖问题;只看控制台不看页面会漏掉菜单残留;不要把无关的 Vue3 组合式 API 内容硬套到本节路由重置中。本节改动点很小,关键是调用位置正确、命名空间正确、退出后状态确实回到初始。

代码示例 ​

① 路由守卫中动态添加权限路由(permission.js)

js
// ---------- router/index.js ----------
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)

// 静态路由:无需权限,登录后人人可访问
export const contantsRoutes = [
  { path: '/login', component: () => import('@/views/login') },
  { path: '/dashboard', component: () => import('@/views/dashboard') }
]

// 动态路由:需要按权限过滤后再 addRoute
export const asyncRoutes = [
  {
    path: '/park',
    component: () => import('@/layout'),
    children: [
      {
        path: 'building',
        meta: { title: '楼宇管理', perms: ['park:building:list'] },
        component: () => import('@/views/park/building')
      }
    ]
  }
]

const createRouter = () => new VueRouter({ routes: contantsRoutes })
const router = createRouter()

// 重置路由系统(Vue Router 3 通过替换 matcher 实现)
export function resetRouter() {
  const newRouter = createRouter()
  router.matcher = newRouter.matcher
}

export default router

// ---------- permission.js ----------
import router, { asyncRoutes } from '@/router'
import store from '@/store'
import { getCookie } from '@/utils/auth'
import { getMenuList, getFilterRoutes } from '@/utils/permission'

const WHITE_LIST = ['/login', '/404']

router.beforeEach(async (to, from, next) => {
  const token = getCookie('park_token')
  // 有token
  if (token) {
    next()
    // 获取用户信息
    if (!store.state.user.profile.id) {
      // 1. 获取原始权限列表
      const permissions = await store.dispatch('user/getProfile')
      // 2. 获取菜单权限列表
      const menuPerms = getMenuList(permissions)
      console.log(menuPerms)
      // 3. 通过菜单权限过滤一级和二级
      const filterRoutes = getFilterRoutes(menuPerms, asyncRoutes)
      console.log(filterRoutes)
      // 4. addRoute动态添加
      filterRoutes.forEach(route => router.addRoute(route))
      // 5. 存入Vuex渲染左侧菜单
      store.commit('menu/setMenuList', filterRoutes)
    }
  } else {
    // 没有token
    if (WHITE_LIST.includes(to.path)) {
      next()
    } else {
      next('/login')
    }
  }
})
  • 关键行:if (!store.state.user.profile.id) 是幂等开关——有 profile.id 说明权限已拉过,跳过,避免每次跳转都重复 addRoute(重复添加会报警告并产生重复菜单)。
  • 关键行:filterRoutes.forEach(route => router.addRoute(route)) 一次性把过滤后的动态路由注册进路由系统,之后 $router 才认得这些 path。
  • 关键行:store.commit('menu/setMenuList', filterRoutes) 只存“过滤后的路由”,左侧菜单直接拿它渲染,菜单与路由天然一致。
  • 易错点:笔记中 next() 写在 await 之前,首次直接访问动态路由(如刷新 /park/building)时,addRoute 发生在导航确认之后,会白屏/404。稳妥写法是异步逻辑跑完后 next({ ...to, replace: true }) 重新触发一次匹配。
  • 易错点:getMenuList / getFilterRoutes 是项目内的工具函数(把 park:building:list 这类权限点映射成路由标识,再按标识递归过滤 asyncRoutes 的一级和二级),需结合权限点对照表实现,不在本段范围内。

② 退出登录时重置菜单与路由(user / menu 模块)

js
// ---------- store/modules/user.js ----------
import { getCookie, removeCookie } from '@/utils/auth'
const TOKEN_KEY = 'park_token'

export default {
  namespaced: true,
  state: () => ({
    token: getCookie(TOKEN_KEY),
    profile: {}
  }),
  mutations: {
    clearUserInfo(state) {
      // 清除Token
      state.token = ''
      state.profile = {}
      removeCookie(TOKEN_KEY)
    }
  }
}

// ---------- store/modules/menu.js ----------
import { contantsRoutes, resetRouter } from '@/router'

export default {
  namespaced: true,
  state: () => ({
    menuList: contantsRoutes
  }),
  mutations: {
    setMenuList(state, list) {
      state.menuList = list
    },
    resetMenu(state) {
      // 重置左侧菜单
      state.menuList = contantsRoutes
      // 重置路由系统
      resetRouter()
    }
  }
}

// ---------- 组件中的退出登录 ----------
export default {
  methods: {
    logout() {
      // 增加代码
      this.$store.commit('menu/resetMenu')
      this.$store.commit('user/clearUserInfo')
      this.$router.push('/login')
    }
  }
}
  • 关键行:state.menuList = contantsRoutes 是回到“静态路由”而不是空数组,否则重置后连登录页/首页的菜单都没了。
  • 关键行:resetRouter() 必须放在 resetMenu 里一起做——只清 Vuex 不清路由,换账号登录时旧账号的动态路由还留在路由表里,等于越权可访问。
  • 关键行:clearUserInfo 里 state.profile = {} 不能漏,否则 profile.id 还有值,下次登录 !store.state.user.profile.id 为 false,权限不会再拉取一次。
  • 易错点:Vue Router 3 用 router.matcher = newRouter.matcher 重置;Vue Router 4(Vue3)没有 matcher,要遍历 router.getRoutes() 逐个 removeRoute。

③ 菜单渲染数据改写为 Vuex 数据(选项式 → 组合式)

vue
<!-- 选项式写法(Vue2 / Vue3 均可) -->
<script>
export default {
  computed: {
    routes() {
      // return this.$router.options.routes
      return this.$store.state.menu.menuList
    }
  }
}
</script>
vue
<!-- 组合式写法(Vue3 <script setup>) -->
<script setup>
import { computed } from 'vue'
import { useStore } from 'vuex'

const store = useStore()

// computed 传入回调函数,内部 return 计算结果
const routes = computed(() => store.state.menu.menuList)
</script>

<template>
  <el-menu :default-active="$route.path" router>
    <template v-for="route in routes" :key="route.path">
      <el-menu-item v-if="!route.children" :index="route.path">
        {{ route.meta && route.meta.title }}
      </el-menu-item>
      <el-submenu v-else :index="route.path">
        <template #title>{{ route.meta && route.meta.title }}</template>
        <el-menu-item
          v-for="child in route.children"
          :key="child.path"
          :index="`${route.path}/${child.path}`"
        >
          {{ child.meta.title }}
        </el-menu-item>
      </el-submenu>
    </template>
  </el-menu>
</template>
  • 关键行:this.$router.options.routes 只是创建路由时的初始配置,addRoute 动态加进去的路由不会出现在这里,所以必须换成 Vuex 里的 menuList,否则左侧菜单永远是静态那几条。
  • 关键行:组合式里没有 this,取 store 要用 useStore();computed() 的回调必须 return,不 return 得到的是 undefined。
  • 易错点:computed 是只读的,直接 routes.value = xxx 会报错,改菜单只能通过 commit('menu/setMenuList')。
  • 易错点:模板里 route.meta.title 要防空(route.meta && route.meta.title),静态路由常常没写 meta。

常见错误 ​

  1. 登录后首次进入菜单页白屏或 404,刷新一下又正常 现象:跳转目标页面匹配不到,控制台报 No match found。 原因:beforeEach 里先 next() 放行,导航已经结束才执行 addRoute,目标路由此时还没注册。 修法:把 next() 挪到 addRoute 和 commit 之后,并用 next({ ...to, replace: true }) 重新触发一次导航,让新路由参与匹配。

  2. 切换账号后,新账号仍能看到上一个账号的菜单和页面 现象:低权限账号登录,左侧菜单少了,但直接输 URL 还能进。 原因:只清了 token 和 profile,addRoute 加进去的路由还留在 router 实例里。 修法:logout 里同时 commit('menu/resetMenu'),resetMenu 中既重置 menuList 为 constantRoutes,也调用 resetRouter() 真正移除动态路由。

  3. 重复登录 / 多次进入后路由重复、菜单项重复出现 现象:菜单出现两份,或同名路由告警。 原因:每次 beforeEach 都无条件执行 addRoute,没有用 profile.id 之类的标记做“只执行一次”的守卫。 修法:用 if (!store.state.user.profile.id) 包裹整段拉取和添加逻辑;或在添加前先 resetRouter()。

  4. 重置路由后连首页、404 都进不去 现象:退出登录后所有地址都跳 login 或空白。 原因:resetRouter 只保留了不含兜底路由的常量表,通配 404 也没重新挂。 修法:constantRoutes 里必须包含登录页、首页、404 兜底,重置时把它们重新 addRoute 回去;removeRoute 只删动态部分,别整体重建 matcher 后忘了常量。

  5. Vue3 的 setup 里写 this.$router / this.$store,报 undefined 现象:Cannot read properties of undefined。 原因:setup 在 beforeCreate 之前执行,此时 this 不是组件实例,拿不到任何实例属性。 修法:改用 useRouter()、useStore()、useRoute() 从模块导入,别在 setup 里碰 this。

  6. 脚本里改 ref 忘了 .value,视图不更新;或模板里多写了 .value 现象:赋值后页面没变,或者渲染出 [object Object]。 原因:ref 返回的是包装对象,脚本侧访问必须走 .value,模板侧由框架自动解套。 修法:脚本里读写一律加 .value,模板里一律不加;拿不准就 console.log 打印看结构。

  7. 解构 reactive 或 reactive 传简单类型,响应性丢失 现象:const { name } = reactive({ name: 'x' }) 改 name 页面不动;reactive(0) 直接告警。 原因:解构出的是普通值,脱离了代理;reactive 只接受引用类型。 修法:简单类型用 ref,需要解构时用 toRefs,或者干脆全项目统一用 ref。

  8. computed 里发请求 / 改 DOM / 改别的状态,导致请求反复发出 现象:接口被调多次,甚至死循环。 原因:computed 应保持纯计算,依赖一变就重算,副作用会被反复执行。 修法:异步和副作用交给 watch 或生命周期钩子,computed 只做数据转换并 return 结果。

练习 ​

  1. 把笔记里的 beforeEach 改写成不会出现“首次白屏”的版本,并用注释说明每一步为什么放在 next 之前。额外回答:为什么不能简单写成 next() 后加 router.addRoute() 再 next() 第二次?

  2. 实现一个 resetRouter:要求能真正移除所有动态添加的路由,同时保证 constantRoutes(含 404 兜底)在重置后依然可用。给出两种写法(removeRoute 逐个删 / 记录 addRoute 返回的移除函数),并说明各自适用场景。

  3. 用组合式 API 重写笔记里的菜单渲染:在 setup 中用 useStore 取出 menuList,写成 computed 返回给模板。要求不使用 this,并说明这里用 computed 而不是普通变量的理由。

  4. 用 ref 和 reactive 各实现一次“列表过滤”:源数据 [1,2,3,4,5,6],过滤出大于 3 的项。写完后回答:① 脚本里改数据哪里必须加 .value;② 若把 reactive 的对象解构使用会发生什么,怎么修。

  5. 线上问题排查:某后台“退出登录后换低权限账号登录,仍可直接输入 URL 访问高权限页面”。请写出你的排查顺序(至少 3 步),并说明最终定位在哪个环节、如何验证修复。

课堂画面 ​

课堂画面 01

课堂画面 02

课堂画面 03

课堂画面 04

课堂画面 05

课堂画面 06

原视频对照 ​

#视频时长
1Vue3-两种风格的取舍07:14
2Vue3-组合式API入口-setup06:33
3Vue3组合式API-reactive06:18
4Vue3组合式API-ref05:39
5ref对比reactive06:19
6Vue3组合式API-computed15:18

小结 ​

  1. 动态路由的三件事必须成套出现:拉权限 → addRoute → commit 菜单;少任何一步都会表现为“菜单有但进不去”或“能进但菜单空”。
  2. next() 是导航的放行闸门,任何“导航前必须完成”的准备工作都要排在它前面,必要时用 next({ ...to, replace: true }) 重走一次。
  3. 有“添加”就必须有“移除”:addRoute 的对称操作是 removeRoute/resetRouter,登录态的清理要把 token、profile、路由、菜单四样一起清。
  4. beforeEach 里的初始化逻辑一定要有幂等守卫(如 profile.id 判空),否则刷新、重复登录都会让副作用叠加。
  5. Vue3 组合式 API 的入口是 setup,它比 beforeCreate 还早,this 不可用,一切依赖都靠显式导入的 useXxx 获取。
  6. ref 与 reactive 的差异本质是“包装对象 vs 代理对象”:脚本侧 .value、模板侧自动解套,这条规则记牢能省掉大半调试时间。
  7. computed 的定位是“带缓存的纯派生数据”,缓存是性能红利也是行为约束——别在里面做请求、改 DOM、改其他状态。
  8. 遇到“为什么必须这样写”的疑惑,最快的手段是把返回值 console.log 出来看结构,比背结论可靠得多。