前端權(quán)限開發(fā)——設(shè)計到實踐(保姆級)
共 20977字,需瀏覽 42分鐘
·
2024-05-10 08:38
關(guān)于本文
作者:
https://juejin.cn/post/7259210874446692411
1.權(quán)限控制的方案選擇。
做后臺項目區(qū)別于做其它的項目,權(quán)限驗證與安全性是非常重要的,可以說是一個后臺項目一開始就必須考慮和搭建的基礎(chǔ)核心功能。在后臺管理系統(tǒng)中,實現(xiàn)權(quán)限控制可以采用多種方案:
| 權(quán)限方案類型 | 描述 |
|---|---|
| 基于角色的訪問控制(Role-Based Access Control,RBAC) | 是一種廣泛采用的權(quán)限控制方案。系統(tǒng)中定義了不同的角色,每個角色具有一組權(quán)限,而用戶被分配到一個或多個角色。通過控制用戶角色的分配,可以實現(xiàn)對用戶訪問系統(tǒng)中不同功能和資源的權(quán)限控制。 |
| 基于權(quán)限的訪問控制(Permission-Based Access Control) | 這種方案將權(quán)限直接分配給用戶,而不是通過角色來管理。每個用戶都有自己的權(quán)限列表,控制用戶對系統(tǒng)中各項功能和資源的訪問。 |
| 基于資源的訪問控制(Resource-Based Access Control,RBAC) | 這種方案將權(quán)限控制與資源本身關(guān)聯(lián)起來。系統(tǒng)中的每個資源都有自己的訪問權(quán)限,用戶通過被授予資源的訪問權(quán)限來控制其對資源的操作。 |
| 層次結(jié)構(gòu)權(quán)限控制(Hierarchical Access Control) | 這種方案基于資源和操作的層次結(jié)構(gòu)來進(jìn)行權(quán)限控制。系統(tǒng)中的資源和操作被組織成層次結(jié)構(gòu),用戶被授予訪問某個層次及其子層次的權(quán)限。 |
| 基于規(guī)則的訪問控制(Rule-Based Access Control) | 這種方案使用預(yù)定義的規(guī)則來確定用戶對系統(tǒng)中功能和資源的訪問權(quán)限。規(guī)則可以基于用戶屬性、環(huán)境條件或其他因素進(jìn)行定義。 |
這里我選擇了基于角色的訪問控制(Role-Based Access Control,RBAC) 這是因為RBAC提供了一種靈活且易于管理的方式來控制用戶對系統(tǒng)功能和資源的訪問,也是目前最主流的前端權(quán)限方案選擇。
在RBAC中,系統(tǒng)中的功能和資源被組織成角色,而用戶則被分配到不同的角色。每個角色都有一組權(quán)限,定義了該角色可以執(zhí)行的操作和訪問的資源。通過給用戶分配適當(dāng)?shù)慕巧梢詫崿F(xiàn)對用戶的權(quán)限控制。
RBAC的好處之一是它簡化了權(quán)限管理的復(fù)雜性。管理員只需管理角色和分配角色給用戶,而不需要為每個用戶單獨(dú)定義權(quán)限。當(dāng)需要對用戶的權(quán)限進(jìn)行修改時,只需調(diào)整其角色的權(quán)限即可。
此外,RBAC還支持靈活的權(quán)限組合,允許創(chuàng)建具有不同權(quán)限組合的角色,以適應(yīng)不同用戶的需求。它也便于擴(kuò)展,可以隨著系統(tǒng)的發(fā)展和需求的變化而調(diào)整和添加角色。
2.RBAC下的權(quán)限字段設(shè)計與管理模型
1.用戶權(quán)限授權(quán)
是對用戶身份認(rèn)證的細(xì)化。可簡單理解為訪問控制,在用戶身份認(rèn)證通過后,系統(tǒng)對用戶訪問菜單或按鈕進(jìn)行控制。也就是說,該用戶有身份進(jìn)入系統(tǒng)了,但他不一定能訪問系統(tǒng)里的所有菜單或按鈕,而他只能訪問管理員給他分配的權(quán)限菜單或按鈕。
主要包括:
Permission(權(quán)限標(biāo)識、權(quán)限字符串):針對系統(tǒng)訪問資源的權(quán)限標(biāo)識,如:用戶添加、用戶修改、用戶刪除。
Role (角色):可以理解為權(quán)限組,也就是說角色下可以訪問和點(diǎn)擊哪些菜單、訪問哪些權(quán)限標(biāo)識。
權(quán)限標(biāo)識或權(quán)限字符串校驗規(guī)則:
權(quán)限字符串:指定權(quán)限串必須和菜單中的權(quán)限標(biāo)識匹配才可訪問
權(quán)限字符串命名規(guī)范為:
模塊:功能:操作,例如:sys:user:edit使用冒號分隔,對授權(quán)資源進(jìn)行分類,如
sys:user:edit代表系統(tǒng)模塊:用戶功能:編輯操作設(shè)定的功能指定的
權(quán)限字符串與當(dāng)前用戶的權(quán)限字符串進(jìn)行匹配,若匹配成功說明當(dāng)前用戶有該功能權(quán)限還可以使用簡單的通配符,如
sys:user:*,建議省略為sys:user(分離前端不能使用星號寫法)舉例1
sys:user將于sys:user或sys:user:開頭的所有權(quán)限字符串匹配成功舉例2
sys將于sys或sys:開頭的所有權(quán)限字符串匹配成功 這種命名格式的好處有:
可讀性和可理解性:使用模塊、功能和操作的格式可以直觀地表達(dá)權(quán)限的含義。每個部分都有明確的作用,模塊表示特定的模塊或子系統(tǒng),功能表示模塊內(nèi)的某個功能或頁面,操作表示對功能進(jìn)行的具體操作。通過這種格式,權(quán)限名稱可以更容易地被開發(fā)人員、管理員和其他人員理解和解釋。
可擴(kuò)展性和靈活性: 通過使用模塊、功能和操作的格式,可以輕松地擴(kuò)展和管理權(quán)限。每個模塊、功能和操作都可以被單獨(dú)定義和控制。當(dāng)系統(tǒng)需要增加新的功能或操作時,可以根據(jù)需要添加新的權(quán)限字符串,而不需要修改現(xiàn)有的權(quán)限規(guī)則和代碼。
細(xì)粒度的權(quán)限控制: 這種格式支持細(xì)粒度的權(quán)限控制,可以針對特定的功能和操作進(jìn)行權(quán)限管理。通過將權(quán)限名稱拆分為模塊、功能和操作,可以精確地定義哪些用戶或角色具有訪問或操作特定功能的權(quán)限。
避免權(quán)限沖突: 使用模塊、功能和操作的格式可以避免權(quán)限之間的沖突。不同模塊、功能和操作的權(quán)限名稱是唯一的,這樣可以避免同名權(quán)限之間的混淆和沖突。
2.權(quán)限管理模型
關(guān)鍵數(shù)據(jù)模型如下:
用戶:登錄賬號、密碼、角色
角色:角色名稱、角色權(quán)限字符、對應(yīng)菜單、對應(yīng)菜單下的權(quán)限
菜單:菜單名稱、菜單URL、菜單類型
用戶角色關(guān)系:用戶編碼、角色編碼
角色菜單關(guān)系:角色編碼、菜單編碼
關(guān)系圖如下:
rust
復(fù)制代碼
【用戶】 <---多對多---> 【角色】 <---多對多---> 【菜單/權(quán)限】
3.實現(xiàn)思路與步驟
前端權(quán)限一般分為路由級權(quán)限和按鈕級權(quán)限,這里我們先實現(xiàn)頁面路由級的權(quán)限功能,按鈕級的會在后面講到。大致的思路如下圖:
上圖是用戶從
登錄-->路由守衛(wèi)-->權(quán)限驗證-->構(gòu)建路由表-->跳轉(zhuǎn)目標(biāo)頁面的一個簡單的正向流程,可以看到,權(quán)限驗證和構(gòu)建路由表這兩步是發(fā)生在路由守衛(wèi)這一步里,而實際上在開發(fā)設(shè)計階段,我們還要做的準(zhǔn)備有:
與后端確認(rèn)權(quán)限字段及類型
確定路由表信息是由前端還是后端生成
為了方便理解,我們就按照這個正向流程來逐步實現(xiàn),期間需要用到或者提前考慮設(shè)計到的內(nèi)容,包括以上需要準(zhǔn)備的兩點(diǎn),我會補(bǔ)充在步驟當(dāng)中。
1.登錄
登錄成功后,獲取到token,將token存到本地的sessionStorage里。
action.js
復(fù)制代碼
async login({ commit }, userInfo) {
const { data } = await login(userInfo)
const accessToken = 'Bearer ' + data.access_token
if (accessToken) {
sessionStorage.getItem(tokenTableName)
} else {
message.error(`登錄接口異常,未正確返回${tokenName}...`)
}
}
接著,進(jìn)行路由跳轉(zhuǎn)到首頁
login.vue
復(fù)制代碼
import { useRouter } from 'vue-router';
const router = useRouter();
router.push('/');
如果考慮到頁面token失效后,重新登陸后返回原先的路由地址,則需要在路由守衛(wèi)中添加redirect字段用來存儲當(dāng)前的路由地址
permissions.js
復(fù)制代碼
next({ path: '/login', query: { redirect: to.fullPath }, replace: true })
然后在登錄頁中,監(jiān)聽路由對象中的這個值,如果有值,那么在剛剛路由跳轉(zhuǎn)時,就跳轉(zhuǎn)到該路由地址,而非/首頁,另外,還需要考慮到403,404頁面。所以,添加了這些邏輯后,部分代碼如下:
js
復(fù)制代碼
//login.vue
<script setup>
import { ref, watch, useRouter } from 'vue';
import { useRouter } from 'vue-router';
const redirect = ref('/');
const router = useRouter();
watch(
() => router.currentRoute.value.query.redirect,
(redirectValue) => {
redirect.value = redirectValue || '/';
},
{ immediate: true }
);
function handleRoute() {
return redirect.value === '/404' || redirect.value === '/403'
? '/'
: redirect.value
},
async handleSubmit() {
loading.value = true
try {
await login(form.value)
loading.value = false
router.push(handleRoute());
} catch {
loading.value = false
}
},
</script>
2.路由守衛(wèi)與權(quán)限校驗
當(dāng)進(jìn)行路由跳轉(zhuǎn)時,會進(jìn)入到路由守衛(wèi)中,在路由守衛(wèi)中:
路由守衛(wèi)會首先判斷有沒有token,如果沒有,先判斷要去的路由地址是否包含在路由白名單內(nèi),如果要去的路由地址也不在路由白名單內(nèi),就會讓用戶跳轉(zhuǎn)到登錄頁重新登陸。
如果有token,會先判斷跳轉(zhuǎn)的目標(biāo)地址是否是登錄頁,如果是,則重新跳轉(zhuǎn)到默認(rèn)首頁
此時,我們就需要對用戶權(quán)限進(jìn)行校驗,首先,判斷當(dāng)前用戶是否
擁有角色信息,如果沒有,就要獲取用戶的角色信息。
3.獲取角色信息與權(quán)限信息
調(diào)取用戶信息接口獲取用戶角色信息和權(quán)限信息,代碼如下:
js
復(fù)制代碼
//store/user
// 獲取用戶信息
GetInfo({ commit, dispatch }) {
return new Promise((resolve, reject) => {
getUserInfo()
.then(async (response) => {
const result = response.data
if (result.roles && result.roles.length > 0) {
// 驗證返回的roles是否是一個非空數(shù)組
commit('SET_ROLES', result.roles)
//permissions就是對應(yīng)的用戶權(quán)限信息
commit('SET_PERMISSION', result.permissions)
} else {
// 如果當(dāng)前用戶沒有角色,則賦值一個默認(rèn)角色
commit('SET_ROLES', ['ROLE_DEFAULT'])
}
resolve(result)
// GetInfo一旦失敗就說明這個token不是過期就是丟失了,直接走catch并讓調(diào)用方跳轉(zhuǎn)路由
if (!response.success) {
reject(response.errorMsg)
}
})
.catch((error) => {
reject(error)
})
})
},
根據(jù)之前上文提到的權(quán)限管理模型,從之間的關(guān)系是多對多,因此,role(角色)和permssions(權(quán)限)的類型應(yīng)為數(shù)組,其中的權(quán)限permssion這個字段的格式規(guī)范也在上文提及。在與后端約定好后,后端返回的信息如圖:
4.誰來生成動態(tài)路由表?
在成功獲取到用戶信息,拿到用戶角色和對應(yīng)權(quán)限后,此時,就需要根據(jù)權(quán)限生成對應(yīng)的路由表了,到這里,我們需要思考一個問題:
路由表是由后端提供還是由前端提供?
A:前端根據(jù)權(quán)限生成路由表
B:后端生成路由表給前端
沒錯!答案是C:路由表可以由后端提供或由前端提供。
兩者的優(yōu)劣分別是:
后端提供路由表:
前后端耦合度高:由于路由表由后端提供,前端開發(fā)人員可能需要與后端開發(fā)人員密切協(xié)作,增加了協(xié)調(diào)和溝通的成本。
前端依賴后端:前端應(yīng)用程序可能需要等待后端提供路由表后才能進(jìn)行開發(fā)和測試,增加了開發(fā)的時間和依賴性。
安全性高:后端負(fù)責(zé)驗證和控制權(quán)限,可以確保只有授權(quán)的用戶能夠訪問特定的路由和功能。
隱藏敏感信息:后端可以根據(jù)用戶的角色和權(quán)限隱藏不應(yīng)該被訪問的敏感路由和數(shù)據(jù)。
適用于復(fù)雜的權(quán)限規(guī)則:后端可以使用更復(fù)雜的邏輯和規(guī)則來處理權(quán)限控制,例如基于用戶角色、用戶組、權(quán)限等的復(fù)雜控制邏輯。
優(yōu)點(diǎn):
缺點(diǎn):
前端提供路由表:
安全性較低:前端提供的路由表容易受到篡改和繞過,安全性相對較低。
隱藏敏感信息的難度增加:前端無法直接隱藏敏感路由和數(shù)據(jù),需要依賴后端接口的授權(quán)驗證來保護(hù)敏感信息。
前后端解耦:前端可以獨(dú)立開發(fā)和維護(hù)路由表,減少了與后端的依賴性和協(xié)調(diào)成本。
更好的用戶體驗:前端可以根據(jù)用戶的角色和權(quán)限動態(tài)展示路由和功能,提供更靈活和個性化的用戶體驗。
優(yōu)點(diǎn):
缺點(diǎn):
最終,考慮到后臺管理系統(tǒng)的安全性優(yōu)先級最高,我選擇了由后端存儲,并生成返回路由表信息。 確定好了之后,你就會遇到一個坑:后端提供的路由表無法直接在前端路由中添加使用 這是因為,后端存儲的路由表的結(jié)構(gòu)只一個JSON對象。怎么解決,我們放到后面講。
5.公共路由和動態(tài)路由
我們現(xiàn)在把整個路由分為兩個部分,分別是公共路由和動態(tài)路由(私有路由)。
公共路由
公共路由顧名思義就是無論當(dāng)前用戶是什么角色,都會出現(xiàn)的路由部分。一般這樣的路由分別有:首頁駕駛艙、登錄注冊頁、403頁、404頁。 這部分路由是在前端項目中提前寫死的。
以我的項目為例,我在src/config下創(chuàng)建一個publicRouter.js文件,然后里面放入基礎(chǔ)路由信息:
javascript
復(fù)制代碼
//基礎(chǔ)公共路由
export const constantRouterMap = [
{
path: '/',
name: '',
component: () => import('@/layout'),
redirect: '/homePage',
children: [
{
path: '/homePage',
name: 'homePage',
component: () => import('@/views/homePage/index.vue'),
},
],
},
{
path: '/login',
component: () => import('@/views/login'),
},
{
path: '/403',
name: '403',
component: () => import('@/views/403'),
},
{
path: '/:pathMatch(.*)*',
component: () => import('@/views/404'),
}]
這里面都是路由懶加載的寫法,這個沒啥好說的。需要注意的是在vue3中,vue-router的版本是4.x以上,在跳轉(zhuǎn)404頁面時,如果你的寫法是
css
復(fù)制代碼
{
path: "/404",
name: "notFound",
component: () => import('@/views/404')
}
這樣寫會提示報錯,這是因為vue-router4.x的版本官方推薦引入了這種新寫法,配置一個通配符路由,匹配所有未被其他具體路由匹配的路徑。其中 :pathMatch 是參數(shù)名,而 (.*)* 是參數(shù)的匹配模式,它使用正則表達(dá)式來匹配任意路徑。
動態(tài)路由(私有路由)
上文提到的后端返回給前端當(dāng)前用戶的路由表信息,其實就是私有路由,這部分的路由是動態(tài)的。那么我們只需要把公共路由+動態(tài)路由=當(dāng)前角色用戶完整路由表。然鵝,后端返回給前端的動態(tài)路由是無法直接添加到當(dāng)前頁面路由中的,這是因為在瀏覽器的前后端請求當(dāng)中,信息的返回體都是以JSON字符串的數(shù)據(jù)交換格式進(jìn)行傳輸,而JSON字符串是不支持函數(shù)的。為什么要提到函數(shù)形式呢?
因為當(dāng)我們在路由表使用懶加載的寫法時,component的值是一個異步函數(shù)。也只有異步函數(shù)才能實現(xiàn)對路由的異步懶加載。import()會返回一個promise,這個 promise 最終會加載對應(yīng)的組件模塊。在格式上表現(xiàn)為一個func函數(shù),因此,我們無法將compnent的值傳給后端存儲
但是辦法總比困難多,我們可以將對應(yīng)的路徑字符串傳給后端存儲,然后再通過后端返回的路徑字符串轉(zhuǎn)換成這種箭頭函數(shù)的寫法。
除了這個需要轉(zhuǎn)換以外,我們還要考慮一些問題:
比如,轉(zhuǎn)換后的路由結(jié)構(gòu)要符合router中的路由格式,否則會在調(diào)用router.addRoutes時報錯,添加失敗。
添加一些其他自定義屬性(例如添加導(dǎo)航欄菜單圖標(biāo)、某個路由的顯隱、路由緩存、跳轉(zhuǎn)外鏈接、...),以滿足特定需求。
以我項目中的的路由示例,直接上代碼看:
json
復(fù)制代碼
{
"path": "/", //path,路由的路徑,即訪問該路由時的 URL 路徑。
"name": "homePage",//name,路由的名稱,用于在代碼中標(biāo)識路由。通常用于編程式導(dǎo)航。
"hidden": false,//hidden,是否隱藏路由,在Vue Router 4.0 中被廢棄。
"component": "Layout"//路由對應(yīng)的組件,可以是通過懶加載方式導(dǎo)入的異步組件,或直接引入的同步組件。
"meta": {//meta,路由元信息,可以用于存儲一些額外的信息,比如頁面標(biāo)題、權(quán)限等
"title": "首頁", //路由標(biāo)題
"icon": "icon-Home", //路由圖標(biāo)
"target": "", //是否跳轉(zhuǎn)新頁簽
"permission": "homePage",//權(quán)限
"keepAlive": false//是否緩存
},
"redirect": "/homePage",//重定向
"fullPath": "/",//完整的url路徑,會帶上?后面的參數(shù)
"children": [//子路由
{
"path": "/homePage",
"name": "homePage",
"meta": {
"title": "首頁",
"icon": "",
"target": "",
"permission": "homePage",
"keepAlive": false
},
"fullPath": "/homePage"
}
]
}
考慮完這些之后,我們就開始拿著后端返回的路由表信息動手轉(zhuǎn)換吧! 先看看后端返回給前端的路由表格式內(nèi)容
這里你只需要注意component這個字段的值就行了。
可能有人會問:“那我給后端傳的路由表是什么格式的,也是這樣嗎?”
答案是:當(dāng)然不是!RBAC權(quán)限方案中,用戶可以自己配置權(quán)限,也就是會有對應(yīng)的用戶管理,角色管理,菜單(權(quán)限)管理這三個基本的模塊,給后端傳的是對應(yīng)模塊的修改配置內(nèi)容。前端是不需要關(guān)心傳給后端什么格式。 有點(diǎn)啰嗦了,總之,就是你指著上文這三張圖片,讓后端給你生成出來就完事了。后端要是說辦不到,那就是后端的問題。
6.獲取動態(tài)路由(私有路由)并轉(zhuǎn)換
接下來,這一步是整個前端權(quán)限中最重要的一步。在src/router下,我們新建一個generatorRouters.js文件。里面的內(nèi)容是:
js
復(fù)制代碼
//getCurrentUserNav獲取動態(tài)路由的接口
import { getCurrentUserNav } from '@/api/user'
//validURL是一個正則判斷方法,用來校驗當(dāng)前字符串是否符合url外鏈格式
import { validURL } from '@/utils/validate'
// 前端路由表
const constantRouterComponents = {
// 基礎(chǔ)頁面 layout 必須引入
Layout: () => import('@/layout'),
403: () => import('@/views/403'),
404: () => import('@/views/404'),
}
/**
* 動態(tài)生成菜單
* @param token
* @returns {Promise<Router>}
*/
export const generatorDynamicRouter = (token) => {
return new Promise((resolve, reject) => {
getCurrentUserNav()
.then((res) => {
//接收后端返回的路由表,結(jié)構(gòu)是個數(shù)組
let menuNav = res.data
//將路由表存到本地臨時緩存中
//這一步是為了刷新的時候不需要再調(diào)接口,增加用戶體驗的,沒這方面需求可以不用寫
sessionStorage.setItem('generateRoutes', JSON.stringify(menuNav))
//轉(zhuǎn)化路由格式
const routers = generator(menuNav)
resolve(routers)
})
.catch((err) => {
reject(err)
})
})
}
/**
* 格式化樹形結(jié)構(gòu)數(shù)據(jù) 生成 vue-router 層級路由表
*
* @param routerMap //當(dāng)前路由表route
* @param parent//當(dāng)前路由表route的父級component
* @returns {*}
*/
export const generator = (routerMap, parent) => {
return routerMap.map((item) => {
const { title, show, hideChildren, hiddenHeaderContent, icon, hidden } =
item.meta || {}
if (item.component) {
// Layout ParentView 組件特殊處理
//這里是對父組件/布局組件的處理,因為有可能出現(xiàn)嵌套布局和多種布局的情況,這個時候需要進(jìn)行處理。
if (item.component === 'Layout') {
item.component = 'Layout'
} else if (item.component === 'ParentView') {
item.component = 'BasicLayout'
item.path = '/' + item.path
}
}
if (item.isFrame === 0) {
item.target = '_blank'
}
const currentRouter = {
// 如果路由設(shè)置了 path,則作為默認(rèn) path,否則 路由地址 動態(tài)拼接生成如 /dashboard/workplace
path: item.path || `${(parent && parent.path) || ''}/${item.key}`,
// 路由名稱,建議唯一
// name: item.name || item.key || '',
name: item.name || item.key || '',
// 該路由對應(yīng)頁面的 組件 :方案1
// 該路由對應(yīng)頁面的 組件 :方案2 (動態(tài)加載)
//這里就是將component的字符串值轉(zhuǎn)換成懶加載的異步函數(shù)
//同時,如果當(dāng)前路徑并沒有對應(yīng)的組件,catch捕獲報錯,然后跳轉(zhuǎn)到404頁,這一步很重要,否則
component:
constantRouterComponents[item.component || item.key] ||
(() =>
import(`@/views/${item.component}`).catch(() =>
import('@/views/404')
)),
hidden: item.hidden,
// redirect: '/' + item.path || `${parent && parent.path || ''}/${item.key}`,
// meta: 頁面標(biāo)題, 菜單圖標(biāo), 頁面權(quán)限(供指令權(quán)限用,可去掉)
meta: {
title: title,
icon: icon,
hiddenHeaderContent: hiddenHeaderContent,
target: validURL(item.path) ? '_blank' : '',
permission: item.name,
keepAlive: false,
hidden: hidden,
},
//適配本框架的跳轉(zhuǎn)路徑
}
// 是否設(shè)置了隱藏菜單
if (show === false) {
currentRouter.hidden = true
}
// 修正路徑,而antdv-pro的pro-layout要求每個路徑需為全路徑
//這一步的修正路徑也是因人而異,正常情況下按照我這樣拼接就沒問題了
if (!constantRouterComponents[item.component || item.key]) {
if (parent && parent.path && parent.path !== '/') {
currentRouter.path = `${parent.path}/${item.path}`
}
}
// 是否設(shè)置了隱藏子菜單
if (hideChildren) {
currentRouter.hideChildrenInMenu = true
}
// 重定向
item.redirect && (currentRouter.redirect = item.redirect)
//添加fullPath
currentRouter.fullPath = currentRouter.path
// 是否有子菜單,并遞歸處理
if (item.children && item.children.length > 0) {
currentRouter.children = generator(item.children, currentRouter)
}
return currentRouter
})
}
generatorDynamicRouter方法(promise嵌套)
上面的代碼有點(diǎn)多,不要覺得麻煩,其實就兩個方法,第一個方法generatorDynamicRouter會調(diào)取后端接口,獲取后端返回的私有路由表信息,返回的是個promise對象,因為需要調(diào)接口請求后端數(shù)據(jù),并且其他方法依賴這個方法的接口返回值,所以是個異步函數(shù)。之所以這么寫的另外一個原因,是因為在它之前還有個promise異步方法包著它。
我們都知道:在嵌套的 Promise 中,內(nèi)部的 Promise 會先執(zhí)行,然后再執(zhí)行外部的 Promise。這是由于 JavaScript 的異步執(zhí)行機(jī)制所決定的。 所以這種套娃式的目的只有一個——就是確保外層的異步方法在調(diào)用內(nèi)部異步方法時,能夠保證拿到內(nèi)部異步方法請求成功之后的返回值(也就是將異步方法變成同步方法,這也是Promise主要的作用之一,面試常問的)。
而最外層的異步方法,是放在路由守衛(wèi)當(dāng)中直接調(diào)用的,由于我們是動態(tài)異步加載路由,那么執(zhí)行的方法肯定也是異步。具體的原因是因為:
如果動態(tài)加載路由的方法不是異步的,那么在路由守衛(wèi)中使用它將導(dǎo)致它立即執(zhí)行并返回一個 Promise,而不是在路由導(dǎo)航過程中延遲加載。這樣會使得在路由守衛(wèi)中的邏輯無法按預(yù)期執(zhí)行,因為在組件加載完成之前,它依賴的組件可能還沒有被加載,導(dǎo)致出現(xiàn)錯誤或不一致的狀態(tài)。
從后端獲取路由配置往往涉及到網(wǎng)絡(luò)請求和異步操作,不能保證立即返回所有路由信息。當(dāng)你從后端獲取到路由配置后,需要將其添加到 Vue Router 中,以便在前端應(yīng)用程序中進(jìn)行動態(tài)路由。在這個過程中,你需要使用異步操作來確保在獲取到路由配置后再添加到路由中。
generator方法(遞歸轉(zhuǎn)化)
言歸正傳,generatorDynamicRouter方法調(diào)取后端接口,拿到后端返回路由表信息后,會緊接著調(diào)用第二個generator方法,并將路由表信息當(dāng)做參數(shù)傳入。而generator是一個遞歸方法。這個也很好理解,因為路由信息里都會包含children子路由,需要層層遞歸,generator內(nèi)部會對每一個路由進(jìn)行逐層轉(zhuǎn)化,每一步都在代碼中標(biāo)有注釋,我就不再過多贅述了。我們只需要知道:generator的最終目的就是將后端返回的路由表結(jié)構(gòu)轉(zhuǎn)化成符合前端路由格式的路由(聽起來有點(diǎn)繞)。
附上格式化好之后的路由信息: 再對比下格式化之前的樣子:
可以看到component從字符串變成了函數(shù),path路徑也得到了拼接補(bǔ)充。
7.存儲格式化好的動態(tài)路由及導(dǎo)航欄菜單信息
上文提到在generatorDynamicRouter異步方法之外還有一層異步方法,其實我是放在了vuex的store下某個模塊中的actions里(默認(rèn)大家都會用vuex)。具體代碼如下
js
復(fù)制代碼
const actions = {
GenerateRoutes({ commit }) {
let sidebarList = []
return new Promise((resolve) => {
generatorDynamicRouter().then((routers) => {
//sidebarList是側(cè)邊欄渲染數(shù)據(jù)
sidebarList = routers
//routers就是我們要存儲到vuex中的動態(tài)路由
commit('set_routers', routers)
//側(cè)邊欄數(shù)據(jù)存到vuex中
commit('set_sidebarList', sidebarList)
resolve()
})
})
},
}
8.添加動態(tài)路由
好了,到這一步,我們就拿到了準(zhǔn)備好的公共路由和私有路由。這個時候,我們再回到路由守衛(wèi),回顧一下之前的流程:
路由守衛(wèi)先判斷token
沒有token去登錄拿token(或者是直接去了白名單里)
拿到token后判斷有沒有角色信息,沒有角色信息就去請求角色信息(調(diào)接口)
拿到角色信息后,再去獲取對應(yīng)角色信息的路由表(調(diào)接口)
將后端返回的路由表信息轉(zhuǎn)換成前端能添加的路由表信息格式
添加路由
跳轉(zhuǎn)對應(yīng)路由頁面
路由守衛(wèi)的代碼如下,直接在src/config目錄下新建文件permissions.js
js
復(fù)制代碼
import router from '@/router'
import store from '@/store'
import getPageTitle from '@/utils/pageTitle'
import getInfoRouter from '@/router/getInfoRouter'
import { loginInterception, recordRoute, routesWhiteList } from '@/config'
router.beforeEach(async (to, from, next) => {
let hasToken = store.getters['user/accessToken']
if (hasToken) {
if (to.path === '/login') {
next({ path: '/' })
} else {
//權(quán)限校驗
if (store.getters['user/roles'].length === 0) {
try {
//獲取用戶信息,包括角色信息和權(quán)限信息
await store.dispatch('user/GetInfo')
//獲取動態(tài)路由表信息,并對路由表進(jìn)行格式轉(zhuǎn)化,然后存到vuex中
await store.dispatch('async-router/GenerateRoutes')
//從vuex中拿到動態(tài)路由表數(shù)據(jù)
let accessRoutes = store.getters['async-router/addRouters']
//循環(huán)添加路由
accessRoutes.forEach((route) => {
//isHttp方法用來判斷是否是外鏈,如果是外鏈,就不添加到當(dāng)前路由表中
if (!isHttp(route.path)) {
router.addRoute(route) // 動態(tài)添加可訪問路由表
}
})
//刷新跳轉(zhuǎn)
next({ ...to, replace: true })
} catch (e) {
//登出
await store.dispatch('user/Logout')
//記錄登出前的路由地址
next({ path: '/login', query: { redirect: to.fullPath } })
}
} else {
next()
}
}
} else {
//判斷是否在白名單中
if (routesWhiteList.indexOf(to.path) !== -1) {
next()
} else {
//記錄跳轉(zhuǎn)到登錄頁之前的路由地址
next({ path: '/login', query: { redirect: to.fullPath }, replace: true })
}
}
})
router.afterEach((to) => {
//瀏覽器頁簽標(biāo)題
document.title = getPageTitle(to.meta.title)
})
在vue-router4.x版本中,往當(dāng)前的路由router對象中動態(tài)添加路由時,需要使用官方提供的addRoute方法,不能直接對router對象進(jìn)行修改,這是因為vue-router必須是要vue在實例化之前就掛載上去的。addRoute方法傳入的參數(shù)是單個路由對象,所以寫法上我們需要對動態(tài)路由數(shù)組進(jìn)行for循環(huán),在內(nèi)部調(diào)用addRoute方法,從而達(dá)到成功添加多個動態(tài)路由的目的。這里附上官方API地址鏈接:router.vuejs.org/zh/api/inte…
9.遇到的坑
這里面有幾個坑需要注意下:
第一個坑就是在
vue3支持的vue-router4.0版本之前,也就是vue2中,動態(tài)添加路由的方式支持的是addRoutes,它的參數(shù)是格式是一個數(shù)組。而到了4.0后,addRoutes這個方法被廢棄,取而代之的是addRoute,它的參數(shù)則是一個路由對象。這兩個方法無論是在傳參類型還是添加相同path時的覆蓋邏輯都不相同。第二個坑就是無論是用控制臺還是打斷點(diǎn)的方式,或者是用Vue.js devtools的谷歌插件,都無法在路由守衛(wèi)中獲取到添加完成后的最新router對象,也就是說你會在調(diào)試addRoute加載后的動態(tài)路由表時,發(fā)現(xiàn)與之前為添加路由時的路由表是一樣的,從而無法判斷是否動態(tài)路由添加成功(就很蛋疼)
第三個坑是 next({ ...to, replace: true })這個方法,當(dāng)添加好動態(tài)路由后,如果不這么寫next,會白屏。在
addRoute()之后第一次訪問被添加的路由會白屏,這是因為剛剛addRoute()就立刻訪問被添加的路由,然而此時addRoute()沒有執(zhí)行結(jié)束,因而找不到剛剛被添加的路由導(dǎo)致白屏。因此需要從新訪問一次路由才行。
具體原因見文章鏈接:blog.csdn.net/qq_41912398…還有一個關(guān)于vuex的問題,從之前的代碼里不難看出,我們將用戶信息,角色、權(quán)限、動態(tài)路由等都存在了vuex中。由于vuex的特性,用戶會在刷新頁面后,vuex里的數(shù)據(jù)會被清空,這個時候就會導(dǎo)致頁面直接跳轉(zhuǎn)到登錄頁。這肯定是不能接受的,因此,我們需要對vuex里的數(shù)據(jù)在刷新的時候做持久化處理。邏輯也很簡單,監(jiān)聽頁面刷新事件,
beforeunload這個方法恰好就能做到,在頁面刷新的時候,將vuex中的數(shù)據(jù)緩存到瀏覽器本地臨時緩存中,然后再在頁面初始化的時候,從本地臨時緩存中取出存入vuex對象中。 附上代碼:(項目根目錄下的的App.vue文件中,這里是vue2的寫法)
js
復(fù)制代碼
created() {
//在頁面加載時讀取sessionStorage里的狀態(tài)信息
sessionStorage.getItem('userMsg') &&
this.$store.replaceState(
Object.assign(
this.$store.state,
JSON.parse(sessionStorage.getItem('userMsg'))
)
)
//在頁面刷新時將vuex里的信息保存到sessionStorage里
window.addEventListener('beforeunload', () => {
sessionStorage.setItem('userMsg', JSON.stringify(this.$store.state))
})
},
這里再附上src/router目錄下的index.js代碼,方便大家理解路由這塊:
js
復(fù)制代碼
import { createWebHistory, createRouter, useRouter } from 'vue-router'
import { constantRouterMap } from '@/config/router.config.js'
import { generator } from '@/router/generatorRouters'
const router = createRouter({
history: createWebHistory(),
routes: constantRouterMap,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
} else {
return { top: 0 }
}
},
})
//這里是我為了刷新的時候不調(diào)取獲取路由信息的接口,做的本地緩存,因為刷新一次就要獲取一遍用戶信息和
//動態(tài)路由表信息太影響用戶體驗了,尤其是在網(wǎng)速差的情況下。沒這方面需求的可以不用管
let asyncRouterMap = []
function handleLocalRouter() {
if (sessionStorage.getItem('generateRoutes')) {
asyncRouterMap = asyncRouterMap.concat(
JSON.parse(sessionStorage.getItem('generateRoutes'))
)
const generatedRoutes = generator(asyncRouterMap)
generatedRoutes.push({
path: '/:pathMatch(.*)*',
redirect: '/404',
hidden: true,
})
generatedRoutes.forEach((route) => {
router.addRoute(route)
})
}
}
handleLocalRouter()
export default router
10.結(jié)束與優(yōu)化
當(dāng)用戶要退出時,也就是登出,我們只需要調(diào)用登出接口,并在接口成功返回后,重置vuex中的用戶信息(token,角色roles,權(quán)限permission),同時清空本地緩存的數(shù)據(jù)就行了。附上代碼:
js
復(fù)制代碼
/**
* @description 退出登錄
* @param {*} { dispatch }
*/
Logout({ commit, dispatch, state }) {
return new Promise((resolve, reject) => {
logout()
.then(async () => {
await dispatch('resetAll')
//清空導(dǎo)航tab頁簽
this.dispatch('tagsBar/delAllVisitedRoutes', { root: true })
resolve()
})
.catch((error) => {
reject(error)
})
.finally(() => {})
})
},
/**
* @description 重置accessToken、roles、permission等
* @param {*} { commit, dispatch }
*/
async resetAll({ dispatch, commit }) {
await dispatch('setAccessToken', '')
commit('SET_ROLES', [])
commit('SET_PERMISSION', [])
sessionStorage.clear()
},
好,至此,整個RBAC的前端權(quán)限方案設(shè)計到實現(xiàn)就已經(jīng)宣告完成,其實還有很多需要優(yōu)化的地方,比如把用戶路由表信息緩存到本地臨時緩存中,這樣用戶刷新的時候就不會因為vuex的特性需要再去請求一邊接口。還比如
js
復(fù)制代碼
await store.dispatch('user/GetInfo')
await store.dispatch('async-router/GenerateRoutes')
let accessRoutes = store.getters['async-router/addRouters']
accessRoutes.forEach((route) => {
//用來判斷是否是外鏈
if (!isHttp(route.path)) {
router.addRoute(route) // 動態(tài)添加可訪問路由表
}
})
這段代碼可以抽離出來,單獨(dú)封裝。另外還有側(cè)邊導(dǎo)航欄的處理,這個我沒講,一方面是相對簡單,我們獲取到格式化之后的動態(tài)路由數(shù)據(jù)的同時,其實也就是獲取到了導(dǎo)航欄的菜單信息。 另一方面,每個人的項目頁面布局方式不一樣,導(dǎo)航欄的設(shè)計也會不一樣。在此基礎(chǔ)上,針對性的對數(shù)據(jù)進(jìn)行修飾和改造即可。
不啰嗦了,我寫的比較匆忙,也有些地方我可能沒有考慮到,看到這里的小伙伴,或者對著文章實踐的小伙伴們,歡迎你們提出自己的意見和看法,我會加以訂正,不足之處,還請海涵~~
