前端初学者的第一个Vue后台管理项目总结2:登录与权限控制

2,375 阅读5分钟

系列文章:

  1. 基础架构

Vue源码系列(硬核)

  1. 基本原理
  2. 数组的处理
  3. 渲染watcher 这是vue后台项目总结的第二篇,讲解登录和权限控制的部分。

本项目中登录和权限控制功能的实现,主要参考了vue-elemen-admin,这部分是比较核心的功能,涉及到的模块比较多,笔者在研究vue-elemen-admin的源码时花费了很多时间,也踩了很多坑,所以这篇文章也可以作为新手入坑vue-elemen-admin源码的一个上手教程,相比较于花裤衩大佬的原版教学会更新手向,更细致一些,希望能帮到大家。

登录

获取token

项目中使用token进行用户验证,并将token保存在sessionStorage中(不能只保存在vuex中,因为页面刷新后vuex中的信息会丢失)。

当用户点击登录时触发以下操作,从而获取token

methods: {
  handleLogin() {
    // ...
    // 自定义表单验证部分
    // ...
    this.$store
      .dispatch('user/login', this.userData)
      .then(() => {
        // 用于用户登出后重新登录时,将页面重新定向到退出时的页面
        // 在src/views/layout/components/navbar/ToolBox.vue的handleLogout中会记录用户退出时所在的路径
      	// this.$router.push(`/login?redirect=${this.$route.fullPath}`)
        this.$router.push(this.$route.query.redirect || '/')
      })
      .catch(() => {
        this.$message({
          type: 'error',
          message: '登录失败,请重新尝试'
        })
      })
  }
}

action

// src/store/modules/user.js
login({ commit }, userData) {
    const { username, password } = userData
    return new Promise((resolve, reject) => {
      // userLogin接口,该接口只返回一个token
      userLogin({
        username: username.trim(),
        password: password,
      })
        .then(({ data: { token } }) => {
          // 设置state中的token
          commit('SET_TOKEN', token)
          // 设置sessionStorage中的token
          setToken(token)
          resolve()
        })
        .catch((error) => {
          reject(error)
        })
    })
  }

在每次请求时均需要携带token,因此可以使用axios请求拦截器为每次请求添加token

// src/utils/request.js
service.interceptors.request.use(
  (config) => {
    // 从sessionStorage中获取token
    const token = getToken()
    if (token) {
      config.headers.Authoration = token
    }
    return config
  },
  (err) => {
    console.log(err)
    return Promise.reject(err)
  }
)

获取用户信息

用户登录成功后会进入首页,此时会触发beforeEach钩子,这部分代码在src/permission.js中,总体逻辑如下

router.beforeEach(async (to, from, next) => {
  const token = getToken()
  if (token) {
    // 如果有token,说明已经登录了,不能再进入登录页
    if (to.path === 'login') {
      next({ path: '/ '})
    } else {
      // ...
      // 判断有无用户信息
      // ...
    }
  } else {
    // WHITE_LIST无需登录就能访问的页面白名单,比如'/login'
    if (WHITE_LIST.includes(to.path)) {
      next()
    } else {
      // 如果不在白名单内且没有登录,进入login页面,并且携带重定向的路径
      next(`/login?redirect=${to.path}`)
    }
  }
})

在进入其他页面前需要获取用户的权限,从而确定用户可以访问的页面

beforeEach中会请求getUserInfo来获取用户信息,在本项目中会获取用户对应权限roles(数组)和用户的name,并根据用户权限来生成路由,因此可以根据vuex中是否有roles来判断是否已经请求过getUserInfo

判断有无用户信息的代码如下

const hasRoles = store.getters.roles && store.getters.roles.length > 0
if (hasRoles) {
  // 如果已经获取过用户信息,直接放行
  next()
} else {
  const { roles } = await store.dispatch('user/getUserInfo')
  // ...
	// 动态生成路由
  // ...
}

action

// src/store/modules/user.js
getUserInfo({ commit, state }) {
  return new Promise((resolve, reject) => {
    // 请求getUserInfo接口
    getUserInfo(state.token)
      .then(({ data }) => {
        if (!data) {
          reject(new Error('请重新登录'))
        }
        const { roles, name } = data
        if (!roles || roles.length < 1) {
          reject(new Error('权限错误'))
        }
        // 设置roles和name
        commit('SET_ROLES', roles)
        commit('SET_NAME', name)
        resolve(data)
      })
      .catch((error) => {
        reject(error)
      })
  })
}

权限

前面已经获取了用户的roles字段,并且我们可以在路由表中定义每个路由的访问权限,通过遍历该路由表来确定用户有权限的路由,并利用router.addRoute来动态添加路由(vue-element-admin中采用router.addRoutes,而新版本vue-router推荐使用router.addRoute)。

路由设置

router
  ├── modules         
  ├── asyncRoutes.js         // 异步加载的路由模块,需要根据权限动态添加
  ├── constantRoutes.js      // 所有权限均能访问的通用路由              
  └── index.js            

asyncRoutes中的404路由要放在最后,否则会出现问题

在每个路由对象中,使用meta来定义该路由的访问权限

{
  path: 'page',
  component: () => import('@/views/permission/PagePermission'),
  name: 'PagePermission',
  meta: {
    title: '页面权限',
    roles: ['admin'],
  }
}

src/router/index.js的代码如下所示

import Vue from 'vue'
import VueRouter from 'vue-router'
import constantRoutes from './constantRoutes'

Vue.use(VueRouter)
// 写成一个函数原因在用户退出部分会提到
const createRouter = () =>
  new VueRouter({
    scrollBehavior: () => ({ y: 0 }),
    routes: constantRoutes,
  })

const router = createRouter()

export default router

src/permission.js

前面已经讲解了beforeEach中的部分代码,接下来将解释路由的动态添加部分

try {
  // 获取用户权限
  const { roles } = await store.dispatch('user/getUserInfo')
  // 生成可访问的异步路由
  const accessedRoutes = await store.dispatch(
    'permission/generateRoutes',
    roles
  )
  // 挂载异步路由,没有使用废弃的addRoutes
  accessedRoutes.forEach((r) => router.addRoute(r))
  // 使用next()将无法成功跳转
  next({ ...to, replace: true })
} catch (err) {
  // 发生错误则返回登录页
  await store.dispatch('user/resetToken')
  Message.error(err.message || 'Error')
  next(`/login?redirect=${to.path}`)
  NProgress.done()
}

next('/') 或者 next({ path: '/' }): 跳转到一个不同的地址。当前的导航被中断,然后进行一个新的导航。

因此,在登录后会触发两次beforeEach钩子,如果你在这个钩子中有一些log的话,可能会发现这个有些诡异的现象.

此外,这里还有一个小问题,在较高版本的vue-router中,以上代码会有一个报错

Uncaught (in promise) Error: Redirected when going from "/login" to "/index" via a navigation guard

当使用 router-link 组件时,这些失败都不会打印出错误。然而,如果你使用 router.push 或者 router.replace 的话,可能会在控制台看到一条 "Uncaught (in promise) Error" 这样的错误,后面跟着一条更具体的消息。让我们来了解一下如何区分导航故障

这是由于vue-router3.1.0版本后,pushreplace方法会返回一个promise,导航中断导致产生一个未捕获的错误,可用如下方法解决

// src/router/index.js
const originalPush = VueRouter.prototype.push
VueRouter.prototype.push = function push(location, onResolve, onReject) {
  if (onResolve || onReject)
    return originalPush.call(this, location, onResolve, onReject)
  return originalPush.call(this, location).catch((err) => err)
}

src/store/modules/permission.js

接下来讲解如何获取可访问的异步路由,也就是permission/generateRoutes这个action的实现原理

// src/store/modules/permission.js
const actions = {
  generateRoutes({ commit }, roles) {
    return new Promise((resolve) => {
      let accessedRoutes
      // 如果用户是admin,则可以访问所有异步路由
      if (roles.includes('admin')) {
        accessedRoutes = asyncRoutes || []
      } else {
        // 否则过滤出用户可访问的异步路由
        accessedRoutes = filterAsyncRoutes(asyncRoutes, roles)
      }
      // 更新state
      commit('SET_ROUTES', accessedRoutes)
      resolve(accessedRoutes)
    })
  },
}

filterAsyncRoutes递归获取了有权限访问的异步路由,类似于多叉树的dfs

function filterAsyncRoutes(routes, roles) {
  const res = []
  routes.forEach((route) => {
    const tmp = { ...route }
    if (hasPermission(roles, tmp)) {
      if (tmp.children) {
        tmp.children = filterAsyncRoutes(tmp.children, roles)
      }
      res.push(tmp)
    }
  })
  return res
}

function hasPermission(roles, route) {
  if (route.meta && route.meta.roles) {
    return roles.some((r) => route.meta.roles.includes(r))
  }
  // 没有meta字段或者meta内没有roles字段,说明所有权限均可访问
  return true
}

用户退出

在用户退出时需要重置路由,在src/router/index.js中导出一个方法,并在用户退出时调用

export function resetRouter() {
  // 创建一个新的router对象,该对象的routes只有constantRoutes
  const newRouter = createRouter()
  // 将当前的router重置为初始状态
  router.matcher = newRouter.matcher
}

用户退出后将router重置,用户重新登录时会触发beforeEach钩子,从而重新添加异步路由,如果不重置路由,则用户重新登录时会有一个警告

Duplicate named routes definition,这是由于router中已经存在了这个路由,登录时重新添加了一遍所导致的。但是当根据路由生成菜单栏时并不会产生错误,因为本项目中菜单栏时根据用户能够访问的路由表生成的,与router对象无关。