前言
哈喽,小伙伴你好,我是 嘟老板。最近接了一个成本千万级的前端项目运维工作,本着 知己知彼 的态度,我将整个前端的大致设计思路过了一遍。不看不知道,一看…吓一跳。光是 路由权限 这块儿的设计,都让我“受益匪浅”,特写此篇文章来记录自己的所学所感。
阅读本文您将收获:
- 真实
vue
项目的路由权限处理思路及伪代码实现(反面案例)。- 个人对于
vue
路由权限处理的设计思考及伪代码实现。- 结合您个人项目经验,思考更优的
vue
路由权处理方案,评论区讨论。
场景分析
接触过业务系统开发的小伙伴应该知道,对于前端来说一个比较重要的事情:权限控制。当然这并不是说系统的权限会交由前端处理,而是应该在后端提供正确的权限数据的前提下,前端合理的做到用户操作上的权限控制。
前端权限控制大致可以分为三类:
- 菜单权限:用于可以访问哪些菜单。
- 操作权限:用户可以执行哪些操作,如增删改查,导入导出等。
- 路由权限:配合菜单权限,从路由上规避用户越权访问的行为。
本文的内容假设在菜单权限正确提供的前提下,路由权限 该如何处理?
关于完整的权限控制,后续会单独出一篇系统性的文章,敬请期待…
路由权限 顾名思义,就是管理前端路由的。对于 Vue
项目来说,就是对于 vue-router
的一系列处理。
真实业务场景中,后端会提供权限查询接口(通常是菜单接口),前端调用来获取当前用户已分配的所有菜单数据。前端根据给定的菜单数据,通过一系列手段,实现对于用户访问的权限控制。
项目案例
路由权限处理的思路很简单,但落地的实现方案却可以多种多样,我们来看看案例项目是如何做的?是否合理?又存在哪些问题呢?
大致分为以下 3 步:
- 配置前端路由。
- 应用入口获取菜单数据。
- 根据菜单数据,控制左侧菜单栏展示内容,实现访问入口控制。
1. 配置前端路由
在 src/router/index
文件中维护了一份完整的路由配置,并将该配置数组传给 createRouter
创建路由对象。
// 路由配置
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
文件中,引入并安装。
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
文件,并维护菜单相关的 state
,mutations
和 actions
等,其中定义了 getMenus
action
用来获取参数数据。
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
以获取菜单数据逻辑。
await store.dispatch('menu/getMenus')
3. 处理左侧菜单栏
上一步已经将菜单数据存储到全局状态里了,接下来我们将菜单数据绑定到左侧菜单栏组件,即可渲染最终的可访问菜单了。
<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
的配置项。
再来看下案例项目的问题:
-
没有做到完全的路由权限控制
我们回顾下
router
部分的代码,在router/index
文件中维护了一份完整的路由配置数组,然后用其来创建router
实例。 这里最明显的问题,就是没有根据菜单权限数据来处理router
配置项。这就可能导致一个现象:虽然左侧菜单栏权限控制住了,但是用户可以直接通过 url,打开他并没有权限访问的页面。 -
权限获取时机问题,导致入口文件代码量激增
案例项目将获取菜单的逻辑代码,写到了应用入口文件
main
中,功能上没有问题,确实能够实现启动应用即获取权限数据。但是对于入口文件来说,首先要保持逻辑的精简,尽可能仅负责应用相关的逻辑处理,如创建应用对象,安装插件(VueRouter、Vuex 等),国际化等。除此之外,若掺杂了业务数据获取逻辑在内,首先会导致文件内容混杂,关注点不突出;
明确了问题点,我们就来考虑下该如何处理?
VueRouter
配置调整,将一个完整的路由配置数组拆分为固定配置和权限配置。其中固定配置是指不需要通过权限来分配,每个人都可以访问的路由,如 主页,错误页面(403、404等);权限配置即需要分配权限才能访问的路由,一般是业务模块。- 使用固定配置创建
router
实例,保证固定配置中的路由始终可以访问,而权限配置的路由则要按照权限数据动态添加。 - 在
VueRouter
的全局前置守卫beforeEach
中初始化权限菜单及动态添加路由配置,解放main
,也能保证每次进入应用即初始化。
大体流程如下:
实现步骤
了解了大致步骤,我们来逐步实现一下:
1. VueRouter
配置,创建 VueRouter
实例并导出
我们需要定义两个路由配置:staticRoutes
和 dynamicRoutes
,即 静态路由 和 动态路由 。
在 src/router/index
文件中定义:
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
实例,保证其配置的页面一直可访问。
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
中创建应用实例
// 创建应用实例
const app = createApp(App)
// 安装路由,添加路由守卫
await setupRouter(app)
// 挂载 到 id 为 app 的 dom 节点下
app.mount('#app')
3. 添加路由导航守卫 beforeEach
第一步在 router/index
文件中有一个 setupRouter
函数,其中调用了一个方法 setupRouterGuards
,该方法就是用来添加路由导航的。现在我们来看下它都干了些什么事情?
export async function setupRouterGuards(router: Router) {
// 添加前置导航守卫
const beforeEachGuard = await createBeforeEachGuard()
router.beforeEach(beforeEachGuard)
// 添加后置导航守卫
const afterEachGuard = await createAfterEachGuard()
router.afterEach(afterEachGuard)
}
内容不多,就是添加了两个全局导航守卫,本文中我们只需要关注前置导航守卫即可,那么我们继续深入 createBeforeEachGuard
内部瞅瞅。
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
函数,现在我们进入该函数内部瞅一瞅。
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
函数:
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
路由权限控制 是个老生常谈的话题,只要是业务系统,必然会涉及到权限问题,想必大家对此也有多种多样的解决方案和实现方式,非常欢迎大家评论区讨论😋。您的任何反馈我都会仔细斟酌,感谢万分!!!
感谢阅读,愿 你我共同进步,谢谢!!!