项目实战 | 如何恰当的处理 Vue 路由权限

前言

哈喽,小伙伴你好,我是 阿嘟嘟 。最近接了一个成本千万级的前端项目运维工作,本着 知己知彼 的态度,我将整个前端的大致设计思路过了一遍。不看不知道,一看...吓一跳。光是 路由权限 这块儿的设计,都让我"受益匪浅",特写此篇文章来记录自己的所学所感。

阅读本文您将收获:

  1. 真实 vue 项目的路由权限处理思路及伪代码实现(反面案例)。
  2. 个人对于 vue 路由权限处理的设计思考及伪代码实现。
  3. 结合您个人项目经验,思考更优的 vue 路由权处理方案,评论区讨论。

场景分析

接触过业务系统开发的小伙伴应该知道,对于前端来说一个比较重要的事情:权限控制 。当然这并不是说系统的权限会交由前端处理,而是应该在后端提供正确的权限数据的前提下,前端合理的做到用户操作上的权限控制。

前端权限控制大致可以分为三类:

  • 菜单权限:用于可以访问哪些菜单。
  • 操作权限:用户可以执行哪些操作,如增删改查,导入导出等。
  • 路由权限 :配合菜单权限,从路由上规避用户越权访问的行为。

本文的内容假设在菜单权限 正确提供的前提下,路由权限 该如何处理?

关于完整的权限控制,后续会单独出一篇系统性的文章,敬请期待....

路由权限 顾名思义,就是管理前端路由的。对于 Vue 项目来说,就是对于 vue-router 的一系列处理。

真实业务场景中,后端会提供权限查询接口(通常是菜单接口),前端调用来获取当前用户已分配的所有菜单数据。前端根据给定的菜单数据,通过一系列手段,实现对于用户访问的权限控制。

项目案例

路由权限处理的思路很简单,但落地的实现方案却可以多种多样,我们来看看案例项目是如何做的?是否合理?又存在哪些问题呢?

大致分为以下 3 步:

  1. 配置前端路由。
  2. 应用入口获取菜单数据。
  3. 根据菜单数据,控制左侧菜单栏展示内容,实现访问入口控制。

1. 配置前端路由

src/router/index 文件中维护了一份完整的路由配置,并将该配置数组传给 createRouter 创建路由对象。

javascript 复制代码
// 路由配置
const routes = [
    {
        path: '/404',
        component: () => import('@/views/error-page/404.vue')
    },
    {
        path: '/401',
        component: () => import('@/views/error-page/401.vue')
    },
    // 其他所有业务页面路由配置
    ...
]

// 创建路由对象
const router = createRouter({
    history: createWebHashHistory(),
    routes: routes
})

export default router

通过以上代码创建了路由实例 router 并导出,然后在应用的入口文件,一般是 src/main 文件中,引入并安装。

js 复制代码
import { createApp } from 'vue'
import router from '@/router'
import App from './App.vue'

const app = createApp(App)

app.use(rotuer)

2. 应用入口获取菜单数据

封装获取菜单数据的方法,本项目中使用 Vuex 管理菜单数据,作为全局状态。在 src/store/modules 目录下新建 menus 文件,并维护菜单相关的 statemutationsactions 等,其中定义了 getMenus action 用来获取参数数据。

js 复制代码
export default {
  namespaced: true,

  state: () => ({
    menus: [], // 菜单数据
    // ...
  }),

  mutations: {
    SET_MENUS(state: any, menus: any []) {
      state.menus = menus
    },
    // ...
  },

    actions: {
        getMenus({commit, state}) {
          return new Promise((resolve, reject) => {
            getAuthMenu().then((res: any) => {
              const sortMenus = sortRoutes(res)
              commit('SET_MENUS', sortMenus)
              // 其他处理逻辑...
              resolve(sortMenus)
            }).catch((err: any) => {
              reject(err)
            })
          })
        },
    }
}

然后在 src/main 文件中通过调用 store.dispatch 来触发 getMenus 以获取菜单数据逻辑。

js 复制代码
await store.dispatch('menu/getMenus')

3. 处理左侧菜单栏

上一步已经将菜单数据存储到全局状态里了,接下来我们将菜单数据绑定到左侧菜单栏组件,即可渲染最终的可访问菜单了。

html 复制代码
<template>
<!-- 其他页面结构 ...-->
    <el-complete-menu
        class="navigator-menu"
        :data="menuData"
        :collectable="true"
        :collectedData="collectMenus"
        :common-used="false"
        @collect="handleCollect"
        @select="onMenuItemSelected"
        @collapseChange="onMenuCollapseChange"
        />
<!-- 其他页面结构 ...-->
</template>
<script setup lang="ts">
import { useStore } from 'vuex'

const $store = useStore()
const menuData = ref($store.state.menu.menus)
</script>

el-complete-menu 是基于 el-menu 封装的菜单栏组件,data 即为需要展示的菜单树形结构。

大致的实现流程如上,不需要过分纠结代码实现的细节,全部代码远比上面复杂得多。

了解了案例项目的实现过程,接下来请你先暂停并思考下最开始的问题,这个实现方案是否合理?又存在哪些问题?带着问题,我们走进下一节...

优化方案

前端可以通过配置 vue-router 来实现页面导航,那么实现路由权限 的核心思路,就是根据给定的权限数据,调整 vue-router 的配置项

再来看下案例项目的问题:

  1. 没有做到完全的路由权限控制

    我们回顾下 router 部分的代码,在 router/index 文件中维护了一份完整的路由配置数组,然后用其来创建 router 实例。 这里最明显的问题,就是没有根据菜单权限数据来处理 router 配置项。这就可能导致一个现象:虽然左侧菜单栏权限控制住了,但是用户可以直接通过 url,打开他并没有权限访问的页面。

  2. 权限获取时机问题,导致入口文件代码量激增

    案例项目将获取菜单的逻辑代码,写到了应用入口文件 main 中,功能上没有问题,确实能够实现启动应用即获取权限数据。但是对于入口文件来说,首先要保持逻辑的精简,尽可能仅负责应用相关的逻辑处理,如创建应用对象安装插件(VueRouter、Vuex 等)国际化等。除此之外,若掺杂了业务数据获取逻辑在内,首先会导致文件内容混杂,关注点不突出;

明确了问题点,我们就来考虑下该如何处理?

  1. VueRouter 配置调整,将一个完整的路由配置数组拆分为固定配置权限配置 。其中固定配置 是指不需要通过权限来分配,每个人都可以访问的路由,如 主页错误页面(403、404等)权限配置即需要分配权限才能访问的路由,一般是业务模块。
  2. 使用固定配置 创建 router 实例,保证固定配置 中的路由始终可以访问,而权限配置的路由则要按照权限数据动态添加。
  3. VueRouter 的全局前置守卫 beforeEach 中初始化权限菜单及动态添加路由配置,解放 main,也能保证每次进入应用即初始化。

大体流程如下:

实现步骤

了解了大致步骤,我们来逐步实现一下:

1. VueRouter 配置,创建 VueRouter 实例并导出

我们需要定义两个路由配置:staticRoutesdynamicRoutes,即 静态路由动态路由

src/router/index 文件中定义:

js 复制代码
const staticRoutes = [
  {
        name: 'root',
        path: '/',
        redirect: import.meta.env.VITE_ROUTE_HOME_PATH,
        meta: {
            title: '主页'
        }
    },
    {
        name: '404',
        path: '/404',
        component: RouteComponents[404],
        meta: {
          title: '未找到'
        }
    },
    {
        name: 'not-found',
        path: '/:pathMatch(.*)*',
        component: 'blank',
        meta: {
            title: '未找到'
        }
    }
    // ...
]

export const dynamicRoutes = [
    {
        name: 'UserManage',
        path: 'user-manage',
        component: () => import('@/views/system-manage/user-manage/index.vue'),
        meta: { title: '用户管理', keepAlive: true }
    },
    // ...
]

其中 静态路由 用来定义 router 实例,保证其配置的页面一直可访问。

js 复制代码
const router = createRouter({
  history: createWebHashHistory(),
  routes: staticRoutes,
  strict: true,
  sensitive: true
})

// 安装 router 插件的方法
export async function setupRouter(app: App<Element>) {
  app.use(router)
  setupRouterGuards(router)
  // 等待路由初始化完成
  await router.isReady()
}

export default router

注:在 router/idnex 中导出了一个 setupRouter 函数,用来安装 router 插件和添加路由守卫,用于在应用入口 mian 文件中调用。

动态路由 作为备用,在 权限处理 部分会派上用场。

2. 创建应用,安装 router 插件

在应用的入口文件 main 中创建应用实例

js 复制代码
// 创建应用实例
const app = createApp(App)

// 安装路由,添加路由守卫
await setupRouter(app)

// 挂载 到 id 为 app 的 dom 节点下
app.mount('#app')

3. 添加路由导航守卫 beforeEach

第一步在 router/index 文件中有一个 setupRouter 函数,其中调用了一个方法 setupRouterGuards,该方法就是用来添加路由导航的。现在我们来看下它都干了些什么事情?

js 复制代码
export async function setupRouterGuards(router: Router) {
  // 添加前置导航守卫
  const beforeEachGuard = await createBeforeEachGuard()
  router.beforeEach(beforeEachGuard)

  // 添加后置导航守卫
  const afterEachGuard = await createAfterEachGuard()
  router.afterEach(afterEachGuard)
}

内容不多,就是添加了两个全局导航守卫,本文中我们只需要关注前置导航守卫即可,那么我们继续深入 createBeforeEachGuard 内部瞅瞅。

js 复制代码
export default function createBeforeEachGuard(): NavigationGuardWithThis<undefined> {
  return async (
    to: RouteLocationNormalized,
    from: RouteLocationNormalized,
    next: NavigationGuardNext
  ) => {
    // 处理动态路由权限相关,若返回 true,则继续往下执行 
    const normalNext = await handleDynamicRoutes(to, from, next)
    normalNext && next()
  }
}

async function handleDynamicRoutes(
  to: RouteLocationNormalized,
  from: RouteLocationNormalized,
  next: NavigationGuardNext
) {
  const menuStore = useMenuStore()
  // 是否已初始化权限菜单
  const isAuthInitialized = menuStore.isAuthInitialized
  // 若未初始化,则执行初始化函数
  if (!isAuthInitialized) {
    await menuStore.initAuthMenus()
    // 其他处理逻辑...
  }

  return true
}

处理逻辑都在 handleDynamicRoutes 中,首先校验是否已经初始化过权限,即 isAuthInitialized。若未初始化,执行 menuStore.initAuthMenus 函数。那么 menuStore.initAuthMenus 都干了些什么呢,我继续往下看。

4. 动态处理路由配置

终于来到最核心的部分了,上一步我们看到了 menuStore.initAuthMenus 函数,现在我们进入该函数内部瞅一瞅。

js 复制代码
const initAuthMenus = async () => {
    // 获取菜单权限列表
    const menus = await getMenuList()
    // 初始化路由权限配置
    await routeStore.initRoutes(menus)
    // 将平铺的路由数组转换为属性结构,该结果用于渲染菜单栏
    authMenus.value = transformFlatMenusToTree(menus)
    // 执行完成后,将 `isAuthInitialized` 置为 true,避免页面跳转时重复执行权限处理逻辑
    isAuthInitialized.value = true
}

可以看到,initAuthMenus 主要是获取权限菜单数据,并根据菜单数据处理路由配置(routeStore.initRoutes 函数的逻辑);由于后端的设计是返回数组形式,需要前端将数组转化为树形结构,供菜单栏使用(transformFlatMenusToTree 函数的逻辑)。

现在我们进入 initRoutes 函数:

js 复制代码
const initRoutes = async (menus: ZiMuAuth.Menu[]) => {
    if (!routes.length) return
    // 将 路由配置 转化为可用的、平铺的 vue 路由
    const vueRoutes = transformRouteConfigToVueRoutes(routes)
    // 根据权限菜单数据匹配路由配置
    const matchedRoutes = matchRoutesByAuthMenus(vueRoutes, menus)
    for (const route of matchedRoutes) {
      router.addRoute(route)
    }
}

export function matchRoutesByAuthMenus(
  routes: RouteRecordRaw[],
  menus: ZiMuAuth.Menu[]
) {
  if (!menus.length || !routes.length) return []
  const menuCodes = menus.map(m => m.code)
  const matchRoutes = (routes: RouteRecordRaw[]) => {
    const target: RouteRecordRaw[] = []
    for (const route of routes) {
      const matched = !route.name || menuCodes.includes(route.name as string)
      if (matched) {
        if (route.children?.length) {
          route.children = matchRoutes(route.children)
        }
        target.push(route)
      }
    }

    return target
  }

  const result: RouteRecordRaw[] = matchRoutes(routes)
  return result
}

对于路由配置的处理在 matchRoutesByAuthMenus 函数中,以递归的方式,拿路由配置的 name 属性与菜单的 code 进行匹配,若父菜单匹配通过,则继续匹配其子菜单,直到所有路由配置全部匹配完成。

然后遍历匹配通过的路由配置,调用 router.addRoute 函数添加到路由中。

5. 整完收工

前端对于路由权限的处理到这就告一段落了,主要是梳理下整体的实现思路,小伙伴们不需要太过关注代码的实现细节,正如 一千个读者就有一千个哈姆雷特,每个小伙伴写代码的习惯、组织方式、认知程度都有所不同,最终成品肯定也大相径庭,优秀的你肯定比我做的要更好!!!

相关代码已上传 GitHub,感兴趣的小伙伴可以去瞅瞅

关于路由权限的其他思考

对于程序猿来说,懂得思考是美德,能力的退步将从停止思考开始。

除了上面的处理方案,还有没有其他的可行性方式?

现在我们以硬编码的方式将所有路由配置维护在代码里,然后再根据权限菜单动态添加到路由中。既然有动态处理的步骤,那如果不在代码里维护,直接动态生成配置是否可行呢?

可行,需要结合权限模块的设计,将路由需要的必要属性添加到菜单配置中,比如 路由地址对应组件 等。不过这会将权限配置与前端开发紧密耦合,不见得是更优解,但却也是一个不错的思路,毕竟权限模块的设计总是要或多或少的考虑代码逻辑,只是个耦合程度的问题。

后续会考虑将这块添加到个人工程 中,感兴趣的小伙伴可以关注一下...

结语

好啦,今天的内容就到这里了。vue 路由权限控制 是个老生常谈的话题,只要是业务系统,必然会涉及到权限问题,想必大家对此也有多种多样的解决方案和实现方式,非常欢迎大家评论区讨论😋。您的任何反馈我都会仔细斟酌,感谢万分!!!

感谢阅读,愿 你我共同进步,谢谢!!!

推荐阅读:

相关推荐
别拿曾经看以后~39 分钟前
【el-form】记一例好用的el-input输入框回车调接口和el-button按钮防重点击
javascript·vue.js·elementui
我要洋人死41 分钟前
导航栏及下拉菜单的实现
前端·css·css3
科技探秘人1 小时前
Chrome与火狐哪个浏览器的隐私追踪功能更好
前端·chrome
科技探秘人1 小时前
Chrome与傲游浏览器性能与功能的深度对比
前端·chrome
JerryXZR1 小时前
前端开发中ES6的技术细节二
前端·javascript·es6
七星静香1 小时前
laravel chunkById 分块查询 使用时的问题
java·前端·laravel
q2498596931 小时前
前端预览word、excel、ppt
前端·word·excel
小华同学ai1 小时前
wflow-web:开源啦 ,高仿钉钉、飞书、企业微信的审批流程设计器,轻松打造属于你的工作流设计器
前端·钉钉·飞书
Gavin_9151 小时前
【JavaScript】模块化开发
前端·javascript·vue.js
懒大王爱吃狼2 小时前
Python教程:python枚举类定义和使用
开发语言·前端·javascript·python·python基础·python编程·python书籍