重慶分公司,新征程啟航
為企業(yè)提供網(wǎng)站建設、域名注冊、服務器等服務
為企業(yè)提供網(wǎng)站建設、域名注冊、服務器等服務
成都創(chuàng)新互聯(lián)專注于企業(yè)營銷型網(wǎng)站、網(wǎng)站重做改版、自貢網(wǎng)站定制設計、自適應品牌網(wǎng)站建設、H5場景定制、商城網(wǎng)站建設、集團公司官網(wǎng)建設、成都外貿(mào)網(wǎng)站建設公司、高端網(wǎng)站制作、響應式網(wǎng)頁設計等建站業(yè)務,價格優(yōu)惠性價比高,為自貢等各大城市提供網(wǎng)站開發(fā)制作服務。
上一篇文章中有同學提到路由鑒權,由于時間關系沒有寫,本文將針對這一特性對 vue
和 react
做專門說明,希望同學看了以后能夠受益匪淺,對你的項目能夠有所幫助,本文借鑒了很多大佬的文章篇幅也是比較長的。
單獨項目中是希望根據(jù)登錄人來看下這個人是不是有權限進入當前頁面。雖然服務端做了進行接口的權限,但是每一個路由加載的時候都要去請求這個接口太浪費了。有時候是通過SESSIONID來校驗登陸權限的。
在正式開始 react
路由鑒權之前我們先看一下vue的路由鑒權是如何工作的:
一般我們會相應的把路由表角色菜單配置在后端,當用戶未通過頁面菜單,直接從地址欄訪問非權限范圍內(nèi)的url時,攔截用戶訪問并重定向到首頁。
vue
的初期是可以通過動態(tài)路由的方式,按照權限加載對應的路由表 AddRouter
,但是由于權限交叉,導致權限路由表要做判斷結合,想想還是挺麻煩的,所以采用的是在 beforeEach
里面直判斷用非動態(tài)路由的方式
在使用 Vue的時候,框架提供了路由守衛(wèi)功能,用來在進入某個路有前進行一些校驗工作,如果校驗失敗,就跳轉(zhuǎn)到 404 或者登陸頁面,比如 Vue 中的 beforeEnter
函數(shù):
... router.beforeEach(async(to, from, next) => { const toPath = to.path; const fromPath = from.path; }) ...
1、路由概覽
// index.js import Vue from 'vue' import Router from 'vue-router' import LabelMarket from './modules/label-market' import PersonalCenter from './modules/personal-center' import SystemSetting from './modules/system-setting' import API from '@/utils/api' Vue.use(Router) const routes = [ { path: '/label', component: () => import(/* webpackChunkName: "index" */ '@/views/index.vue'), redirect: { name: 'LabelMarket' }, children: [ { // 基礎公共頁面 path: 'label-market', name: 'LabelMarket', component: () => import(/* webpackChunkName: "label-market" */ '@/components/page-layout/OneColLayout.vue'), redirect: { name: 'LabelMarketIndex' }, children: LabelMarket }, { // 個人中心 path: 'personal-center', name: 'PersonalCenter', redirect: '/label/personal-center/my-apply', component: () => import(/* webpackChunkName: "personal-center" */ '@/components/page-layout/TwoColLayout.vue'), children: PersonalCenter }, { // 系統(tǒng)設置 path: 'system-setting', name: 'SystemSetting', redirect: '/label/system-setting/theme', component: () => import(/* webpackChunkName: "system-setting" */ '@/components/page-layout/TwoColLayout.vue'), children: SystemSetting }] }, { path: '*', redirect: '/label' } ] const router = new Router({ mode: 'history', routes }) // personal-center.js export default [ ... { // 我的審批 path: 'my-approve', name: 'PersonalCenterMyApprove', component: () => import(/* webpackChunkName: "personal-center" */ '@/views/personal-center/index.vue'), children: [ { // 數(shù)據(jù)服務審批 path: 'api', name: 'PersonalCenterMyApproveApi', meta: { requireAuth: true, authRole: 'dataServiceAdmin' }, component: () => import(/* webpackChunkName: "personal-center" */ '@/views/personal-center/api-approve/index.vue') }, ... ] } ]
export default [ ... { // 數(shù)據(jù)服務設置 path: 'api', name: 'SystemSettingApi', meta: { requireAuth: true, authRole: 'dataServiceAdmin' }, component: () => import(/* webpackChunkName: "system-setting" */ '@/views/system-setting/api/index.vue') }, { // 主題設置 path: 'theme', name: 'SystemSettingTheme', meta: { requireAuth: true, authRole: 'topicAdmin' }, component: () => import(/* webpackChunkName: "system-setting" */ '@/views/system-setting/theme/index.vue') }, ... ]
2、鑒權判斷
用戶登陸信息請求后端接口,返回菜單、權限、版權信息等公共信息,存入vuex。此處用到權限字段如下:
_userInfo: { admin:false, // 是否超級管理員 dataServiceAdmin:true, // 是否數(shù)據(jù)服務管理員 topicAdmin:false // 是否主題管理員 }
// index.js router.beforeEach(async (to, from, next) => { try { // get user login info const _userInfo = await API.get('/common/query/menu', {}, false) router.app.$store.dispatch('setLoginUser', _userInfo) if (_userInfo && Object.keys(_userInfo).length > 0 && to.matched.some(record => record.meta.requireAuth)) { if (_userInfo.admin) { // super admin can pass next() } else if (to.fullPath === '/label/system-setting/theme' && !_userInfo.topicAdmin) { if (_userInfo.dataServiceAdmin) { next({ path: '/label/system-setting/api' }) } else { next({ path: '/label' }) } } else if (!(_userInfo[to.meta.authRole])) { next({ path: '/label' }) } } } catch (e) { router.app.$message.error('獲取用戶登陸信息失敗!') } next() })
1、路由簡介
路由是干什么的?
根據(jù)不同的 url 地址展示不同的內(nèi)容或頁面。
單頁面應用最大的特點就是只有一個 web 頁面。因而所有的頁面跳轉(zhuǎn)都需要通過javascript實現(xiàn)。當需要根據(jù)用戶操作展示不同的頁面時,我們就需要根據(jù)訪問路徑使用js控制頁面展示內(nèi)容。
2、React-router 簡介
React Router 是專為 React 設計的路由解決方案。它利用HTML5 的history API,來操作瀏覽器的 session history (會話歷史)。
3、使用
React Router被拆分成四個包:react-router,react-router-dom,react-router-native和react-router-config。react-router提供核心的路由組件與函數(shù)。react-router-config用來配置靜態(tài)路由(還在開發(fā)中),其余兩個則提供了運行環(huán)境(瀏覽器與react-native)所需的特定組件。
進行網(wǎng)站(將會運行在瀏覽器環(huán)境中)構建,我們應當安裝react-router-dom。因為react-router-dom已經(jīng)暴露出react-router中暴露的對象與方法,因此你只需要安裝并引用react-router-dom即可。
4、相關組件
4-1、
使用了 HTML5 的 history API (pushState, replaceState and the popstate event) 用于保證你的地址欄信息與界面保持一致。
主要屬性:
basename:設置根路徑
getUserConfirmation:獲取用戶確認的函數(shù)
forceRefresh:是否刷新整個頁面
keyLength:location.key的長度
children:子節(jié)點(單個)
4-2、
為舊版本瀏覽器開發(fā)的組件,通常簡易使用BrowserRouter。
4-3、
為項目提供聲明性的、可訪問的導航
主要屬性:
to:可以是一個字符串表示目標路徑,也可以是一個對象,包含四個屬性:
replace:是否替換整個歷史棧
innerRef:訪問部件的底層引用
同時支持所有a標簽的屬性例如className,title等等
4-4、
React-router 中最重要的組件,最主要的職責就是根據(jù)匹配的路徑渲染指定的組件
主要屬性:
path:需要匹配的路徑
component:需要渲染的組件
render:渲染組件的函數(shù)
children :渲染組件的函數(shù),常用在path無法匹配時呈現(xiàn)的'空'狀態(tài)即所謂的默認顯示狀態(tài)
4-5、
重定向組件
主要屬性: to:指向的路徑
嵌套組件:唯一的渲染匹配路徑的第一個子
引言
在之前的版本中,React Router 也提供了類似的 onEnter
鉤子,但在 React Router 4.0 版本中,取消了這個方法。React Router 4.0 采用了聲明式的組件,路由即組件,要實現(xiàn)路由守衛(wèi)功能,就得我們自己去寫了。
1、react-router-config 是一個幫助我們配置靜態(tài)路由的小助手。其源碼就是一個高階函數(shù) 利用一個map函數(shù)生成靜態(tài)路由
import React from "react"; import Switch from "react-router/Switch"; import Route from "react-router/Route"; const renderRoutes = (routes, extraProps = {}, switchProps = {}) => routes ? ({routes.map((route, i) => ( ) : null; export default renderRoutes;( )} /> ))}
//router.js 假設這是我們設置的路由數(shù)組(這種寫法和vue很相似是不是?)
const routes = [ { path: '/', exact: true, component: Home, }, { path: '/login', component: Login, }, { path: '/user', component: User, }, { path: '*', component: NotFound } ]
//app.js 那么我們在app.js里這么使用就能幫我生成靜態(tài)的路由了
import { renderRoutes } from 'react-router-config' import routes from './router.js' const App = () => () export default App {renderRoutes(routes)}
用過vue的小朋友都知道,vue的router.js 里面添加 meta: { requiresAuth: true }
然后利用 導航守衛(wèi)
router.beforeEach((to, from, next) => { // 在每次路由進入之前判斷requiresAuth的值,如果是true的話呢就先判斷是否已登陸 })
2、基于類似vue的路由鑒權想法,我們稍稍改造一下react-router-config
// utils/renderRoutes.js
import React from 'react' import { Route, Redirect, Switch } from 'react-router-dom' const renderRoutes = (routes, authed, authPath = '/login', extraProps = {}, switchProps = {}) => routes ? ({routes.map((route, i) => ( ) : null export default renderRoutes{ if (!route.requiresAuth || authed || route.path === authPath) { return } return }} /> ))}
修改后的源碼增加了兩個參數(shù) authed 、 authPath 和一個屬性 route.requiresAuth
然后再來看一下最關鍵的一段代碼
if (!route.requiresAuth || authed || route.path === authPath) { return} return
很簡單 如果 route.requiresAuth = false 或者 authed = true 或者 route.path === authPath(參數(shù)默認值'/login')則渲染我們頁面,否則就渲染我們設置的 authPath 頁面,并記錄從哪個頁面跳轉(zhuǎn)。
相應的router.js也要稍微修改一下
const routes = [ { path: '/', exact: true, component: Home, requiresAuth: false, }, { path: '/login', component: Login, requiresAuth: false, }, { path: '/user', component: User, requiresAuth: true, //需要登陸后才能跳轉(zhuǎn)的頁面 }, { path: '*', component: NotFound, requiresAuth: false, } ]
//app.js
import React from 'react' import { Switch } from 'react-router-dom' //import { renderRoutes } from 'react-router-config' import renderRoutes from './utils/renderRoutes' import routes from './router.js' const authed = false // 如果登陸之后可以利用redux修改該值(關于redux不在我們這篇文章的討論范圍之內(nèi)) const authPath = '/login' // 默認未登錄的時候返回的頁面,可以自行設置 const App = () => () export default App {renderRoutes(routes, authed, authPath)}
//登陸之后返回原先要去的頁面login函數(shù) login(){ const { from } = this.props.location.state || { from: { pathname: '/' } } // authed = true // 這部分邏輯自己寫吧。。。 this.props.history.push(from.pathname) }
到此 react-router-config
就結束了并完成了我們想要的效果
3、注意:
很多人會發(fā)現(xiàn),有時候達不到我們想要的效果,那么怎么辦呢,接著往下看
1、設計全局組建來管理是否登陸
configLogin.js
import React, { Component } from 'react' import PropTypes from 'prop-types' import { withRouter } from 'react-router-dom' class App extends Component { static propTypes = { children: PropTypes.object, location: PropTypes.object, isLogin: PropTypes.bool, history: PropTypes.object }; componentDidMount () { if (!this.props.isLogin) { setTimeout(() => { this.props.history.push('/login') }, 300) } if (this.props.isLogin && this.props.location.pathname === '/login') { setTimeout(() => { this.props.history.push('/') }, 300) } } componentDidUpdate () { if (!this.props.isLogin) { setTimeout(() => { this.props.history.push('/login') }, 300) } } render () { return this.props.children } } export default withRouter(App)
通過在主路由模塊index.js中引入
import { BrowserRouter as Router, Redirect, Route, Switch } from 'react-router-dom'} />
很多時候我們是可以通過監(jiān)聽路由變化實現(xiàn)的比如 getUserConfirmation
鉤子就是做這件事情的
const getConfirmation = (message, callback) => { if (!isLogin) { message.push('/login') } else { message.push(message.location.pathname) }
接下來我們看一下 react-acl-router
又是怎么實現(xiàn)的
本節(jié)參考代碼:
react-acl-router
react-boilerplate-pro/src/app/init/router.js
react-boilerplate-pro/src/app/config/routes.js
權限管理作為企業(yè)管理系統(tǒng)中非常核心的一個部分,一直以來因為業(yè)務方很多時候無法使用準確的術語來描述需求成為了困擾開發(fā)者們的一大難題。這里我們先來介紹兩種常見的權限管理設計模式,即基于角色的訪問控制以及訪問控制列表。
1、布局與路由
在討論具體的布局組件設計前,我們首先要解決一個更為基礎的問題,那就是如何將布局組件與應用路由結合起來。
下面的這個例子是 react-router
官方提供的側(cè)邊欄菜單與路由結合的例子,筆者這里做了一些簡化:
const SidebarExample = () => ();
- Home
- Bubblegum
- Shoelaces
{routes.map((route, index) => ())}
抽象為布局的思想,寫成簡單的偽代碼就是:
// with sidebar {routes.map(route => ( ))}
這樣的確是一種非常優(yōu)雅的解決方案,但它的局限性在于無法支持多種不同的布局。受限于一個 Router
只能包含一個子組件,即使我們將多個布局組件包裹在一個容器組件中,如:
// with sidebar {routes.map(route => ( )} // with footer {routes.map(route => ( )}
路由在匹配到 FlexLayout
下的頁面時, BasicLayout
中的 sidebar
也會同時顯示出來,這顯然不是我們想要的結果。換個思路,我們可不可以將布局組件當做 children
直接傳給更底層的 Route
組件呢?代碼如下:
{basicLayoutRoutes.map(route => ())} {flexLayoutRoutes.map(route => ( ))}
這里我們將不同的布局組件當做高階組件,相應地包裹在了不同的頁面組件上,這樣就實現(xiàn)了對多種不同布局的支持。還有一點需要注意的是, react-router
默認會將 match
、 location
、 history
等路由信息傳遞給 Route
的下一級組件,由于在上述方案中, Route
的下一級組件并不是真正的頁面組件而是布局組件,因而我們需要在布局組件中手動將這些路由信息傳遞給頁面組件,或者統(tǒng)一改寫 Route
的 render
方法為:
( // props contains match, location, history )} />
另外一個可能會遇到的問題是, connected-react-router
并不會將路由中非常重要的 match
對象(包含當前路由的 params
等數(shù)據(jù) )同步到 redux store 中,所以我們一定要保證布局及頁面組件在路由部分就可以接收到 match
對象,否則在后續(xù)處理頁面頁眉等與當前路由參數(shù)相關的需求時就會變得非常麻煩。
2、頁眉 & 頁腳
解決了與應用路由相結合的問題,具體到布局組件內(nèi)部,其中最重要的兩部分就是頁面的頁眉和頁腳部分,而頁眉又可以分為應用頁眉與頁面頁眉兩部分。
應用頁眉指的是整個應用層面的頁眉,與具體的頁面無關,一般來說會包含用戶頭像、通知欄、搜索框、多語言切換等這些應用級別的信息與操作。頁面頁眉則一般來講會包含頁面標題、面包屑導航、頁面通用操作等與具體頁面相關的內(nèi)容。
在以往的項目中,尤其是在項目初期許多開發(fā)者因為對項目本身還沒有一個整體的認識,很多時候會傾向于將應用頁眉做成一個展示型組件并在不同的頁面中直接調(diào)用。這樣做當然有其方便之處,比如說頁面與布局之間的數(shù)據(jù)同步環(huán)節(jié)就被省略掉了,每個頁面都可以直接向頁眉傳遞自己內(nèi)部的數(shù)據(jù)。
但從理想的項目架構角度來講這樣做卻是一個 反模式(anti-pattern) 。因為應用頁眉實際是一個應用級別的組件,但按照上述做法的話卻變成了一個頁面級別的組件,偽代碼如下:
從應用數(shù)據(jù)流的角度來講也存在著同樣的問題,那就是應用頁眉應該是向不同的頁面去傳遞數(shù)據(jù)的,而不是反過來去接收來自頁面的數(shù)據(jù)。這導致應用頁眉喪失了控制自己何時 rerender(重繪) 的機會,作為一個純展示型組件,一旦接收到的 props 發(fā)生變化頁眉就需要進行一次重繪。
另一方面,除了通用的應用頁眉外,頁面頁眉與頁面路由之間是有著嚴格的一一對應的關系的,那么我們能不能將頁面頁眉部分的配置也做到路由配置中去,以達到新增加一個頁面時只需要在 config/routes.js
中多配置一個路由對象就可以完成頁面頁眉部分的創(chuàng)建呢?理想情況下的偽代碼如下:
// with app & page header already
1、配置優(yōu)于代碼
在過去關于組件庫的討論中我們曾經(jīng)得出過代碼優(yōu)于配置的結論,即需要使用者自定義的部分,應該盡量拋出回調(diào)函數(shù)讓使用者可以使用代碼去控制自定義的需求。這是因為組件作為極細粒度上的抽象,配置式的使用模式往往很難滿足使用者多變的需求。但在企業(yè)管理系統(tǒng)中,作為一個應用級別的解決方案,能使用配置項解決的問題我們都應該盡量避免讓使用者編寫代碼。
配置項(配置文件)天然就是一種集中式的管理模式,可以極大地降低應用復雜度。以頁眉為例來說,如果我們每個頁面文件中都調(diào)用了頁眉組件,那么一旦頁眉組件出現(xiàn)問題我們就需要修改所有用到頁眉組件頁面的代碼。除去 debug 的情況外,哪怕只是修改一個頁面標題這樣簡單的需求,開發(fā)者也需要先找到這個頁面相對應的文件,并在其 render
函數(shù)中進行修改。這些隱性成本都是我們在設計企業(yè)管理系統(tǒng)解決方案時需要注意的,因為就是這樣一個個的小細節(jié)造成了本身并不復雜的企業(yè)管理系統(tǒng)在維護、迭代了一段時間后應用復雜度陡增。理想情況下,一個優(yōu)秀的企業(yè)管理系統(tǒng)解決方案應該可以做到 80% 以上非功能性需求變更都可以使用修改配置文件的方式解決。
2、配置式頁眉
import { matchRoutes } from 'react-router-config'; // routes config const routes = [{ path: '/outlets', exact: true, permissions: ['admin', 'user'], component: Outlets, unauthorized: Unauthorized, pageTitle: '門店管理', breadcrumb: ['/outlets'], }, { path: '/outlets/:id', exact: true, permissions: ['admin', 'user'], component: OutletDetail, unauthorized: Unauthorized, pageTitle: '門店詳情', breadcrumb: ['/outlets', '/outlets/:id'], }]; // find current route object const pathname = get(state, 'router.location.pathname', ''); const { route } = head((matchRoutes(routes, pathname)));
基于這樣一種思路,我們可以在通用的布局組件中根據(jù)當前頁面的 pathname
使用 react-router-config
提供的 matchRoutes
方法來獲取到當前頁面 route
對象的所有配置項,也就意味著我們可以對所有的這些配置項做統(tǒng)一的處理。這不僅為處理通用邏輯帶來了方便,同時對于編寫頁面代碼的同事來說也是一種約束,能夠讓不同開發(fā)者寫出的代碼帶有更少的個人色彩,方便對于代碼庫的整體管理。
3、頁面標題
renderPageHeader = () => { const { prefixCls, route: { pageTitle }, intl } = this.props; if (isEmpty(pageTitle)) { return null; } const pageTitleStr = intl.formatMessage({ id: pageTitle }); return ({this.renderBreadcrumb()}); }{pageTitleStr}
4、面包屑導航
renderBreadcrumb = () => { const { route: { breadcrumb }, intl, prefixCls } = this.props; const breadcrumbData = generateBreadcrumb(breadcrumb); return ({map(breadcrumbData, (item, idx) => ( idx === breadcrumbData.length - 1 ? ); }{intl.formatMessage({ id: item.text })} :{intl.formatMessage({ id: item.text })} ))}
3、設計策略
1、基于角色的訪問控制
基于角色的訪問控制不直接將系統(tǒng)操作的各種權限賦予具體用戶,而是在用戶與權限之間建立起角色集合,將權限賦予角色再將角色賦予用戶。這樣就實現(xiàn)了對于權限和角色的集中管理,避免用戶與權限之間直接產(chǎn)生復雜的多對多關系。
2、訪問控制列表
具體到角色與權限之間,訪問控制列表指代的是某個角色所擁有的系統(tǒng)權限列表。在傳統(tǒng)計算機科學中,權限一般指的是對于文件系統(tǒng)進行增刪改查的權力。而在 Web 應用中,大部分系統(tǒng)只需要做到頁面級別的權限控制即可,簡單來說就是根據(jù)當前用戶的角色來決定其是否擁有查看當前頁面的權利。
下面就讓我們按照這樣的思路實現(xiàn)一個基礎版的包含權限管理功能的應用路由。
4、實戰(zhàn)代碼
1、路由容器
在編寫權限管理相關的代碼前,我們需要先為所有的頁面路由找到一個合適的容器,即 react-router
中的 Switch
組件。與多個獨立路由不同的是,包裹在 Switch
中的路由每次只會渲染路徑匹配成功的第一個,而不是所有符合路徑匹配條件的路由。
以上面兩段代碼為例,如果當前頁面路徑是 /about
的話,因為
、
及
這三個路由的路徑都符合 /about
,所以它們會同時被渲染在當前頁面。而將它們包裹在 Switch
中后, react-router
在找到第一個符合條件的
路由后就會停止查找直接渲染
組件。
在企業(yè)管理系統(tǒng)中因為頁面與頁面之間一般都是平行且排他的關系,所以利用好 Switch
這個特性對于我們簡化頁面渲染邏輯有著極大的幫助。
另外值得一提的是,在 react-router
作者 Ryan Florence 的新作@reach/router 中, Switch
的這一特性被默認包含了進去,而且 @reach/router
會自動匹配最符合當前路徑的路由。這就使得使用者不必再去擔心路由的書寫順序,感興趣的朋友可以關注一下。
2、權限管理
現(xiàn)在我們的路由已經(jīng)有了一個大體的框架,下面就讓我們?yōu)槠涮砑泳唧w的權限判斷邏輯。
對于一個應用來說,除去需要鑒權的頁面外,一定還存在著不需要鑒權的頁面,讓我們先將這些頁面添加到我們的路由中,如登錄頁。
對于需要鑒權的路由,我們需要先抽象出一個判斷當前用戶是否有權限的函數(shù)來作為判斷依據(jù),而根據(jù)具體的需求,用戶可以擁有單個角色或多個角色,抑或更復雜的一個鑒權函數(shù)。這里筆者提供一個最基礎的版本,即我們將用戶的角色以字符串的形式存儲在后臺,如一個用戶的角色是 admin,另一個用戶的角色是 user。
import isEmpty from 'lodash/isEmpty'; import isArray from 'lodash/isArray'; import isString from 'lodash/isString'; import isFunction from 'lodash/isFunction'; import indexOf from 'lodash/indexOf'; const checkPermissions = (authorities, permissions) => { if (isEmpty(permissions)) { return true; } if (isArray(authorities)) { for (let i = 0; i < authorities.length; i += 1) { if (indexOf(permissions, authorities[i]) !== -1) { return true; } } return false; } if (isString(authorities)) { return indexOf(permissions, authorities) !== -1; } if (isFunction(authorities)) { return authorities(permissions); } throw new Error('[react-acl-router]: Unsupport type of authorities.'); }; export default checkPermissions;
在上面我們提到了路由的配置文件,這里我們?yōu)槊恳粋€需要鑒權的路由再添加一個屬性 permissions
,即哪些角色可以訪問該頁面。
const routes = [{ path: '/outlets', exact: true, permissions: ['admin', 'user'], component: Outlets, unauthorized: Unauthorized, pageTitle: 'Outlet Management', breadcrumb: ['/outlets'], }, { path: '/outlets/:id', exact: true, permissions: ['admin'], component: OutletDetail, redirect: '/', pageTitle: 'Outlet Detail', breadcrumb: ['/outlets', '/outlets/:id'], }];
在上面的配置中,admin 和 user 都可以訪問門店列表頁面,但只有 admin 才可以訪問門店詳情頁面。
對于沒有權限查看當前頁面的情況,一般來講有兩種處理方式,一是直接重定向到另一個頁面(如首頁),二是渲染一個無權限頁面,提示用戶因為沒有當前頁面的權限所以無法查看。二者是排他的,即每個頁面只需要使用其中一種即可,于是我們在路由配置中可以根據(jù)需要去配置 redirect
或 unauthorized
屬性,分別對應 無權限重定向 及 無權限顯示無權限頁面 兩種處理方式。具體代碼大家可以參考示例項目 react-acl-router 中的實現(xiàn),這里摘錄一小段核心部分。
renderRedirectRoute = route => (} /> ); renderAuthorizedRoute = (route) => { const { authorizedLayout: AuthorizedLayout } = this.props; const { authorities } = this.state; const { permissions, path, component: RouteComponent, unauthorized: Unauthorized, } = route; const hasPermission = checkPermissions(authorities, permissions); if (!hasPermission && route.unauthorized) { return ( ( )} /> ); } if (!hasPermission && route.redirect) { return this.renderRedirectRoute(route); } return ( ( )} /> ); }
于是,在最終的路由中,我們會優(yōu)先匹配無需鑒權的頁面路徑,保證所有用戶在訪問無需鑒權的頁面時,第一時間就可以看到頁面。然后再去匹配需要鑒權的頁面路徑,最終如果所有的路徑都匹配不到的話,再渲染 404 頁面告知用戶當前頁面路徑不存在。
需要鑒權的路由和不需要鑒權的路由作為兩種不同的頁面,一般而言它們的頁面布局也是不同的。如登錄頁面使用的就是普通頁面布局:
在這里我們可以將不同的頁面布局與鑒權邏輯相結合以達到只需要在路由配置中配置相應的屬性,新增加的頁面就可以同時獲得鑒權邏輯和基礎布局的效果。這將極大地提升開發(fā)者們的工作效率,尤其是對于項目組的新成員來說純配置的上手方式是最友好的。
5、應用集成
至此一個包含基礎權限管理的應用路由就大功告成了,我們可以將它抽象為一個獨立的路由組件,使用時只需要配置需要鑒權的路由和不需要鑒權的路由兩部分即可。
const authorizedRoutes = [{ path: '/outlets', exact: true, permissions: ['admin', 'user'], component: Outlets, unauthorized: Unauthorized, pageTitle: 'pageTitle_outlets', breadcrumb: ['/outlets'], }, { path: '/outlets/:id', exact: true, permissions: ['admin', 'user'], component: OutletDetail, unauthorized: Unauthorized, pageTitle: 'pageTitle_outletDetail', breadcrumb: ['/outlets', '/outlets/:id'], }, { path: '/exception/403', exact: true, permissions: ['god'], component: WorkInProgress, unauthorized: Unauthorized, }]; const normalRoutes = [{ path: '/', exact: true, redirect: '/outlets', }, { path: '/login', exact: true, component: Login, }]; const Router = props => (); const mapStateToProps = state => ({ user: state.app.user, }); Router.propTypes = propTypes; export default connect(mapStateToProps)(Router); // the router component
在實際項目中,我們可以使用 react-redux
提供的 connect
組件將應用路由 connect 至 redux store,以方便我們直接讀取當前用戶的角色信息。一旦登錄用戶的角色發(fā)生變化,客戶端路由就可以進行相應的判斷與響應。
6、組合式開發(fā):權限管理
對于頁面級別的權限管理來說,權限管理部分的邏輯是獨立于頁面的,是與頁面中的具體內(nèi)容無關的。也就是說,權限管理部分的代碼并不應該成為頁面中的一部分,而是應該在拿到用戶權限后創(chuàng)建應用路由時就將沒有權限的頁面替換為重定向或無權限頁面。
這樣一來,頁面部分的代碼就可以實現(xiàn)與權限管理邏輯的徹底解耦,以至于如果抽掉權限管理這一層后,頁面就變成了一個無需權限判斷的頁面依然可以獨立運行。而通用部分的權限管理代碼也可以在根據(jù)業(yè)務需求微調(diào)后服務于更多的項目。
7、小結
文中我們從權限管理的基礎設計思想講起,實現(xiàn)了一套基于角色的頁面級別的應用權限管理系統(tǒng)并分別討論了無權限重定向及無權限顯示無權限頁面兩種無權限查看時的處理方法。
接下來我們來看一下多級菜單是如何實現(xiàn)的
本節(jié)參考代碼:
react-sider
在大部分企業(yè)管理系統(tǒng)中,頁面的基礎布局所采取的一般都是側(cè)邊欄菜單加頁面內(nèi)容這樣的組織形式。在成熟的組件庫支持下,UI 層面想要做出一個漂亮的側(cè)邊欄菜單并不困難,但因為在企業(yè)管理系統(tǒng)中菜單還承擔著頁面導航的功能,于是就導致了兩大難題,一是多級菜單如何處理,二是菜單項的子頁面(如點擊門店管理中的某一個門店進入的門店詳情頁在菜單中并沒有對應的菜單項)如何高亮其隸屬于的父級菜單。
1、多級菜單
為了增強系統(tǒng)的可擴展性,企業(yè)管理系統(tǒng)中的菜單一般都需要提供多級支持,對應的數(shù)據(jù)結構就是在每一個菜單項中都要有 children 屬性來配置下一級菜單項。
const menuData = [{ name: '儀表盤', icon: 'dashboard', path: 'dashboard', children: [{ name: '分析頁', path: 'analysis', children: [{ name: '實時數(shù)據(jù)', path: 'realtime', }, { name: '離線數(shù)據(jù)', path: 'offline', }], }], }];
遞歸渲染父菜單及子菜單
想要支持多級菜單,首先要解決的問題就是如何統(tǒng)一不同級別菜單項的交互。
在大多數(shù)的情況下,每一個菜單項都代表著一個不同的頁面路徑,點擊后會觸發(fā) url 的變化并跳轉(zhuǎn)至相應頁面,也就是上面配置中的 path 字段。
但對于一個父菜單來說,點擊還意味著打開或關閉相應的子菜單,這就與點擊跳轉(zhuǎn)頁面發(fā)生了沖突。為了簡化這個問題,我們先統(tǒng)一菜單的交互為點擊父菜單(包含 children 屬性的菜單項)為打開或關閉子菜單,點擊子菜單(不包含 children 屬性的菜單項)為跳轉(zhuǎn)至相應頁面。
首先,為了成功地渲染多級菜單,菜單的渲染函數(shù)是需要支持遞歸的,即如果當前菜單項含有 children 屬性就將其渲染為父菜單并優(yōu)先渲染其 children 字段下的子菜單,這在算法上被叫做深度優(yōu)先遍歷。
renderMenu = data => ( map(data, (item) => { if (item.children) { return (); } return ( {item.name} } > {this.renderMenu(item.children)} ); }) ) {item.name}
這樣我們就擁有了一個支持多級展開、子菜單分別對應頁面路由的側(cè)邊欄菜單。細心的朋友可能還發(fā)現(xiàn)了,雖然父菜單并不對應一個具體的路由但在配置項中依然還有 path 這個屬性,這是為什么呢?
2、處理菜單高亮
在傳統(tǒng)的企業(yè)管理系統(tǒng)中,為不同的頁面配置頁面路徑是一件非常痛苦的事情,對于頁面路徑,許多開發(fā)者唯一的要求就是不重復即可,如上面的例子中,我們把菜單數(shù)據(jù)配置成這樣也是可以的。
const menuData = [{ name: '儀表盤', icon: 'dashboard', children: [{ name: '分析頁', children: [{ name: '實時數(shù)據(jù)', path: '/realtime', }, { name: '離線數(shù)據(jù)', path: '/offline', }], }], }];} }
用戶在點擊菜單項時一樣可以正確地跳轉(zhuǎn)到相應頁面。但這樣做的一個致命缺陷就是,對于 /realtime
這樣一個路由,如果只根據(jù)當前的 pathname
去匹配菜單項中 path
屬性的話,要怎樣才能同時也匹配到「分析頁」與「儀表盤」呢?因為如果匹配不到的話,「分析頁」和「儀表盤」就不會被高亮了。我們能不能在頁面的路徑中直接體現(xiàn)出菜單項之間的繼承關系呢?來看下面這個工具函數(shù)。
import map from 'lodash/map'; const formatMenuPath = (data, parentPath = '/') => ( map(data, (item) => { const result = { ...item, path: `${parentPath}${item.path}`, }; if (item.children) { result.children = formatMenuPath(item.children, `${parentPath}${item.path}/`); } return result; }) );
這個工具函數(shù)把菜單項中可能有的 children
字段考慮了進去,將一開始的菜單數(shù)據(jù)傳入就可以得到如下完整的菜單數(shù)據(jù)。
[{ name: '儀表盤', icon: 'dashboard', path: '/dashboard', // before is 'dashboard' children: [{ name: '分析頁', path: '/dashboard/analysis', // before is 'analysis' children: [{ name: '實時數(shù)據(jù)', path: '/dashboard/analysis/realtime', // before is 'realtime' }, { name: '離線數(shù)據(jù)', path: '/dashboard/analysis/offline', // before is 'offline' }], }], }];
然后讓我們再對當前頁面的路由做一下逆向推導,即假設當前頁面的路由為 /dashboard/analysis/realtime
,我們希望可以同時匹配到 ['/dashboard', '/dashboard/analysis', '/dashboard/analysis/realtime']
,方法如下:
import map from 'lodash/map'; const urlToList = (url) => { if (url) { const urlList = url.split('/').filter(i => i); return map(urlList, (urlItem, index) => `/${urlList.slice(0, index + 1).join('/')}`); } return []; };
上面的這個數(shù)組代表著不同級別的菜單項,將這三個值分別與菜單數(shù)據(jù)中的 path
屬性進行匹配就可以一次性地匹配到所有當前頁面應當被高亮的菜單項了。
這里需要注意的是,雖然菜單項中的 path
一般都是普通字符串,但有些特殊的路由也可能是正則的形式,如 /outlets/:id
。所以我們在對二者進行匹配時,還需要引入 path-to-regexp
這個庫來處理類似 /outlets/1
和 /outlets/:id
這樣的路徑。又因為初始時菜單數(shù)據(jù)是樹形結構的,不利于進行 path
屬性的匹配,所以我們還需要先將樹形結構的菜單數(shù)據(jù)扁平化,然后再傳入 getMeunMatchKeys
中。
import pathToRegexp from 'path-to-regexp'; import reduce from 'lodash/reduce'; import filter from 'lodash/filter'; const getFlatMenuKeys = menuData => ( reduce(menuData, (keys, item) => { keys.push(item.path); if (item.children) { return keys.concat(getFlatMenuKeys(item.children)); } return keys; }, []) ); const getMeunMatchKeys = (flatMenuKeys, paths) => reduce(paths, (matchKeys, path) => ( matchKeys.concat(filter(flatMenuKeys, item => pathToRegexp(item).test(path))) ), []);
在這些工具函數(shù)的幫助下,多級菜單的高亮也不再是問題了。
3、知識點:記憶化(Memoization)
在側(cè)邊欄菜單中,有兩個重要的狀態(tài):一個是 selectedKeys
,即當前選定的菜單項;另一個是 openKeys
,即多個多級菜單的打開狀態(tài)。這二者的含義是不同的,因為在 selectedKeys
不變的情況下,用戶在打開或關閉其他多級菜單后, openKeys
是會發(fā)生變化的,如下面二圖所示, selectedKeys
相同但 openKeys
不同。
對于 selectedKeys
來說,由于它是由頁面路徑( pathname
)決定的,所以每一次 pathname
發(fā)生變化都需要重新計算 selectedKeys
的值。又因為通過 pathname
以及最基礎的菜單數(shù)據(jù) menuData
去計算 selectedKeys
是一件非常昂貴的事情(要做許多數(shù)據(jù)格式處理和計算),有沒有什么辦法可以優(yōu)化一下這個過程呢?
Memoization 可以賦予普通函數(shù)記憶輸出結果的功能,它會在每次調(diào)用函數(shù)之前檢查傳入的參數(shù)是否與之前執(zhí)行過的參數(shù)完全相同,如果完全相同則直接返回上次計算過的結果,就像常用的緩存一樣。
import memoize from 'memoize-one'; constructor(props) { super(props); this.fullPathMenuData = memoize(menuData => formatMenuPath(menuData)); this.selectedKeys = memoize((pathname, fullPathMenu) => ( getMeunMatchKeys(getFlatMenuKeys(fullPathMenu), urlToList(pathname)) )); const { pathname, menuData } = props; this.state = { openKeys: this.selectedKeys(pathname, this.fullPathMenuData(menuData)), }; }
在組件的構造器中我們可以根據(jù)當前 props 傳來的 pathname
及 menuData
計算出當前的 selectedKeys
并將其當做 openKeys
的初始值初始化組件內(nèi)部 state。因為 openKeys
是由用戶所控制的,所以對于后續(xù) openKeys
值的更新我們只需要配置相應的回調(diào)將其交給 Menu
組件控制即可。
import Menu from 'antd/lib/menu'; handleOpenChange = (openKeys) => { this.setState({ openKeys, }); };
這樣我們就實現(xiàn)了對于 selectedKeys
及 openKeys
的分別管理,開發(fā)者在使用側(cè)邊欄組件時只需要將應用當前的頁面路徑同步到側(cè)邊欄組件中的 pathname
屬性即可,側(cè)邊欄組件會自動處理相應的菜單高亮( selectedKeys
)和多級菜單的打開與關閉( openKeys
)。
4、知識點:正確區(qū)分 prop 與 state
上述這個場景也是一個非常經(jīng)典的關于如何正確區(qū)分 prop 與 state 的例子。
selectedKeys
由傳入的 pathname
決定,于是我們就可以將 selectedKeys
與 pathname
之間的轉(zhuǎn)換關系封裝在組件中,使用者只需要傳入正確的 pathname
就可以獲得相應的 selectedKeys
而不需要關心它們之間的轉(zhuǎn)換是如何完成的。而 pathname
作為組件渲染所需的基礎數(shù)據(jù),組件無法從自身內(nèi)部獲得,所以就需要使用者通過 props 將其傳入進來。
另一方面, openKeys
作為組件內(nèi)部的 state,初始值可以由 pathname
計算而來,后續(xù)的更新則與組件外部的數(shù)據(jù)無關而是會根據(jù)用戶的操作在組件內(nèi)部完成,那么它就是一個 state,與其相關的所有邏輯都可以徹底地被封裝在組件內(nèi)部而不需要暴露給使用者。
簡而言之,一個數(shù)據(jù)如果想成為 prop 就必須是組件內(nèi)部無法獲得的,而且在它成為了 prop 之后,所有可以根據(jù)它的值推導出來的數(shù)據(jù)都不再需要成為另外的 props,否則將違背 React 單一數(shù)據(jù)源的原則。對于 state 來說也是同樣,如果一個數(shù)據(jù)想成為 state,那么它就不應該再能夠被組件外部的值所改變,否則也會違背單一數(shù)據(jù)源的原則而導致組件的表現(xiàn)不可預測,產(chǎn)生難解的 bug。
5、組合式開發(fā):應用菜單
嚴格來說,在這一小節(jié)中著重探討的應用菜單部分的思路并不屬于組合式開發(fā)思想的范疇,更多地是如何寫出一個支持無限級子菜單及自動匹配當前路由的菜單組件。組件當然是可以隨意插拔的,但前提是應用該組件的父級部分不依賴于組件所提供的信息。這也是我們在編寫組件時所應當遵循的一個規(guī)范,即組件可以從外界獲取信息并在此基礎上進行組件內(nèi)部的邏輯判斷。但當組件向其外界拋出信息時,更多的時候應該是以回調(diào)的形式讓調(diào)用者去主動觸發(fā),然后更新外部的數(shù)據(jù)再以 props 的形式傳遞給組件以達到更新組件的目的,而不是強制需要在外部再配置一個回調(diào)的接收函數(shù)去直接改變組件的內(nèi)部狀態(tài)。
從這點上來說,組合式開發(fā)與組件封裝其實是有著異曲同工之妙的,關鍵都在于對內(nèi)部狀態(tài)的嚴格控制。不論一個模塊或一個組件需要向外暴露多少接口,在它的內(nèi)部都應該是解決了某一個或某幾個具體問題的。就像工廠產(chǎn)品生產(chǎn)流水線上的一個環(huán)節(jié),在經(jīng)過了這一環(huán)節(jié)后產(chǎn)品相較于進入前一定產(chǎn)生了某種區(qū)別,不論是增加了某些功能還是被打上某些標簽,產(chǎn)品一定會變得更利于下游合作者使用。更理想的情況則是即使刪除掉了這一環(huán)節(jié),原來這一環(huán)節(jié)的上下游依然可以無縫地銜接在一起繼續(xù)工作,這就是我們所說的模塊或者說組件的可插拔性。
在前后端分離架構的背景下,前端已經(jīng)逐漸代替后端接管了所有固定路由的判斷與處理,但在動態(tài)路由這樣一個場景下,我們會發(fā)現(xiàn)單純前端路由服務的靈活度是遠遠不夠的。在用戶到達某個頁面后,可供下一步邏輯判斷的依據(jù)就只有當前頁面的 url,而根據(jù) url 后端的路由服務是可以返回非常豐富的數(shù)據(jù)的。
常見的例子如頁面的類型。假設應用中營銷頁和互動頁的渲染邏輯并不相同,那么在頁面的 DSL 數(shù)據(jù)之外,我們就還需要獲取到頁面的類型以進行相應的渲染。再比如頁面的 SEO 數(shù)據(jù),創(chuàng)建和更新時間等等,這些數(shù)據(jù)都對應用能夠在前端靈活地展示頁面,處理業(yè)務邏輯有著巨大的幫助。
甚至我們還可以推而廣之,徹底拋棄掉由 react-router 等提供的前端路由服務,轉(zhuǎn)而寫一套自己的路由分發(fā)器,即根據(jù)頁面類型的不同分別調(diào)用不同的頁面渲染服務,以多種類型頁面的方式來組成一個完整的前端應用。
為了解決大而全的方案在實踐中不夠靈活的問題,我們是不是可以將其中包含的各個模塊解耦后,獨立發(fā)布出來供開發(fā)者們按需取用呢?讓我們先來看一段理想中完整的企業(yè)管理系統(tǒng)應用架構部分的偽代碼:
const App = props => (// react-redux bind );// react-router-redux bind // intl support // router with access control list // route that doesn't need authentication // layout component // page content (view component) ... // more routes that don't need authentication // route that needs authentication ... // more routes that need authentication// hoc for user login check // layout component // sider menu // page header // page content (view component) // page footer 404} /> // 404 page
在上面的這段偽代碼中,我們抽象出了多語言支持、基于路由的權限管理、登錄鑒權、基礎布局、側(cè)邊欄菜單等多個獨立模塊,可以根據(jù)需求添加或刪除任意一個模塊,而且添加或刪除任意一個模塊都不會對應用的其他部分產(chǎn)生不可接受的副作用。這讓我們對接下來要做的事情有了一個大體的認識,但在具體的實踐中,如 props 如何傳遞、模塊之間如何共享數(shù)據(jù)、如何靈活地讓用戶自定義某些特殊邏輯等都仍然面臨著巨大的挑戰(zhàn)。我們需要時刻注意,在處理一個具體問題時哪些部分應當放在某個獨立模塊內(nèi)部去處理,哪些部分應當暴露出接口供使用者自定義,模塊與模塊之間如何做到零耦合以至于使用者可以隨意插拔任意一個模塊去適應當前項目的需要。
從一個具體的前端應用直接切入開發(fā)技巧與理念的講解,所以對于剛?cè)腴T React 的朋友來說可能存在著一定的基礎知識部分梳理的缺失,這里為大家提供一份較為詳細的 React 開發(fā)者學習路線圖,希望能夠為剛?cè)腴T React 的朋友提供一條規(guī)范且便捷的學習之路。
到此react的路由鑒權映梳理完了歡迎大家轉(zhuǎn)發(fā)交流分享 轉(zhuǎn)載請注明出處 ,附帶一個近期相關項目案例代碼給大家一個思路:
react-router-config
以上就是本文的全部內(nèi)容,希望對大家的學習有所幫助,也希望大家多多支持創(chuàng)新互聯(lián)。