Vue3纯前端如何实现Vue路由权限

4,518 阅读7分钟

前言

在开发管理后台时,都会存在多个角色登录,登录成功后,不同的角色会展示不同的菜单路由。这就是我们通常所说的动态路由权限,实现路由权限的方案有多种,比较常用的是由前端使用addRoutes(V3版本改成了addRoute)动态挂载路由和服务端返回可访问的路由菜单这两种。今天主要是从前端角度,实现路由权限的功能。

RBAC模型

前端实现路由权限主要是基于RBAC模型。

RBAC(Role-Based Access Control)即:基于角色的权限控制。通过角色关联用户,角色关联权限的方式间接赋予用户权限。

代码实现

登录

首先是登录,登录成功后,服务端会返回用户登录的角色、token以及用户信息等。用户角色如:role: ['admin']。我们一般会将这些信息保存到Vuex里。

const login = () => {
  ruleFormRef.value?.validate((valid: boolean) => {
    if (valid) {
      store.dispatch('userModule/login', { ...accountForm })
    } else {
      console.log('error submit!')
    }
  })
}

信息存储在Vuex:

async login({ commit }, payload: IRequest) {
  // 登录获取token
  const { data } = await accountLogin(payload)
  commit('SET_TOKEN', data.token)
  localCache.setCache('token', data.token)
  // 获取用户信息
  const userInfo = await getUserInfo(data.id)
  commit('SET_USERINFO', userInfo.data)
  localCache.setCache('userInfo', userInfo.data)
  router.replace('/')
},

服务端返回token:

服务端返回用户信息:

菜单信息

路由菜单信息分为两种,一种是默认路由constantRoutes,即所有人都能够访问的页面,不需去通过用户角色去判断,如login、404、首页等等。还有一种就是动态路由asyncRoutes,用来放置有权限(roles 属性)的路由,这部分的路由是需要访问权限的。我们最终将在动态路由里面根据用户角色筛选出能访问的动态路由列表。

我们将默认路由和动态路由都写在router/index.ts里。

import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'
const Layout = () => import('@/Layout')

/** 常驻路由 */
export const constantRoutes: RouteRecordRaw[] = [
  {
    path: '/login',
    name: 'login',
    component: () => import('@/views/login/index.vue'),
    meta: {
      title: '登录',
      hidden: true
    }
  },
  {
    path: '/',
    component: Layout,
    redirect: '/analysis/dashboard',
    name: 'Analysis',
    meta: {
      hidden: false,
      icon: 'icon-home',
      title: '系统总览'
    },
    children: [
      {
        path: '/analysis/dashboard',
        name: 'Dashboard',
        component: () => import('@/views/analysis/dashboard/dashboard.vue'),
        meta: { title: '商品统计', hidden: false }
      },
      {
        path: '/analysis/overview',
        name: 'Overview',
        component: () => import('@/views/analysis/overview/overview.vue'),
        meta: { title: '核心技术', hidden: false }
      }
    ]
  },
  {
    path: '/product',
    component: Layout,
    redirect: '/product/category',
    name: 'Product',
    meta: {
      hidden: false,
      icon: 'icon-tuijian',
      title: '商品中心'
    },
    children: [
      {
        path: '/product/category',
        name: 'Category',
        component: () => import('@/views/product/category/category.vue'),
        meta: { title: '商品类别', hidden: false }
      },
      {
        path: '/product/goods',
        name: 'Goods',
        component: () => import('@/views/product/goods/goods.vue'),
        meta: { title: '商品信息', hidden: false }
      }
    ]
  },
  {
    path: '/story',
    component: Layout,
    redirect: '/story/chat',
    name: 'Story',
    meta: {
      hidden: false,
      icon: 'icon-xiaoxi',
      title: '随便聊聊'
    },
    children: [
      {
        path: '/story/chat',
        name: 'Story',
        component: () => import('@/views/story/chat/chat.vue'),
        meta: { title: '你的故事', hidden: false }
      },
      {
        path: '/story/list',
        name: 'List',
        component: () => import('@/views/story/list/list.vue'),
        meta: { title: '故事列表', hidden: false }
      }
    ]
  },
  {
    path: '/404',
    component: () => import('@/views/404.vue'),
    meta: {
      title: 'Not Found',
      hidden: true
    }
  },
  {
    path: '/:pathMatch(.*)*',
    redirect: '/404',
    meta: {
      hidden: true,
      title: 'Not Found'
    }
  }
]
/**
 * 动态路由
 * 用来放置有权限(roles 属性)的路由
 * 必须带有 name 属性
 */
export const asyncRoutes: RouteRecordRaw[] = [
  {
    path: '/system',
    component: Layout,
    redirect: '/system/department',
    name: 'System',
    meta: {
      hidden: false,
      icon: 'icon-shezhi',
      title: '系统管理'
    },
    children: [
      {
        path: '/system/department',
        name: 'Department',
        component: () => import('@/views/system/department/department.vue'),
        meta: { title: '部门管理', hidden: false, role: ['admin'] }
      },
      {
        path: '/system/menu',
        name: 'Menu',
        component: () => import('@/views/system/menu/menu.vue'),
        meta: { title: '菜单管理', hidden: false, role: ['admin'] }
      },
      {
        path: '/system/role',
        name: 'Role',
        component: () => import('@/views/system/role/role.vue'),
        meta: { title: '角色管理', hidden: false, role: ['editor'] }
      },
      {
        path: '/system/user',
        name: 'User',
        component: () => import('@/views/system/user/user.vue'),
        meta: { title: '用户管理', hidden: false, role: ['editor'] }
      }
    ]
  }
]
const router = createRouter({
  history: createWebHashHistory(),
  routes: constantRoutes
})
export default router

我们将系统管理这个菜单作为动态路由部分,里面的子菜单meta属性下都分配有一个访问权限的role属性,我们需要将role属性和用户角色去匹配是否用户具有访问权限。

动态路由筛选

思路:

我们登录得到了用户角色role和写好路由信息(分为默认路由列表和动态路由列表),之后我们需要做的就是通过用户角色role去匹配动态路由列表里面每个子路由的role属性,得到能够访问的动态路由部分,将默认路由和我们得到的动态路由进行拼接这样我们就得到了用户能够访问的完整前端路由,最后使用addRoute将完整路由挂载到router上。

有了这样一个比较清晰的思路,接下来我们就来尝试着实现它。

我们可以将这块的逻辑也放在Vuex里面,在store/modules下新建一个permission.ts文件。

首先我们需要写一个方法去判断用户是否具有访问单个路由的权限:

/**
 * 判断用户是否有权限访问单个路由
 * roles:用户角色
 * route:访问的路由
 */
const hasPermission = (roles: string[], route: any) => {
  if (route.meta && route.meta.roles) {
    return roles.some((role) => {
      if (route.meta?.roles !== undefined) {
        return route.meta.roles.includes(role)
      } else {
        return false
      }
    })
  } else {
    return true
  }
}

实现的核心是route.meta.roles.includes(role),即路由的roles是否包含了用户的角色,包含了就可以访问,否则不能。

对用户角色进行some遍历主要是用户的角色可能存在多个,如:['admin', 'editor']。

这样我们就实现了单个路由访问权限的筛选,但是动态路由列表是一个数组,每个一级路由下可能有二级路由、三级路由甚至更多,这样我们就需要用到递归函数进行筛选:

/**
 * 筛选可访问的动态路由
 * roles:用户角色
 * route:访问的动态列表
 */
const filterAsyncRoutes = (routes: RouteRecordRaw[], roles: string[]) => {
  const res: RouteRecordRaw[] = []
  routes.forEach((route) => {
    const r = { ...route }
    if (hasPermission(roles, r)) {
      if (r.children) {
        r.children = filterAsyncRoutes(r.children, roles)
      }
      res.push(r)
    }
  })
  return res
}

这样,通过调用filterAsyncRoutes这个函数,然后传入utes:动态路由列表,roles:用户角色两个参数就能得到我们能访问的动态路由了。

然后我们将筛选得到的动态路由和默认路由通过concat拼接得到完整可访问路由,最后通过addRoute挂载。

我们将以上代码逻辑整理到sion.ts里:

import { Module } from 'vuex'
import { RouteRecordRaw } from 'vue-router'
import { constantRoutes, asyncRoutes } from '@/router'
import { IRootState } from '../types'
import router from '@/router'
/**
 * 判断用户是否有权限访问单个路由
 * roles:用户角色
 * route:访问的路由
 */
const hasPermission = (roles: string[], route: any) => {
  if (route.meta && route.meta.roles) {
    return roles.some((role) => {
      if (route.meta?.roles !== undefined) {
        return route.meta.roles.includes(role)
      } else {
        return false
      }
    })
  } else {
    return true
  }
}
/**
 * 筛选可访问的动态路由
 * roles:用户角色
 * route:访问的动态列表
 */
const filterAsyncRoutes = (routes: RouteRecordRaw[], roles: string[]) => {
  const resRouteRecordRaw[] = []
  routes.forEach((route) => {
    const r = { ...route }
    if (hasPermission(roles, r)) {
      if (r.children) {
        r.children = filterAsyncRoutes(r.children, roles)
      }
      res.push(r)
    }
  })
  return res
}
interface IPermissionState {
  routesRouteRecordRaw[]
  dynamicRoutesRouteRecordRaw[]
}
export const routesModuleModule<IPermissionStateIRootState> = {
  namespacedtrue,
  state: {
    routes: [],
    dynamicRoutes: []
  },
  getters: {},
  mutations: {
    SET_ROUTES(state, routes) {
      state.routes = routes
    },
    SET_DYNAMICROUTES(state, routes) {
      state.dynamicRoutes = routes
    }
  },
  actions: {
    generateRoutes({ commit }, { roles }) {
      // accessedRoutes: 筛选出的动态路由
      const accessedRoutes = filterAsyncRoutes(asyncRoutes, roles)
      // 将accessedRoutes和默认路由constantRoutes拼接得到完整可访问路由
      commit('SET_ROUTES', constantRoutes.concat(accessedRoutes))
      commit('SET_DYNAMICROUTES', accessedRoutes)
      // 通过addRoute将路由挂载到router上
      accessedRoutes.forEach((route) => {
        router.addRoute(route)
      })
    }
  }
}

这样就实现了所有代码逻辑。有个问题,addRoute应该何时调用,在哪里调用?

登录后,获取用户的权限信息,然后筛选有权限访问的路由,再调用addRoute添加路由。这个方法是可行的。但是不可能每次进入应用都需要登录,用户刷新浏览器又要登录一次。所以addRoute还是要在全局路由守卫里进行调用。

我们在router文件夹下创建一个permission.ts,用于写全局路由守卫相关逻辑:

import router from '@/router'
import { RouteLocationNormalized } from 'vue-router'
import localCache from '@/utils/cache'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import store from '@/store'

NProgress.configure({ showSpinnerfalse })
const whiteList = ['/login']
router.beforeEach(
  async (
    toRouteLocationNormalized,
    fromRouteLocationNormalized,
    nextany
  ) => {
    document.title = to.meta.title as string
    const tokenstring = localCache.getCache('token')
    NProgress.start()
    // 判断该用户是否登录
    if (token) {
      if (to.path === '/login') {
        // 如果登录,并准备进入 login 页面,则重定向到主页
        next({ path'/' })
        NProgress.done()
      } else {
        const roles = store.state.userModule.roles
        store.dispatch('routesModule/generateRoutes', { roles })
        // 确保添加路由已完成
        // 设置 replace: true, 因此导航将不会留下历史记录
        next({ ...to, replacetrue })
        // next()
      }
    } else {
      // 如果没有 token
      if (whiteList.includes(to.path)) {
        // 如果在免登录的白名单中,则直接进入
        next()
      } else {
        // 其他没有访问权限的页面将被重定向到登录页面
        next('/login')
        NProgress.done()
      }
    }
  }
)
router.afterEach(() => {
  NProgress.done()
})

这样,完整的路由权限功能就完成了。我们可以做一下验证:

动态路由

动态路由

我们登录的用户角色为roles: ['editor'],动态路由为系统管理菜单,里面有四个子路由对应有roles,正常情况下我们可以访问系统管理菜单下的角色管理和用户管理。

渲染菜单界面

渲染菜单界面

筛选出的动态路由

筛选出的动态路由

没有任何问题!

总结

前端实现动态路由是基于RBAC思想,通过用户角色去筛选出可以访问的路由挂载在router上。这样实现有一点不好的地方在于菜单信息是写死在前端,以后要改个显示文字或权限信息,需要重新修改然后编译。