vue-element-admin的登录权限校验和动态路由的实现逻辑

7,657 阅读5分钟

前言

  感觉 vue-element-admin 开源项目 对登录的权限校验和动态路由的逻辑复用性还是很强的,可以在自己的项目中直接拿来用了。为了加深记忆就写个文章记录一下学习笔记。

文章主要以记录逻辑为主,再配合些关键代码,完整代码可以直接去下载该项目的源码,或者我个人整理的精简版(把其他代码删掉,只保留了登录部分的功能)

动态路由的实现

逻辑

  1. 配置两个路由数组:
    • 一个是公共的,无需权限都可以加载,比如首页,登录页,404页面等;
    • 一个是动态的,配置角色权限,从而动态选择是否显示;
  2. 点击登录后,会返回该用户的权限信息,拿去和动态路由数组的角色权限做配对,把该用户可以访问的路由筛选出来。
  3. 最后通过 vue 的 addRoutes 方法把筛选出来的数组动态添加到实际路由对象即可。

第一步:配置两个路由数组

目录:src/router/index.js

// 该 js 实现了逻辑的第一步,配置两个路由

// 公共的路由
export const constantRoutes = [
  {
    path: '/login',
    component: () => import('@/views/login/index'),
    hidden: true  // 因为无需在侧边栏显示,可以用这个字段来控制隐藏 
  },
  {
    path: '/',
    component: Layout,  // 这是一个框架组件,顶部和侧边栏会固定加载
    redirect: '/dashboard',  // 重定向的就是中间的内容部分
    children: [
      {
        path: 'dashboard', 
        component: () => import('@/views/dashboard/index'),
        name: 'Dashboard',
        meta: { title: 'Dashboard', icon: 'dashboard'}
      }
    ]
  }
]

// 动态的路由 (最后通过 addRoutes 添加进去)
export const asyncRoutes = [
  {
    path: '/user',
    component: Layout, 
    redirect: '/user/create', 
    children: [
      {
        path: 'create',
        name: 'userCreate',
        component: () => import('@/views/create/create'),
        meta: { roles: ['admin'] } // 只有管理员才能访问
      },
      {
        path: 'list',
        name: 'userList',
        component: () => import('@/views/user/list'),
       meta: { roles: ['admin','editor'] } // 管理员和编辑可以访问
      },
    ]
  }
]

// 先把公共路由添加进路由实例,动态的路由手动添加
const createRouter = () => new Router({
  // mode: 'history', // 是否使用 history 模式
  scrollBehavior: () => ({ y: 0 }),
  routes: constantRoutes
})

第二第三步:在路由守卫处实现

点击登录后,页面跳转前会进入到路由守卫

路由守卫的逻辑可以直接看代码,有注释还是挺好理解的,然后也补充几个细节点配合阅读:

  • 页面加载的进度条是用了第三方库 nprogress 实现,简单易用;
  • 第一次登录其实会发送两次请求,第一次是点击登录时获取 token ,第二次就是在路由守卫这里获取用户拥有的权限
  • 拿到当前用户权限后,就会去筛选我们配置好的动态路由数组,再用 addRoutes 动态添加;
  • 路由重定向的情况:当没有 token 而且要去其他路由比如 “用户列表” 的时候,就可以把“用户列表”路由先赋值给参数,在登录页重新登录后,会先判断有没有该参数来实现重定向跳转。

目录:src/promission.js (会在 main.js 中引入)

// 省略 import 引入的代码哦!

NProgress.configure({ showSpinner: false }) // 进度条设置,把转圈圈关掉

const whiteList = ['/login'] // 设置白名单

// 路由守卫
router.beforeEach(async (to, from, next) => {
  // 进度条开始
  NProgress.start()

  // 设置标题
  document.title = getPageTitle(to.meta.title)

  // 第一次登录后会把 token 存在 cookie 中,此处就是通过 cookie 拿 token
  const hasToken = getToken()

  if (hasToken) {
 /* 有 token,证明已成功登录 */
 
    if (to.path === '/login') {
      next({ path: '/' })    // 如果要去登录页,会自动跳转到首页,然后会再次进入这个路由守卫
      NProgress.done()
    } else {
    
      // 在 vuex 中获取用户权限,因为第一次登录时会把请求回来的用户权限存在 vuex 中
      const hasRoles = store.getters.roles && store.getters.roles.length > 0 
      
      if (hasRoles) {
        next()  // 如果有 token 并且有权限,直接跳转
      } else {
        try {
          // 有 token 无权限,这就是用户第一次登录的情况,需要发送请求获取
          const { roles } = await store.dispatch('user/getInfo')

          // 筛选动态路由数组
          const accessRoutes = await store.dispatch('permission/generateRoutes', roles)

          // 动态添加筛选后的路由数组
          router.addRoutes(accessRoutes)

         // replace: true 表示不会记录到浏览器 history
          next({ ...to, replace: true })
          
        } catch (error) {
          // 获取用户权限报错会要求重新登录,并且删掉 token
          await store.dispatch('user/resetToken')
          
          Message.error(error || 'Has Error')
          next(`/login?redirect=${to.path}`)  // 重定向
          NProgress.done()
        }
      }
    }
  } else {
    /*没有 token,证明 token 过期了或者未登录,*/

    if (whiteList.indexOf(to.path) !== -1) {
      next()  // 如果是白名单,直接跳转
    } else {
      // 要去其他路由,先把路由地址赋值给跳转参数,登陆成功后拿出来跳转。
      next(`/login?redirect=${to.path}`)
      NProgress.done()
    }
  }
})

router.afterEach(() => {
  // 结束进度条
  NProgress.done()
})

登录页实现重定向

上面说了,情况有可能是 token 过期或者还没登录而是通过其他链接跳转到内部页面,需要先登录才可以跳转;
此时就会把要去的路由地址作为参数带到登录页,登录后会先判断有没有 redirect 参数。

目录:src/views/login/index.vue

methods:{

   // 点击登录调用的方法
   handleLogin() {
   // 字段校验(element-ui的东西)
    this.$refs.loginForm.validate(valid => {   
      if (valid) {
        this.loading = true  // 登录时按钮里面会有个圈在转
        
        this.$store.dispatch('user/login', this.loginForm)  // 发送登录请求获取 token
          .then(() => {
            // 重定向,先判断有没有redirect参数
            this.$router.push({ path: this.redirect || '/', query: this.otherQuery })
            this.loading = false
          })
          .catch(() => {
            this.loading = false
          })
      } else {
        console.log('error submit!!')
        return false
      }
    })
  }
}

到这整个动态添加路由和登录权限验证就已经实现了,至于 vuex 中怎么筛选合并路由数组,怎么发送请求的代码逻辑这里就不说了。

在此之前其实就已经实现了权限检验和动态路由的添加,接下来配合视图渲染,把我们筛选合并后的路由数组渲染到菜单上就大功告成了。

菜单渲染

菜单渲染是使用了 element-ui 的侧边栏组件实现,不过里面细节还是挺多挺复杂的。也参考了慕课网的课程文档《「小慕读书」管理后台》

目录:src\layout\components\Sidebar\SidebarItem.vue

// 该组件就是负责渲染侧边栏的每一个目录

<template>
  <div v-if="!item.hidden">

    <!-- template 部分渲染没有子目录的目录 -->
    <!-- hasOneShowingChild:判断是否只有一个需要显示的子路由 -->
    <!-- !onlyOneChild.children||onlyOneChild.noShowingChildren:判断需要展示的子菜单是否包含 children 属性,
    如果包含,则说明子菜单可能存在孙子菜单,此时则需要再判断 noShowingChildren 属性 -->
    <!-- !item.alwaysShow:判断路由中是否存在 alwaysShow 属性,只要配置了 alwaysShow 属性就会直接进入下面的那部分-->

    <template v-if="hasOneShowingChild(item.children,item) && (!onlyOneChild.children||onlyOneChild.noShowingChildren)&&!item.alwaysShow">
      <app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path)">
        <el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{'submenu-title-noDropdown':!isNest}">
          <item :icon="onlyOneChild.meta.icon||(item.meta&&item.meta.icon)" :title="onlyOneChild.meta.title" />
        </el-menu-item>
      </app-link>
    </template>

    <!-- el-submenu 部分是渲染含有子目录的目录 -->
    <el-submenu v-else ref="subMenu" :index="resolvePath(item.path)" popper-append-to-body>
      <template slot="title">
        <item v-if="item.meta" :icon="item.meta && item.meta.icon" :title="item.meta.title" />
      </template>
      <!-- 子目录是嵌套本组件实现,所以可以理解为整个组件只会渲染一个目录,子目录是递归整个组件产生 -->
      <sidebar-item
        v-for="child in item.children"
        :key="child.path"
        :is-nest="true"
        :item="child"
        :base-path="resolvePath(child.path)"
        class="nest-menu"
      />
    </el-submenu>
  </div>
</template>

<script>
import path from 'path'
import { isExternal } from '@/utils/validate'
import Item from './Item'
import AppLink from './Link'
import FixiOSBug from './FixiOSBug'

export default {
  name: 'SidebarItem',
  components: { Item, AppLink },
  mixins: [FixiOSBug],
  props: {
    // route object
    item: {
      type: Object,
      required: true
    },
    isNest: {
      type: Boolean,
      default: false
    },
    basePath: {
      type: String,
      default: ''
    }
  },
  data() {
    this.onlyOneChild = null
    return {}
  },
  methods: {
    hasOneShowingChild(children = [], parent) {
      const showingChildren = children.filter(item => {
        // 如果 children 中的路由包含 hidden 属性,则返回 false
        if (item.hidden) {
          return false
        } else {
          // 将子路由赋值给 onlyOneChild,用于只包含一个路由时展示
          this.onlyOneChild = item
          return true
        }
      })

      // 如果过滤后,只包含展示一个路由,则返回 true
      if (showingChildren.length === 1) {
        return true
      }

      // 如果没有子路由需要展示,则将 onlyOneChild 的 path 设置空路由,并添加 noShowingChildren 属性,表示虽然有子路由,但是不需要展示子路由
      if (showingChildren.length === 0) {
        this.onlyOneChild = { ... parent, path: '', noShowingChildren: true }
        return true
      }

      // 返回 false,表示不需要展示子路由,或者超过一个需要展示的子路由
      return false
    },
    resolvePath(routePath) {
      if (isExternal(routePath)) {
        return routePath
      }
      if (isExternal(this.basePath)) {
        return this.basePath
      }
      return path.resolve(this.basePath, routePath)
    }
  }
}
</script>

总结

放一张慕课网课程的文档《「小慕读书」管理后台》里面整理的图片,虽然我是觉得看代码还简单点... 但是看完代码再看这个图应该会加深理解很多。

路由守卫的代码逻辑图:

新手上路,有错误的地方热烈欢迎指正。

参考

「小慕读书」管理后台