Skip to content

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

本页对应 按钮权限与 Vue3 入门,共 6 段视频,约 1 小时 15 分。

学习目标 ​

  • 能使用 render 函数获取插槽并实现基于按钮权限标识的高阶组件,完成有权限显示、无权限不渲染的验证。
  • 能说明业务模块互相依赖、业务状态变化对显示与操作按钮的影响,并复述车辆入场/出场、告警派单等典型数据流转流程。
  • 能概述 Vue3 在响应式底层 API、TypeScript 支持、组合式 API 和工具链方面的主要变化及学习必要性。
  • 能使用 create-vue 创建 Vue3 项目,并按需选择配置项完成依赖安装与启动。
  • 能识别 Vue3 项目的关键文件,说明 vite.config.js、package.json、main.js、App.vue 的作用及入口挂载流程。
  • 能分别用选项式 API 和组合式 API 实现同一个自增案例,并对比二者在代码组织与开发思想上的异同。

知识点 ​

本节要解决什么问题 ​

本节笔记的核心是:项目初始化时,先把不依赖权限判断的静态路由表注册进 Vue Router。代码里 routes 里放着登录页、404、工作台、若干隐藏的新增/详情页,以及 Layout 布局路由。初始化只做一件事:用 createRouter() 生成 router 实例,把这份静态表装进去;后续动态权限路由如何注入,本节没有展开,不能凭空补。

转写稿主要讲按钮权限高阶组件、业务状态流转和 Vue3 入门,并不包含本节路由初始化的推导与调试。因此本节不把无关内容硬塞进来;只借用转写稿反复强调的验证习惯:改完先跑起来,用不同路径和刷新去验证,而不是只看代码。转写稿中“re函数、pros、VBS”等应分别理解为 render 函数、props、Vuex,但它们属于按钮权限方案,不是本节路由内容。

静态路由表:先把公共页面装进去 ​

为什么需要:登录页、404、工作台、隐藏的新增页这些页面不依赖用户权限,项目一启动就应该能匹配到。如果等权限接口回来再注册,首屏和登录跳转容易空白或 404。

怎么想:把“所有用户都可能访问、与权限无关”的路由先抽成 routes 数组;需要权限的动态菜单路由后续再处理。本节标题说的“只处理静态路由表”,就是初始化阶段不碰动态权限路由。

怎么做:看代码中的 routes:/login、/addCard、/addEnterprise、/enterpriseDetail、/addRole 都设置 hidden: true,表示不在侧边菜单显示;/ 用 Layout 并 redirect: '/workbench';/workbench 的 children 里 path: '',作为默认二级路由;/404 也隐藏。

易错点:hidden: true 只影响菜单显示,不影响路由匹配;不要以为隐藏了就不能访问。children 的 path: '' 是默认子路由,访问父路径时会渲染它;写成 '/' 或漏写会导致默认页不出现。

布局路由与默认子路由 ​

为什么需要:后台页面通常共用侧边栏、顶栏,Layout 负责把“架子”渲染出来;业务页面作为二级路由嵌进去。

怎么想:一级路由负责布局,二级路由负责内容。/workbench 用 Layout,子路由 path: '' 指向工作台页面,这样进入 /workbench 就能直接看到默认内容。

怎么做:代码中 /workbench 的 children 只有一项,path: '',component 动态导入工作台,meta 里放标题和图标。/ 则重定向到 /workbench。

易错点:redirect 和 children 默认路由不要混用错;/ 如果直接给 component: Layout 但没有默认子路由,会出现布局有了但内容空。动态导入路径要确保文件存在,否则运行时报模块找不到。

createRouter:生成实例并挂载静态表 ​

为什么需要:Vue Router 需要先创建实例,才能被 Vue 使用;把静态表复制进去,避免后续重置时污染原始 routes。

怎么想:用工厂函数 createRouter() 每次返回新实例,内部 routes: [...routes] 展开复制一份静态表。这样初始化逻辑清晰,也给 resetRouter() 留出“重新生成”的入口。

怎么做:代码里 mode: 'history' 使用 history 模式,scrollBehavior: () => ({ y: 0 }) 让切换路由回到顶部,最后 export default router。

易错点:history 模式需要服务端支持,否则刷新子路径可能 404;如果只是本地练习,可切回 hash。scrollBehavior 返回 { y: 0 } 是常见写法,别写成 { x: 0 } 还误以为能回顶。

resetRouter:用新 matcher 覆盖旧记录 ​

为什么需要:退出登录或切换权限后,之前动态加进去的路由可能残留;重置路由可以回到“只有静态表”的干净状态。

怎么想:不逐个删除路由,而是新建一个 router,拿到它的 matcher,覆盖当前 router 的 matcher。这样当前实例的路由匹配记录就换成了全新静态表。

怎么做:const newRouter = createRouter() 后执行 router.matcher = newRouter.matcher。注意这是依赖 Vue Router 内部属性的做法,版本升级时要验证。

易错点:重置后当前页面不会自动跳转,通常还要配合 router.push('/login') 等操作;不要直接改 routes 原数组,否则后续重置的“干净表”也被污染。

完整代码保留 ​

javascript
import Vue from 'vue'
import Router from 'vue-router'

Vue.use(Router)

/* Layout */
import Layout from '@/layout'

// 俩种路由

export const routes = [
  {
    path: '/login',
    component: () => import('@/views/Login/index'),
    hidden: true
  },
  {
    path: '/addCard',
    component: () => import('@/views/Car/CarCard/add-card'),
    hidden: true
  },
  {
    path: '/addEnterprise',
    component: () => import('@/views/Park/Enterprise/AddEnterprise'),
    hidden: true
  },
  {
    path: '/enterpriseDetail',
    component: () => import('@/views/Park/Enterprise/EnterpriseDetail'),
    hidden: true
  },
  {
    path: '/addRole',
    component: () => import('@/views/System/Role/AddRole'),
    hidden: true
  },
  {
    path: '/',
    component: Layout,
    redirect: '/workbench'
  },
  // 只有一级路由
  // 一级路由:负责把layout架子渲染出来
  // 二级路由:path为空 会作为默认的二级路由一上来就渲染出来
  {
    path: '/workbench',
    component: Layout,
    children: [{
      path: '',
      component: () => import('@/views/Workbench/index'),
      meta: { title: '工作台', icon: 'el-icon-data-board' }
    }]
  },

  {
    path: '/404',
    component: () => import('@/views/404'),
    hidden: true
  }
]

const createRouter = () => new Router({
  // mode: 'history', // require service support
  mode: 'history',
  scrollBehavior: () => ({ y: 0 }),
  routes: [...routes]
})

const router = createRouter()

// 重置路由方法
export function resetRouter() {
  // 得到一个全新的router实例对象
  const newRouter = createRouter()
  // 使用新的路由记录覆盖掉老的路由记录
  router.matcher = newRouter.matcher
}

export default router

验证时按转写稿的调试习惯:启动项目后分别访问 /、/workbench、/login、/404,确认重定向、默认子路由和隐藏页匹配正常;再刷新 history 路径,检查服务端 fallback 是否配置。

本节目标:编写路由权限过滤函数 ​

本节要完成的是一个“处理函数”:根据当前用户拥有的一级、二级权限标识,从完整的动态路由表 asyncRoutes 中过滤出真正可用的路由。转写稿里反复强调过一个判断思路:拿到权限标识后,去用户权限列表里用 includes 查找,找到才保留,找不到就不显示。本节把这个思路从“单个按钮是否显示”扩展到“整张路由表如何过滤”。

为什么需要:动态路由表往往是全量的,不能直接全部注册;如果用户没有某个一级模块或二级页面的权限,就不应该出现在路由结果里。
怎么想:先按一级权限过滤整组路由,再在保留下来的路由里过滤 children。
怎么做:用 filter 保留符合一级权限的路由,用 map 生成新对象并替换 children。
易错点:不要直接修改原数组;要确认 permission 字段和权限列表类型一致。

先看入参:三组数据分别是什么 ​

javascript
// 根据权限标识过滤路由表
function getRoutes(firstRoutePerms, secondRoutePerms, asyncRoutes) {
  // 根据一级和二级对动态路由表做过滤 return出去过滤之后的路由表
  // 1. 根据一级路由对动态路由表做过滤
  return asyncRoutes.filter(route => {
    return firstRoutePerms.includes(route.permission)
  }).map(item => {
    // 2. 对二级路由做过滤
    return {
      ...item,
      children: item.children.filter(item => secondRoutePerms.includes(item.permission))
    }
  })
}

为什么需要:函数必须明确“拿什么过滤什么”。firstRoutePerms 是一级权限标识集合,secondRoutePerms 是二级权限标识集合,asyncRoutes 是完整动态路由表。
怎么想:一级权限决定“整组路由要不要”,二级权限决定“组内子路由要不要”。
怎么做:一级用 filter,二级在 map 中处理 children。
易错点:权限集合应是数组;如果传进来的是字符串,includes 语义会变成“子串包含”,容易误判。

一级路由过滤:先缩小范围 ​

为什么需要:如果一级模块都没有权限,后面的 children 再过滤也没有意义。
怎么想:沿用转写稿中的查找逻辑——当前路由的 permission 是否在 firstRoutePerms 中,找到就保留。
怎么做:

javascript
asyncRoutes.filter(route => {
  return firstRoutePerms.includes(route.permission)
})

filter 会返回一个新数组,不改变 asyncRoutes 本身。
易错点:route.permission 必须与权限列表中的值完全一致;大小写、前后空格、数字与字符串混用都可能导致过滤失败。

二级路由过滤:保留一级结构,只替换 children ​

为什么需要:一级路由保留后,组内可能仍有用户无权限的子页面,所以还要处理 children。
怎么想:不能直接在原对象上改 children,否则会污染原始动态路由表。应该用 map 返回新对象,并用展开运算符保留一级路由的其他字段。
怎么做:

javascript
.map(item => {
  return {
    ...item,
    children: item.children.filter(item => secondRoutePerms.includes(item.permission))
  }
})

这里的 ...item 复制一级路由字段,children 被替换成过滤后的新数组。
易错点:内层 filter 的参数也叫 item,会遮蔽外层 item,阅读时容易混淆;实际项目里可改成 child。另外,如果某个一级路由没有 children,直接调用 item.children.filter 会报错,需要先判断或兜底。

完整执行流程与验证思路 ​

这个函数先执行一级 filter,得到“有权限的一级路由数组”;再执行 map,对每个一级路由生成新对象,并把 children 过滤成“有权限的二级路由数组”。最终返回的是过滤后的新路由表。

转写稿中验证按钮权限时,用过“换一个没有的标识,按钮就不显示”的方式。这里也可以同理验证:从 firstRoutePerms 去掉某个一级权限,观察整组路由是否消失;从 secondRoutePerms 去掉某个二级权限,观察对应子路由是否被过滤。调试时重点检查 permission 字段是否存在、权限数组是否为空、children 是否为数组。

易错点总结:不要省略 return;不要误用 map 替代 filter;不要直接修改 asyncRoutes;对 children 缺失要做兜底;权限标识类型和值必须严格匹配。

先看目标:动态路由为什么不能静态写死 ​

不同角色登录后能看到的菜单不同,所以不能把所有路由都静态注册给所有人。更稳的做法是:保留一份“原始动态路由表”asyncRoutes,登录后拿到用户权限,再过滤出当前用户可用的路由。

为什么需要:菜单和页面访问权要跟用户权限走。
怎么想:原始路由表是“全集”,用户权限是“筛选条件”,最终得到“当前用户路由”。
怎么做:先把 asyncRoutes 导入守卫所在文件:

javascript
// 导入原始动态路由表
import { asyncRoutes } from './router/asyncRoute'

易错点:过滤时不要直接修改 asyncRoutes 原数组,否则多次登录会互相污染;应让 getFilterRoutes 返回新数组。

守卫里的 token 分支:先判断身份再决定放行 ​

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)
    }
  } else {
    // 没有token
    if (WHITE_LIST.includes(to.path)) {
      next()
    } else {
      next('/login')
    }
  }
})

为什么需要:有 token 才允许进入权限流程;无 token 只能去登录页或白名单。
怎么想:先看 token,再看白名单,最后才拉用户信息。
怎么做:无 token 时用 WHITE_LIST.includes(to.path) 判断;有 token 时进入权限加载。
易错点:next() 只能调用一次;原代码在 if (token) 里先 next(),后面又 await 拉权限,导航已经放行,过滤结果可能来不及注册。调试时会表现为“控制台有 filterRoutes,但刷新页面菜单/页面不对”。应把 next() 放到权限处理完成之后。

拉取用户信息:profile.id 作为“是否已加载”的标记 ​

javascript
if (!store.state.user.profile.id) {
  const permissions = await store.dispatch('user/getProfile')

为什么需要:避免每次路由跳转都重复请求用户信息和权限。
怎么想:用 profile.id 是否存在判断“用户资料是否已加载”。
怎么做:await store.dispatch('user/getProfile'),把返回的权限数据交给下一步。
易错点:要确认 getProfile 返回的是权限列表,还是整个用户对象;如果返回结构是 { profile, permissions },就不能直接传给 getMenuList。另外 profile.id 初始值要稳定,否则会重复请求或死循环。

把权限数据转成菜单标识:getMenuList ​

javascript
const menuPerms = getMenuList(permissions)
console.log(menuPerms)

为什么需要:后端返回的权限结构通常不能直接和路由 name/meta 对上。
怎么想:先抽出一级、二级菜单标识,形成路由过滤能识别的数组。
怎么做:getMenuList(permissions) 做数据整形,打印 menuPerms 验证字段。
易错点:空权限、null、字段名大小写、层级结构变化都会让过滤结果为空;先看 console.log(menuPerms),不要直接怀疑路由。

过滤一级和二级路由:getFilterRoutes ​

javascript
const filterRoutes = getFilterRoutes(menuPerms, asyncRoutes)
console.log(filterRoutes)

为什么需要:只保留当前用户有权限访问的一级和二级路由。
怎么想:用 menuPerms 去筛选 asyncRoutes,父路由和子路由都要处理。
怎么做:先打印 filterRoutes,确认一级菜单、二级菜单是否都保留;确认没有权限的路由被剔除。
易错点:只过滤一级不过滤二级,会出现“菜单有但子页面进不去”;过滤后还要把结果真正注册到路由表,否则只是数组存在,路由并不存在。

关键调试:next() 的位置和动态注册 ​

按本节笔记代码,next() 在拉权限之前执行,过滤结果只打印、未注册。实际项目里通常要改成:先 await 拉权限、过滤,再 router.addRoute 注册,最后再 next({ ...to, replace: true })。转写稿里按钮权限的调试思路是“先登录,再改权限标识验证显示/隐藏”,动态路由也可类比:先登录,再看 menuPerms、filterRoutes,最后验证刷新后页面是否可进。不要只盯控制台数组,要验证路由是否真的被加入。易错点:注册后再次进入守卫可能重复注册或死循环,需用 profile.id 或已注册标记控制;next 不要多次调用。

先看目标:这段 Vuex 逻辑在管什么 ​

本节笔记的代码是一个 Vuex 模块,核心只做两件事:用常量路由初始化 menuList,再通过 mutation 把过滤后的动态路由追加进去。转写稿里多次把 Vuex 说成“VBS”“VS”“VX”,这里统一校正为 Vuex;把“拼年”校正为 Pinia。转写稿还提到“当前用户所有权限标识存放在 Vuex 里”,这说明 Vuex 在本项目里承担集中状态管理职责。但转写稿没有直接展开 setMenuList 的逐行调试,所以下面只依据笔记代码和转写稿中的状态管理背景做推导,不编造额外业务。

为什么需要:后台菜单通常由“常量路由”和“权限过滤后的动态路由”共同组成。常量路由是所有人都能看的,动态路由要登录后按权限筛出来。把最终菜单数据放进 Vuex,多个组件就能共享同一份菜单状态。

怎么想:先让 state 持有常量路由副本,等权限路由算出来后,再提交 mutation 合并。合并结果就是最终用于渲染菜单的 menuList。

怎么做:保留笔记代码原样:

javascript
import { contantsRoutes } from '@/router'
export default {
  namespaced: true,
  state: () => {
    return {
      menuList: [...contantsRoutes]
    }
  },
  mutations: {
    setMenuList(state, filterRoutes) {
      state.menuList = [...state.menuList, ...filterRoutes]
    }
  }
}

易错点:contantsRoutes 这个导入名必须和 @/router 里实际导出的名字完全一致。它看起来像 constantsRoutes 的拼写变体,但不要凭感觉改代码;如果导出名是 constantRoutes 而这里写 contantsRoutes,运行时就是 undefined,展开运算符会直接报错。

state 为什么写成函数 ​

为什么需要:Vuex 的 state 如果写成对象,模块被复用时可能共享同一份引用,造成状态污染。写成函数,每次初始化都能返回新对象。

怎么想:menuList: [...contantsRoutes] 不是直接引用常量路由数组,而是用扩展运算符复制一层。这样后续追加动态路由时,不会直接改动路由配置文件里的原数组。

怎么做:保持 state: () => ({ menuList: [...contantsRoutes] }) 的函数形式;如果常量路由里还有嵌套 children,要注意这只是浅拷贝,深层对象仍是引用。

易错点:不要写成 state: { menuList: contantsRoutes },也不要在 mutation 里直接 state.menuList.push(...filterRoutes) 后以为什么都没问题。笔记采用“重新赋值新数组”的写法,更符合不可变更新习惯。

mutation 的合并逻辑与调用方式 ​

为什么需要:Vuex 要求修改 state 走 mutation,便于追踪状态变化。setMenuList 的职责就是把权限过滤后的 filterRoutes 合并进 menuList。

怎么想:state.menuList = [...state.menuList, ...filterRoutes] 表示“旧菜单 + 新过滤路由”。这里默认 filterRoutes 是数组;如果不是数组,展开会出问题。

怎么做:组件里提交时,因为模块设置了 namespaced: true,要带命名空间,例如 commit('模块名/setMenuList', filterRoutes)。调试时可以先打印 filterRoutes 是否为数组,再提交后查看 state.menuList.length 是否增加。

易错点:第一,namespaced: true 后不能只写 commit('setMenuList');第二,重复提交会重复追加,若页面刷新或多次登录,需要先重置为常量路由再合并;第三,mutation 只做同步操作,异步请求权限路由应在 action 或组件中完成后再提交。

和转写稿的对应关系 ​

转写稿讲按钮权限时反复出现“查找逻辑”:拿当前按钮标识去用户权限列表里找,找到才显示,找不到不返回。这个思路和菜单权限过滤是同一类思想:先有权限数据,再做条件筛选,最后把结果交给视图。转写稿还提到“Vuex 是集中状态管理工具”,而本节代码正是把菜单结果集中存起来。术语上注意:props 不是“pros”,render 不是“re函数”,Vuex 不是“VBS/VS/VX”。本节笔记没有附带图片,因此不插入图片;代码按原样保留。

先看目标:为什么要在路由守卫里触发 mutation ​

本节笔记的核心不是“写一个守卫”,而是把“用户权限 → 动态路由 → 左侧菜单”这条链路串起来,最后通过 store.commit('menu/setMenuList', filterRoutes) 触发 mutation,把过滤后的路由存进 Vuex,供菜单渲染使用。

为什么需要这样做?因为后台项目的菜单和路由不能写死:不同用户登录后,能看到的页面不同;如果只在前端静态写死菜单,权限控制就形同虚设。怎么想?先判断有没有 token,有 token 才继续拿用户信息;拿到权限后,生成菜单权限列表,再过滤动态路由,注册路由,最后提交 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')
    }
  }
})

易错点:next() 的位置很关键。上面代码在有 token 后先 next(),再异步 dispatch、addRoute、commit,首次进入动态路由页面时可能放行过早,导致目标路由还没注册、菜单还没写入。实际调试时应确认:动态路由添加完成后再放行,或使用 next({ ...to, replace: true }) 重新进入,避免白屏或 404。

权限链路:getProfile → getMenuList → getFilterRoutes ​

为什么需要这三步?因为后端返回的“原始权限列表”不一定直接等于菜单结构,也不一定直接等于路由结构。怎么想?先把用户信息里的权限取出来,再转成菜单权限,最后用菜单权限去过滤 asyncRoutes,得到当前用户可访问的动态路由。怎么做?代码里用 store.dispatch('user/getProfile') 获取权限,赋值给 permissions;再用 getMenuList(permissions) 得到 menuPerms;最后用 getFilterRoutes(menuPerms, asyncRoutes) 得到 filterRoutes。这里的 getMenuList、getFilterRoutes 是项目已有工具函数,本节不展开内部实现。

转写稿中按钮权限的调试过程可以迁移理解:先从 Vuex 取当前用户所有权限标识,再用 includes 判断某个标识是否存在,找到才显示。菜单过滤同理:先有 menuPerms,再与 asyncRoutes 做匹配过滤。转写稿里“re函数”应纠正为 render 函数,“pros”应纠正为 props,但本节重点是权限列表的消费方式。

易错点:profile.id 不存在才请求用户信息,避免重复请求;但权限为空时不要直接崩溃,应确认 getMenuList 的入参结构。console.log(menuPerms) 和 console.log(filterRoutes) 是调试手段,上线前应清理或按规范保留。

mutation 的职责:存过滤后的路由 ​

为什么需要 mutation?因为左侧菜单要响应式渲染,菜单数据应集中放在 Vuex 中,而不是散落在守卫局部变量里。怎么想?动态路由注册解决“能不能进页面”,mutation 存菜单解决“左侧显示什么”。怎么做?store.commit('menu/setMenuList', filterRoutes) 触发 menu 模块的 setMenuList,把过滤后的路由存入状态。注意这里是 commit,不是直接改 store.state.menu.menuList,保证状态变更可追踪。

易错点:模块命名要匹配,menu/setMenuList 中的 menu 是 Vuex 模块名;filterRoutes 应是可序列化、菜单组件能消费的结构;重复执行守卫时避免重复 addRoute 和重复提交,必要时做已添加标记。无 token 时走白名单判断,WHITE_LIST.includes(to.path) 命中才放行,否则 next('/login')。

代码示例 ​

1. src/router/index.js —— 初始化时只处理静态路由表 ​

javascript
import Vue from 'vue'
import Router from 'vue-router'

Vue.use(Router)

/* Layout */
import Layout from '@/layout'

// 俩种路由:静态路由(所有人可见)+ 动态路由(按权限过滤,见 asyncRoute.js)

export const routes = [
  {
    path: '/login',
    component: () => import('@/views/Login/index'),
    hidden: true
  },
  {
    path: '/addCard',
    component: () => import('@/views/Car/CarCard/add-card'),
    hidden: true
  },
  {
    path: '/addEnterprise',
    component: () => import('@/views/Park/Enterprise/AddEnterprise'),
    hidden: true
  },
  {
    path: '/enterpriseDetail',
    component: () => import('@/views/Park/Enterprise/EnterpriseDetail'),
    hidden: true
  },
  {
    path: '/addRole',
    component: () => import('@/views/System/Role/AddRole'),
    hidden: true
  },
  {
    path: '/',
    component: Layout,
    redirect: '/workbench'
  },
  // 只有一级路由
  // 一级路由:负责把 layout 架子渲染出来
  // 二级路由:path 为空,会作为默认的二级路由一上来就渲染出来
  {
    path: '/workbench',
    component: Layout,
    children: [{
      path: '',
      component: () => import('@/views/Workbench/index'),
      meta: { title: '工作台', icon: 'el-icon-data-board' }
    }]
  },
  {
    path: '/404',
    component: () => import('@/views/404'),
    hidden: true
  }
]

// 工厂函数:方便每次生成一个全新的 router 实例
const createRouter = () => new Router({
  // mode: 'history', // require service support
  mode: 'history',
  scrollBehavior: () => ({ y: 0 }),
  routes: [...routes]
})

const router = createRouter()

// 重置路由方法(退出登录时用)
export function resetRouter() {
  // 得到一个全新的 router 实例对象
  const newRouter = createRouter()
  // 使用新的路由记录覆盖掉老的路由记录
  router.matcher = newRouter.matcher
}

export default router

关键行与易错点

  • routes 只放「不需要权限」的页面:登录、404、工作台、各种 hidden: true 的详情/新增页。动态菜单页一律不放这里,否则会绕过权限。
  • 一级路由 component: Layout 负责渲染出整个后台架子(侧边栏 + 顶栏 + 内容区);二级路由 path: '' 才是默认显示的子页面,写成 '/' 或漏掉空 path 都会导致首屏空白。
  • const createRouter = () => new Router({...}) 必须是工厂函数,resetRouter 才能拿到一份全新的 matcher 去覆盖旧的;如果直接 new Router() 复用同一个实例,切换账号后旧动态路由仍残留。
  • mode: 'history' 需要服务端把 404 回退到 index.html,否则刷新子路由白屏;本地调试不确定时先用默认 hash 模式。
  • router.matcher = newRouter.matcher 是 Vue Router 3 的重置写法,只重置路由记录,不会重置页面状态。

2. 动态路由权限完整流程:过滤函数 + 路由守卫 + Vuex 菜单 ​

javascript
// ========== ① 根据权限标识过滤动态路由表 ==========
// firstRoutePerms / secondRoutePerms:一级、二级菜单的权限标识数组
// asyncRoutes:原始动态路由表(每项都带 permission 字段)
function getRoutes(firstRoutePerms, secondRoutePerms, asyncRoutes) {
  // 1. 根据一级路由对动态路由表做过滤
  return asyncRoutes
    .filter(route => firstRoutePerms.includes(route.permission))
    .map(item => {
      // 2. 对二级路由做过滤
      return {
        ...item,
        children: item.children.filter(child => secondRoutePerms.includes(child.permission))
      }
    })
}

// ========== ② Vuex:左侧菜单数据源 ==========
// store/modules/menu.js
import { routes as contantsRoutes } from '@/router'

export default {
  namespaced: true,
  state: () => {
    return {
      menuList: [...contantsRoutes] // 初始只有静态路由(含 layout 架子)
    }
  },
  mutations: {
    setMenuList(state, filterRoutes) {
      // 静态 + 动态,合并后才是完整菜单
      state.menuList = [...state.menuList, ...filterRoutes]
    }
  }
}

// ========== ③ 路由守卫:拿权限 → 过滤 → addRoute → 存 Vuex ==========
import router from './router'
import store from './store'
import { getCookie } from '@/utils/auth'
import { asyncRoutes } from './router/asyncRoute'

const WHITE_LIST = ['/login']

router.beforeEach(async (to, from, next) => {
  const token = getCookie('park_token')

  // 有 token
  if (token) {
    next()

    // 获取用户信息(用 profile.id 判断是否已拉过,避免每次跳转都请求)
    if (!store.state.user.profile.id) {
      // 1. 获取原始权限列表
      const permissions = await store.dispatch('user/getProfile')
      // 2. 数据处理得到一级二级路由菜单标识
      const menuPerms = getMenuList(permissions)
      // 3. 通过菜单权限过滤一级和二级,得到权限菜单列表
      const filterRoutes = getFilterRoutes(menuPerms, asyncRoutes)
      // 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')
    }
  }
})

关键行与易错点

  • 过滤要两级都过滤:先 filter 一级,再 map 出 { ...item, children: item.children.filter(...) }。只过滤一级会把无权的子菜单全带出去,等于权限失效。
  • ...item 必须展开保留原路由的 component、meta、path,只替换 children;直接返回 { children: ... } 会丢掉 component,页面渲染不出来。
  • 笔记里函数定义名是 getRoutes、调用处写的是 getFilterRoutes、getRoutes(menuPerms, asyncRoutes) 是两参、定义是三参——实际代码里名字和形参顺序必须完全一致,这是最容易照抄出 bug 的地方。
  • !store.state.user.profile.id 这个判断是「只处理一次」的开关,配合 store.commit('menu/setMenuList', ...) 只 commit 一次,否则每次路由跳转都会重复追加菜单。
  • next() 写在 addRoute 之前,首次刷新进入动态页面时会先匹配一次「路由不存在」;生产上通常要改成动态路由加完后 next({ ...to, replace: true }) 重新进入一次,否则首屏白屏/跳 404。
  • 退出登录时除了清 token,还要 resetRouter() 并清空 menuList,否则换账号会看到上一个账号的菜单。

3. Vue3 选项式 API 与组合式 API 对比(自增小案例) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>选项式 vs 组合式</title>
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
</head>
<body>
  <div id="app1"></div>
  <div id="app2"></div>

  <script>
    const { createApp, ref } = Vue

    /* ---------- 写法一:选项式 API(Vue2 风格,Vue3 同样支持) ---------- */
    const OptionsApp = {
      data() {
        // 第一步:定义响应数据
        return { count: 0 }
      },
      methods: {
        // 第二步:定义修改数据的方法
        addCount() {
          this.count++
        }
      },
      template: `
        <div>
          <p>选项式:{{ count }}</p>
          <button @click="addCount">加号</button>
        </div>
      `
    }

    /* ---------- 写法二:组合式 API(setup) ---------- */
    const CompositionApp = {
      setup() {
        // 第一步:定义响应数据(ref 专门生成响应式数据,作用类似 data)
        const count = ref(0)

        // 第二步:定义修改响应式数据的方法
        const addCount = () => {
          count.value++ // ref 生成的数据必须通过 .value 修改
        }

        // 模板里要用到的东西,必须以对象形式 return 出去
        return { count, addCount }
      },
      template: `
        <div>
          <p>组合式:{{ count }}</p>
          <button @click="addCount">加号</button>
        </div>
      `
    }

    createApp(OptionsApp).mount('#app1')
    createApp(CompositionApp).mount('#app2')
  </script>
</body>
</html>

关键行与易错点

  • 两种写法开发思想完全一致:都是「先定义响应数据 → 再定义修改它的方法 → 最后在模板里绑定」,只是 API 换了;选项式在 Vue3 里照常能跑。
  • ref(0) 生成的响应式数据在 JS 里必须写 count.value++,直接 count++ 不会触发更新(这是组合式最高频的坑);模板里 Vue 会自动解包,写 &#123;&#123; count &#125;&#125; 即可,不要写 &#123;&#123; count.value &#125;&#125;。
  • setup 里声明的变量和方法必须 return,否则模板拿不到;返回的是对象,count 对应模板里的 count。
  • setup 里就是纯 JS 环境:局部变量 + 局部函数,写法和原生 JS 一样,不再「date 放数据、methods 放方法」各有各的位置。
  • 挂载用 createApp(根组件).mount('#app'),Vue3 没有 new Vue();一个容器只能挂一个应用,所以示例里用了 #app1、#app2 两个容器。

常见错误 ​

  1. 有 token 时先 next(),再异步获取用户信息并 addRoute
    现象:刷新动态路由页面 404/白屏,首次进入菜单不出现。
    原因:守卫放行早于动态路由注册。
    修法:先 await 完成 getProfile → 过滤路由 → addRoute → commit 菜单,再 next({ ...to, replace: true })。

  2. addRoute 后直接 next(),不重新匹配当前地址
    现象:动态路由已添加,但当前页仍 404。
    原因:本次导航已按旧路由表匹配。
    修法:addRoute 完成后用 next({ ...to, replace: true }) 重新进入目标地址。

  3. 404 兜底路由先于动态路由生效
    现象:权限路由被 404 拦截。
    原因:通配路由提前参与匹配。
    修法:404 放在静态路由末尾或动态路由添加后再追加,确保业务动态路由先注册。

  4. 退出登录只 resetRouter(),不重置 Vuex 菜单
    现象:换账号后左侧菜单残留、按钮权限残留。
    原因:router.matcher 重置了,但 menuList/profile/token 未同步清理。
    修法:登出时同时清 token、profile,commit 将 menuList 重置为静态路由,再 resetRouter()。

  5. setMenuList 用追加方式且无幂等控制
    现象:刷新或重复登录后菜单重复。
    原因:mutation 每次 [...state.menuList, ...filterRoutes]。
    修法:改为“静态路由 + 本次过滤路由”整体替换,或增加 hasAddRoutes 标记防止重复提交。

  6. getRoutes 对 children 直接 filter
    现象:无 children 的一级路由报错。
    原因:未判空。
    修法:使用 children?.filter(...),无 children 时按业务规则保留或跳过。

  7. 按钮权限高阶组件中直接返回 this.$slots.default
    现象:按钮不显示、渲染报错或结构异常。
    原因:默认插槽可能不存在,且 $slots.default 不是可直接返回的单 VNode。
    修法:判空后取 this.$slots.default?.(),返回合法 VNode/数组包裹;权限列表未加载时先不渲染或等待。

  8. Vue3 项目仍沿用 Vue2 写法
    现象:全局组件不可用、启动报错、编辑器无高亮提示。
    原因:仍用 Vue.component/new Vue/Vetur,或 setup 中遗漏 return、ref 修改未 .value。
    修法:改用 createApp().component().mount()、Volar,检查 setup 返回值与 ref.value。

练习 ​

  1. 阅读给定 router.beforeEach:指出“有 token 分支先 next()”会导致什么具体问题,并改写为正确的异步守卫流程伪代码。

  2. 设计“退出登录 → 切换账号 → 重新登录”的清理清单:说明 token、profile、menuList、resetRouter()、动态路由记录的清理顺序与原因。

  3. 改造 getRoutes:要求兼容一级路由无 children、二级路由无 permission、重复提交导致菜单重复三种情况,并写出关键判断。

  4. 用 render 实现按钮权限组件:要求处理默认插槽为空、权限列表未加载、Vue3 全局注册三个边界场景,写出组件核心结构与注册方式。

  5. 列一份 Vue3 项目迁移检查清单:至少覆盖 Vetur/Volar、入口文件、单文件组件、setup 返回值、ref 使用五项,并说明每项检查点。

课堂画面 ​

课堂画面 01

课堂画面 02

课堂画面 03

课堂画面 04

课堂画面 05

课堂画面 06

原视频对照 ​

#视频时长
1通过render实现高阶组件方案19:44
2业务典型场景说明15:28
3Vue3-简单介绍06:26
4Vue3-创建一个Vue3的项目07:40
5Vue3-项目关键文件熟悉17:30
6Vue3-俩种API风格对比08:30

小结 ​

  1. 动态路由权限链路的核心顺序是:取用户信息 → 过滤路由 → addRoute → 存菜单 → 再放行。
  2. next() 的调用时机决定首屏是否 404,动态路由场景下不能先放行再注册。
  3. 静态路由与动态路由必须分离,登录、404、工作台等基础路由不应被权限过滤误伤。
  4. resetRouter() 只解决路由匹配器问题,必须与 Vuex 菜单、用户信息、token 同步重置。
  5. 菜单状态写入要幂等,避免刷新、重复登录、重复守卫造成菜单重复。
  6. 按钮权限高阶组件的本质是条件渲染,重点在插槽判空、权限数据就绪和合法 VNode 返回。
  7. Vue3 的组合式 API 是写法变化,不是开发思想推倒重来;选项式 API 仍可继续使用。
  8. Vue3 工具链要同步升级:Vite 替代旧构建体验,Volar 替代 Vetur,createApp 替代 new Vue。