项目实战 | 如何恰当的处理 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 路由权限控制 是个老生常谈的话题,只要是业务系统,必然会涉及到权限问题,想必大家对此也有多种多样的解决方案和实现方式,非常欢迎大家评论区讨论😋。您的任何反馈我都会仔细斟酌,感谢万分!!!

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

推荐阅读:

相关推荐
星辰中的维纳斯28 分钟前
vue新手入门教程(项目创建+组件导入+VueRouter)
前端·javascript·vue.js
嫣嫣细语1 小时前
css实现鼠标禁用(鼠标滑过显示红色禁止符号)
前端·css
Days20501 小时前
web前端主要包括哪些技术
前端
XF鸭1 小时前
HTML-CSS 入门介绍
服务器·前端·javascript
kinlon.liu2 小时前
基于 Nginx + Spring Boot + Vue + JPA 的网站安全防护指南
网络·vue.js·spring boot·nginx·安全
forwardMyLife2 小时前
element-plus 的form表单组件之el-radio(单选按钮组件)
前端·javascript·vue.js
fs哆哆2 小时前
ExcelVBA运用Excel的【条件格式】(二)
linux·运维·服务器·前端·excel
码农小野2 小时前
基于Vue.js和SpringBoot的地方美食分享网站系统设计与实现
vue.js·spring boot·美食
安冬的码畜日常2 小时前
【CSS in Depth 2精译】2.5 无单位的数值与行高
前端·css
ilisi_2 小时前
导航栏样式,盒子模型
前端·javascript·css