您好,登錄后才能下訂單哦!
上一篇文章中有同學提到路由鑒權,由于時間關系沒有寫,本文將針對這一特性對 vue
和 react
做專門說明,希望同學看了以后能夠受益匪淺,對你的項目能夠有所幫助,本文借鑒了很多大佬的文章篇幅也是比較長的。
單獨項目中是希望根據登錄人來看下這個人是不是有權限進入當前頁面。雖然服務端做了進行接口的權限,但是每一個路由加載的時候都要去請求這個接口太浪費了。有時候是通過SESSIONID來校驗登陸權限的。
在正式開始 react
路由鑒權之前我們先看一下vue的路由鑒權是如何工作的:
一般我們會相應的把路由表角色菜單配置在后端,當用戶未通過頁面菜單,直接從地址欄訪問非權限范圍內的url時,攔截用戶訪問并重定向到首頁。
vue
的初期是可以通過動態路由的方式,按照權限加載對應的路由表 AddRouter
,但是由于權限交叉,導致權限路由表要做判斷結合,想想還是挺麻煩的,所以采用的是在 beforeEach
里面直判斷用非動態路由的方式
在使用 Vue的時候,框架提供了路由守衛功能,用來在進入某個路有前進行一些校驗工作,如果校驗失敗,就跳轉到 404 或者登陸頁面,比如 Vue 中的 beforeEnter
函數:
... 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 }, { // 系統設置 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: [ { // 數據服務審批 path: 'api', name: 'PersonalCenterMyApproveApi', meta: { requireAuth: true, authRole: 'dataServiceAdmin' }, component: () => import(/* webpackChunkName: "personal-center" */ '@/views/personal-center/api-approve/index.vue') }, ... ] } ]
export default [ ... { // 數據服務設置 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, // 是否數據服務管理員 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、路由簡介
路由是干什么的?
根據不同的 url 地址展示不同的內容或頁面。
單頁面應用最大的特點就是只有一個 web 頁面。因而所有的頁面跳轉都需要通過javascript實現。當需要根據用戶操作展示不同的頁面時,我們就需要根據訪問路徑使用js控制頁面展示內容。
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提供核心的路由組件與函數。react-router-config用來配置靜態路由(還在開發中),其余兩個則提供了運行環境(瀏覽器與react-native)所需的特定組件。
進行網站(將會運行在瀏覽器環境中)構建,我們應當安裝react-router-dom。因為react-router-dom已經暴露出react-router中暴露的對象與方法,因此你只需要安裝并引用react-router-dom即可。
4、相關組件
4-1、
使用了 HTML5 的 history API (pushState, replaceState and the popstate event) 用于保證你的地址欄信息與界面保持一致。
主要屬性:
basename:設置根路徑
getUserConfirmation:獲取用戶確認的函數
forceRefresh:是否刷新整個頁面
keyLength:location.key的長度
children:子節點(單個)
4-2、
為舊版本瀏覽器開發的組件,通常簡易使用BrowserRouter。
4-3、
為項目提供聲明性的、可訪問的導航
主要屬性:
to:可以是一個字符串表示目標路徑,也可以是一個對象,包含四個屬性:
replace:是否替換整個歷史棧
innerRef:訪問部件的底層引用
同時支持所有a標簽的屬性例如className,title等等
4-4、
React-router 中最重要的組件,最主要的職責就是根據匹配的路徑渲染指定的組件
主要屬性:
path:需要匹配的路徑
component:需要渲染的組件
render:渲染組件的函數
children :渲染組件的函數,常用在path無法匹配時呈現的'空'狀態即所謂的默認顯示狀態
4-5、
重定向組件
主要屬性: to:指向的路徑
<Switch>
嵌套組件:唯一的渲染匹配路徑的第一個子 <Route> 或者 <Redirect>
引言
在之前的版本中,React Router 也提供了類似的 onEnter
鉤子,但在 React Router 4.0 版本中,取消了這個方法。React Router 4.0 采用了聲明式的組件,路由即組件,要實現路由守衛功能,就得我們自己去寫了。
1、react-router-config 是一個幫助我們配置靜態路由的小助手。其源碼就是一個高階函數 利用一個map函數生成靜態路由
import React from "react"; import Switch from "react-router/Switch"; import Route from "react-router/Route"; const renderRoutes = (routes, extraProps = {}, switchProps = {}) => routes ? ( <Switch {...switchProps}> {routes.map((route, i) => ( <Route key={route.key || i} path={route.path} exact={route.exact} strict={route.strict} render={props => ( <route.component {...props} {...extraProps} route={route} /> )} /> ))} </Switch> ) : null; export default renderRoutes;
//router.js 假設這是我們設置的路由數組(這種寫法和vue很相似是不是?)
const routes = [ { path: '/', exact: true, component: Home, }, { path: '/login', component: Login, }, { path: '/user', component: User, }, { path: '*', component: NotFound } ]
//app.js 那么我們在app.js里這么使用就能幫我生成靜態的路由了
import { renderRoutes } from 'react-router-config' import routes from './router.js' const App = () => ( <main> <Switch> {renderRoutes(routes)} </Switch> </main> ) export default App
用過vue的小朋友都知道,vue的router.js 里面添加 meta: { requiresAuth: true }
然后利用 導航守衛
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 ? ( <Switch {...switchProps}> {routes.map((route, i) => ( <Route key={route.key || i} path={route.path} exact={route.exact} strict={route.strict} render={(props) => { if (!route.requiresAuth || authed || route.path === authPath) { return <route.component {...props} {...extraProps} route={route} /> } return <Redirect to={{ pathname: authPath, state: { from: props.location } }} /> }} /> ))} </Switch> ) : null export default renderRoutes
修改后的源碼增加了兩個參數 authed 、 authPath 和一個屬性 route.requiresAuth
然后再來看一下最關鍵的一段代碼
if (!route.requiresAuth || authed || route.path === authPath) { return <route.component {...props} {...extraProps} route={route} /> } return <Redirect to={{ pathname: authPath, state: { from: props.location } }} />
很簡單 如果 route.requiresAuth = false 或者 authed = true 或者 route.path === authPath(參數默認值'/login')則渲染我們頁面,否則就渲染我們設置的 authPath 頁面,并記錄從哪個頁面跳轉。
相應的router.js也要稍微修改一下
const routes = [ { path: '/', exact: true, component: Home, requiresAuth: false, }, { path: '/login', component: Login, requiresAuth: false, }, { path: '/user', component: User, requiresAuth: true, //需要登陸后才能跳轉的頁面 }, { 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不在我們這篇文章的討論范圍之內) const authPath = '/login' // 默認未登錄的時候返回的頁面,可以自行設置 const App = () => ( <main> <Switch> {renderRoutes(routes, authed, authPath)} </Switch> </main> ) export default App
//登陸之后返回原先要去的頁面login函數 login(){ const { from } = this.props.location.state || { from: { pathname: '/' } } // authed = true // 這部分邏輯自己寫吧。。。 this.props.history.push(from.pathname) }
到此 react-router-config
就結束了并完成了我們想要的效果
3、注意:
很多人會發現,有時候達不到我們想要的效果,那么怎么辦呢,接著往下看
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' <Router history={ history } basename="/" getUserConfirmation={ getConfirmation(history, 'yourCallBack') } forceRefresh={ !supportsHistory } > <App isLogin={ isLogin ? true : false }> <Switch> <Route exact path="/" render={ () => <Redirect to="/layout/dashboard" push /> } /> <Route path="/login" component={ Login } /> <Route path="/layout" component={ RootLayout } /> <Route component={ NotFound } /> </Switch> </App> </Router>
很多時候我們是可以通過監聽路由變化實現的比如 getUserConfirmation
鉤子就是做這件事情的
const getConfirmation = (message, callback) => { if (!isLogin) { message.push('/login') } else { message.push(message.location.pathname) }
接下來我們看一下 react-acl-router
又是怎么實現的
本節參考代碼:
react-acl-router
react-boilerplate-pro/src/app/init/router.js
react-boilerplate-pro/src/app/config/routes.js
權限管理作為企業管理系統中非常核心的一個部分,一直以來因為業務方很多時候無法使用準確的術語來描述需求成為了困擾開發者們的一大難題。這里我們先來介紹兩種常見的權限管理設計模式,即基于角色的訪問控制以及訪問控制列表。
1、布局與路由
在討論具體的布局組件設計前,我們首先要解決一個更為基礎的問題,那就是如何將布局組件與應用路由結合起來。
下面的這個例子是 react-router
官方提供的側邊欄菜單與路由結合的例子,筆者這里做了一些簡化:
const SidebarExample = () => ( <Router> <div style={{ display: "flex" }}> <div style={{ padding: "10px", width: "40%", background: "#f0f0f0" }} > <ul style={{ listStyleType: "none", padding: 0 }}> <li> <Link to="/">Home</Link> </li> <li> <Link to="/bubblegum">Bubblegum</Link> </li> <li> <Link to="/shoelaces">Shoelaces</Link> </li> </ul> </div> <div style={{ flex: 1, padding: "10px" }}> {routes.map((route, index) => ( <Route key={index} path={route.path} exact={route.exact} component={route.main} /> ))} </div> </div> </Router> );
抽象為布局的思想,寫成簡單的偽代碼就是:
<Router> <BasicLayout> // with sidebar {routes.map(route => ( <Route {...route} /> ))} </BasicLayout> </Router>
這樣的確是一種非常優雅的解決方案,但它的局限性在于無法支持多種不同的布局。受限于一個 Router
只能包含一個子組件,即使我們將多個布局組件包裹在一個容器組件中,如:
<Router> <div> <BasicLayout> // with sidebar {routes.map(route => ( <Route {...route} /> )} </BasicLayout> <FlexLayout> // with footer {routes.map(route => ( <Route {...route} /> )} </FlexLayout> </div> </Router>
路由在匹配到 FlexLayout
下的頁面時, BasicLayout
中的 sidebar
也會同時顯示出來,這顯然不是我們想要的結果。換個思路,我們可不可以將布局組件當做 children
直接傳給更底層的 Route
組件呢?代碼如下:
<Router> <div> {basicLayoutRoutes.map(route => ( <Route {...route}> <BasicLayout component={route.component} /> </Route> ))} {flexLayoutRoutes.map(route => ( <Route {...route}> <FlexLayout component={route.component} /> </Route> ))} </div> </Router>
這里我們將不同的布局組件當做高階組件,相應地包裹在了不同的頁面組件上,這樣就實現了對多種不同布局的支持。還有一點需要注意的是, react-router
默認會將 match
、 location
、 history
等路由信息傳遞給 Route
的下一級組件,由于在上述方案中, Route
的下一級組件并不是真正的頁面組件而是布局組件,因而我們需要在布局組件中手動將這些路由信息傳遞給頁面組件,或者統一改寫 Route
的 render
方法為:
<Route render={props => ( // props contains match, location, history <BasicLayout {...props}> <PageComponent {...props} /> </BasicLayout> )} />
另外一個可能會遇到的問題是, connected-react-router
并不會將路由中非常重要的 match
對象(包含當前路由的 params
等數據 )同步到 redux store 中,所以我們一定要保證布局及頁面組件在路由部分就可以接收到 match
對象,否則在后續處理頁面頁眉等與當前路由參數相關的需求時就會變得非常麻煩。
2、頁眉 & 頁腳
解決了與應用路由相結合的問題,具體到布局組件內部,其中最重要的兩部分就是頁面的頁眉和頁腳部分,而頁眉又可以分為應用頁眉與頁面頁眉兩部分。
應用頁眉指的是整個應用層面的頁眉,與具體的頁面無關,一般來說會包含用戶頭像、通知欄、搜索框、多語言切換等這些應用級別的信息與操作。頁面頁眉則一般來講會包含頁面標題、面包屑導航、頁面通用操作等與具體頁面相關的內容。
在以往的項目中,尤其是在項目初期許多開發者因為對項目本身還沒有一個整體的認識,很多時候會傾向于將應用頁眉做成一個展示型組件并在不同的頁面中直接調用。這樣做當然有其方便之處,比如說頁面與布局之間的數據同步環節就被省略掉了,每個頁面都可以直接向頁眉傳遞自己內部的數據。
但從理想的項目架構角度來講這樣做卻是一個 反模式(anti-pattern) 。因為應用頁眉實際是一個應用級別的組件,但按照上述做法的話卻變成了一個頁面級別的組件,偽代碼如下:
<App> <BasicLayout> <PageA> <AppHeader title="Page A" /> </PageA> </BasicLayout> <BasicLayout> <PageB> <AppHeader title="Page B" /> </PageB> </BasicLayout> </App>
從應用數據流的角度來講也存在著同樣的問題,那就是應用頁眉應該是向不同的頁面去傳遞數據的,而不是反過來去接收來自頁面的數據。這導致應用頁眉喪失了控制自己何時 rerender(重繪) 的機會,作為一個純展示型組件,一旦接收到的 props 發生變化頁眉就需要進行一次重繪。
另一方面,除了通用的應用頁眉外,頁面頁眉與頁面路由之間是有著嚴格的一一對應的關系的,那么我們能不能將頁面頁眉部分的配置也做到路由配置中去,以達到新增加一個頁面時只需要在 config/routes.js
中多配置一個路由對象就可以完成頁面頁眉部分的創建呢?理想情況下的偽代碼如下:
<App> <BasicLayout> // with app & page header already <PageA /> </BasicLayout> <BasicLayout> <PageB /> </BasicLayout> </App>
1、配置優于代碼
在過去關于組件庫的討論中我們曾經得出過代碼優于配置的結論,即需要使用者自定義的部分,應該盡量拋出回調函數讓使用者可以使用代碼去控制自定義的需求。這是因為組件作為極細粒度上的抽象,配置式的使用模式往往很難滿足使用者多變的需求。但在企業管理系統中,作為一個應用級別的解決方案,能使用配置項解決的問題我們都應該盡量避免讓使用者編寫代碼。
配置項(配置文件)天然就是一種集中式的管理模式,可以極大地降低應用復雜度。以頁眉為例來說,如果我們每個頁面文件中都調用了頁眉組件,那么一旦頁眉組件出現問題我們就需要修改所有用到頁眉組件頁面的代碼。除去 debug 的情況外,哪怕只是修改一個頁面標題這樣簡單的需求,開發者也需要先找到這個頁面相對應的文件,并在其 render
函數中進行修改。這些隱性成本都是我們在設計企業管理系統解決方案時需要注意的,因為就是這樣一個個的小細節造成了本身并不復雜的企業管理系統在維護、迭代了一段時間后應用復雜度陡增。理想情況下,一個優秀的企業管理系統解決方案應該可以做到 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)));
基于這樣一種思路,我們可以在通用的布局組件中根據當前頁面的 pathname
使用 react-router-config
提供的 matchRoutes
方法來獲取到當前頁面 route
對象的所有配置項,也就意味著我們可以對所有的這些配置項做統一的處理。這不僅為處理通用邏輯帶來了方便,同時對于編寫頁面代碼的同事來說也是一種約束,能夠讓不同開發者寫出的代碼帶有更少的個人色彩,方便對于代碼庫的整體管理。
3、頁面標題
renderPageHeader = () => { const { prefixCls, route: { pageTitle }, intl } = this.props; if (isEmpty(pageTitle)) { return null; } const pageTitleStr = intl.formatMessage({ id: pageTitle }); return ( <div className={`${prefixCls}-pageHeader`}> {this.renderBreadcrumb()} <div className={`${prefixCls}-pageTitle`}>{pageTitleStr}</div> </div> ); }
4、面包屑導航
renderBreadcrumb = () => { const { route: { breadcrumb }, intl, prefixCls } = this.props; const breadcrumbData = generateBreadcrumb(breadcrumb); return ( <Breadcrumb className={`${prefixCls}-breadcrumb`}> {map(breadcrumbData, (item, idx) => ( idx === breadcrumbData.length - 1 ? <Breadcrumb.Item key={item.href}> {intl.formatMessage({ id: item.text })} </Breadcrumb.Item> : <Breadcrumb.Item key={item.href}> <Link href={item.href} to={item.href}> {intl.formatMessage({ id: item.text })} </Link> </Breadcrumb.Item> ))} </Breadcrumb> ); }
3、設計策略
1、基于角色的訪問控制
基于角色的訪問控制不直接將系統操作的各種權限賦予具體用戶,而是在用戶與權限之間建立起角色集合,將權限賦予角色再將角色賦予用戶。這樣就實現了對于權限和角色的集中管理,避免用戶與權限之間直接產生復雜的多對多關系。
2、訪問控制列表
具體到角色與權限之間,訪問控制列表指代的是某個角色所擁有的系統權限列表。在傳統計算機科學中,權限一般指的是對于文件系統進行增刪改查的權力。而在 Web 應用中,大部分系統只需要做到頁面級別的權限控制即可,簡單來說就是根據當前用戶的角色來決定其是否擁有查看當前頁面的權利。
下面就讓我們按照這樣的思路實現一個基礎版的包含權限管理功能的應用路由。
4、實戰代碼
1、路由容器
在編寫權限管理相關的代碼前,我們需要先為所有的頁面路由找到一個合適的容器,即 react-router
中的 Switch
組件。與多個獨立路由不同的是,包裹在 Switch
中的路由每次只會渲染路徑匹配成功的第一個,而不是所有符合路徑匹配條件的路由。
<Router> <Route path="/about" component={About}/> <Route path="/:user" component={User}/> <Route component={NoMatch}/> </Router>
<Router> <Switch> <Route path="/about" component={About}/> <Route path="/:user" component={User}/> <Route component={NoMatch}/> </Switch> </Router>
以上面兩段代碼為例,如果當前頁面路徑是 /about
的話,因為 <About />
、 <User />
及 <NoMatch />
這三個路由的路徑都符合 /about
,所以它們會同時被渲染在當前頁面。而將它們包裹在 Switch
中后, react-router
在找到第一個符合條件的 <About />
路由后就會停止查找直接渲染 <About />
組件。
在企業管理系統中因為頁面與頁面之間一般都是平行且排他的關系,所以利用好 Switch
這個特性對于我們簡化頁面渲染邏輯有著極大的幫助。
另外值得一提的是,在 react-router
作者 Ryan Florence 的新作@reach/router 中, Switch
的這一特性被默認包含了進去,而且 @reach/router
會自動匹配最符合當前路徑的路由。這就使得使用者不必再去擔心路由的書寫順序,感興趣的朋友可以關注一下。
2、權限管理
現在我們的路由已經有了一個大體的框架,下面就讓我們為其添加具體的權限判斷邏輯。
對于一個應用來說,除去需要鑒權的頁面外,一定還存在著不需要鑒權的頁面,讓我們先將這些頁面添加到我們的路由中,如登錄頁。
<Router> <Switch> <Route path="/login" component={Login}/> </Switch> </Router>
對于需要鑒權的路由,我們需要先抽象出一個判斷當前用戶是否有權限的函數來作為判斷依據,而根據具體的需求,用戶可以擁有單個角色或多個角色,抑或更復雜的一個鑒權函數。這里筆者提供一個最基礎的版本,即我們將用戶的角色以字符串的形式存儲在后臺,如一個用戶的角色是 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;
在上面我們提到了路由的配置文件,這里我們為每一個需要鑒權的路由再添加一個屬性 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 才可以訪問門店詳情頁面。
對于沒有權限查看當前頁面的情況,一般來講有兩種處理方式,一是直接重定向到另一個頁面(如首頁),二是渲染一個無權限頁面,提示用戶因為沒有當前頁面的權限所以無法查看。二者是排他的,即每個頁面只需要使用其中一種即可,于是我們在路由配置中可以根據需要去配置 redirect
或 unauthorized
屬性,分別對應 無權限重定向 及 無權限顯示無權限頁面 兩種處理方式。具體代碼大家可以參考示例項目 react-acl-router 中的實現,這里摘錄一小段核心部分。
renderRedirectRoute = route => ( <Route key={route.path} {...omitRouteRenderProperties(route)} render={() => <Redirect to={route.redirect} />} /> ); 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 ( <Route key={path} {...omitRouteRenderProperties(route)} render={props => ( <AuthorizedLayout {...props}> <Unauthorized {...props} /> </AuthorizedLayout> )} /> ); } if (!hasPermission && route.redirect) { return this.renderRedirectRoute(route); } return ( <Route key={path} {...omitRouteRenderProperties(route)} render={props => ( <AuthorizedLayout {...props}> <RouteComponent {...props} /> </AuthorizedLayout> )} /> ); }
于是,在最終的路由中,我們會優先匹配無需鑒權的頁面路徑,保證所有用戶在訪問無需鑒權的頁面時,第一時間就可以看到頁面。然后再去匹配需要鑒權的頁面路徑,最終如果所有的路徑都匹配不到的話,再渲染 404 頁面告知用戶當前頁面路徑不存在。
需要鑒權的路由和不需要鑒權的路由作為兩種不同的頁面,一般而言它們的頁面布局也是不同的。如登錄頁面使用的就是普通頁面布局:
在這里我們可以將不同的頁面布局與鑒權邏輯相結合以達到只需要在路由配置中配置相應的屬性,新增加的頁面就可以同時獲得鑒權邏輯和基礎布局的效果。這將極大地提升開發者們的工作效率,尤其是對于項目組的新成員來說純配置的上手方式是最友好的。
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 => ( <ConnectedRouter history={props.history}> <MultiIntlProvider defaultLocale={locale} messageMap={messages} > // the router component <AclRouter authorities={props.user.authorities} authorizedRoutes={authorizedRoutes} authorizedLayout={BasicLayout} normalRoutes={normalRoutes} normalLayout={NormalLayout} notFound={NotFound} /> </MultiIntlProvider> </ConnectedRouter> ); const mapStateToProps = state => ({ user: state.app.user, }); Router.propTypes = propTypes; export default connect(mapStateToProps)(Router);
在實際項目中,我們可以使用 react-redux
提供的 connect
組件將應用路由 connect 至 redux store,以方便我們直接讀取當前用戶的角色信息。一旦登錄用戶的角色發生變化,客戶端路由就可以進行相應的判斷與響應。
6、組合式開發:權限管理
對于頁面級別的權限管理來說,權限管理部分的邏輯是獨立于頁面的,是與頁面中的具體內容無關的。也就是說,權限管理部分的代碼并不應該成為頁面中的一部分,而是應該在拿到用戶權限后創建應用路由時就將沒有權限的頁面替換為重定向或無權限頁面。
這樣一來,頁面部分的代碼就可以實現與權限管理邏輯的徹底解耦,以至于如果抽掉權限管理這一層后,頁面就變成了一個無需權限判斷的頁面依然可以獨立運行。而通用部分的權限管理代碼也可以在根據業務需求微調后服務于更多的項目。
7、小結
文中我們從權限管理的基礎設計思想講起,實現了一套基于角色的頁面級別的應用權限管理系統并分別討論了無權限重定向及無權限顯示無權限頁面兩種無權限查看時的處理方法。
接下來我們來看一下多級菜單是如何實現的
本節參考代碼:
react-sider
在大部分企業管理系統中,頁面的基礎布局所采取的一般都是側邊欄菜單加頁面內容這樣的組織形式。在成熟的組件庫支持下,UI 層面想要做出一個漂亮的側邊欄菜單并不困難,但因為在企業管理系統中菜單還承擔著頁面導航的功能,于是就導致了兩大難題,一是多級菜單如何處理,二是菜單項的子頁面(如點擊門店管理中的某一個門店進入的門店詳情頁在菜單中并沒有對應的菜單項)如何高亮其隸屬于的父級菜單。
1、多級菜單
為了增強系統的可擴展性,企業管理系統中的菜單一般都需要提供多級支持,對應的數據結構就是在每一個菜單項中都要有 children 屬性來配置下一級菜單項。
const menuData = [{ name: '儀表盤', icon: 'dashboard', path: 'dashboard', children: [{ name: '分析頁', path: 'analysis', children: [{ name: '實時數據', path: 'realtime', }, { name: '離線數據', path: 'offline', }], }], }];
遞歸渲染父菜單及子菜單
想要支持多級菜單,首先要解決的問題就是如何統一不同級別菜單項的交互。
在大多數的情況下,每一個菜單項都代表著一個不同的頁面路徑,點擊后會觸發 url 的變化并跳轉至相應頁面,也就是上面配置中的 path 字段。
但對于一個父菜單來說,點擊還意味著打開或關閉相應的子菜單,這就與點擊跳轉頁面發生了沖突。為了簡化這個問題,我們先統一菜單的交互為點擊父菜單(包含 children 屬性的菜單項)為打開或關閉子菜單,點擊子菜單(不包含 children 屬性的菜單項)為跳轉至相應頁面。
首先,為了成功地渲染多級菜單,菜單的渲染函數是需要支持遞歸的,即如果當前菜單項含有 children 屬性就將其渲染為父菜單并優先渲染其 children 字段下的子菜單,這在算法上被叫做深度優先遍歷。
renderMenu = data => ( map(data, (item) => { if (item.children) { return ( <SubMenu key={item.path} title={ <span> <Icon type={item.icon} /> <span>{item.name}</span> </span> } > {this.renderMenu(item.children)} </SubMenu> ); } return ( <Menu.Item key={item.path}> <Link to={item.path} href={item.path}> <Icon type={item.icon} /> <span>{item.name}</span> </Link> </Menu.Item> ); }) )
這樣我們就擁有了一個支持多級展開、子菜單分別對應頁面路由的側邊欄菜單。細心的朋友可能還發現了,雖然父菜單并不對應一個具體的路由但在配置項中依然還有 path 這個屬性,這是為什么呢?
2、處理菜單高亮
在傳統的企業管理系統中,為不同的頁面配置頁面路徑是一件非常痛苦的事情,對于頁面路徑,許多開發者唯一的要求就是不重復即可,如上面的例子中,我們把菜單數據配置成這樣也是可以的。
const menuData = [{ name: '儀表盤', icon: 'dashboard', children: [{ name: '分析頁', children: [{ name: '實時數據', path: '/realtime', }, { name: '離線數據', path: '/offline', }], }], }]; <Router> <Route path="/realtime" render={() => <div />} <Route path="/offline" render={() => <div />} </Router>
用戶在點擊菜單項時一樣可以正確地跳轉到相應頁面。但這樣做的一個致命缺陷就是,對于 /realtime
這樣一個路由,如果只根據當前的 pathname
去匹配菜單項中 path
屬性的話,要怎樣才能同時也匹配到「分析頁」與「儀表盤」呢?因為如果匹配不到的話,「分析頁」和「儀表盤」就不會被高亮了。我們能不能在頁面的路徑中直接體現出菜單項之間的繼承關系呢?來看下面這個工具函數。
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; }) );
這個工具函數把菜單項中可能有的 children
字段考慮了進去,將一開始的菜單數據傳入就可以得到如下完整的菜單數據。
[{ name: '儀表盤', icon: 'dashboard', path: '/dashboard', // before is 'dashboard' children: [{ name: '分析頁', path: '/dashboard/analysis', // before is 'analysis' children: [{ name: '實時數據', path: '/dashboard/analysis/realtime', // before is 'realtime' }, { name: '離線數據', 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 []; };
上面的這個數組代表著不同級別的菜單項,將這三個值分別與菜單數據中的 path
屬性進行匹配就可以一次性地匹配到所有當前頁面應當被高亮的菜單項了。
這里需要注意的是,雖然菜單項中的 path
一般都是普通字符串,但有些特殊的路由也可能是正則的形式,如 /outlets/:id
。所以我們在對二者進行匹配時,還需要引入 path-to-regexp
這個庫來處理類似 /outlets/1
和 /outlets/:id
這樣的路徑。又因為初始時菜單數據是樹形結構的,不利于進行 path
屬性的匹配,所以我們還需要先將樹形結構的菜單數據扁平化,然后再傳入 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))) ), []);
在這些工具函數的幫助下,多級菜單的高亮也不再是問題了。
3、知識點:記憶化(Memoization)
在側邊欄菜單中,有兩個重要的狀態:一個是 selectedKeys
,即當前選定的菜單項;另一個是 openKeys
,即多個多級菜單的打開狀態。這二者的含義是不同的,因為在 selectedKeys
不變的情況下,用戶在打開或關閉其他多級菜單后, openKeys
是會發生變化的,如下面二圖所示, selectedKeys
相同但 openKeys
不同。
對于 selectedKeys
來說,由于它是由頁面路徑( pathname
)決定的,所以每一次 pathname
發生變化都需要重新計算 selectedKeys
的值。又因為通過 pathname
以及最基礎的菜單數據 menuData
去計算 selectedKeys
是一件非常昂貴的事情(要做許多數據格式處理和計算),有沒有什么辦法可以優化一下這個過程呢?
Memoization 可以賦予普通函數記憶輸出結果的功能,它會在每次調用函數之前檢查傳入的參數是否與之前執行過的參數完全相同,如果完全相同則直接返回上次計算過的結果,就像常用的緩存一樣。
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)), }; }
在組件的構造器中我們可以根據當前 props 傳來的 pathname
及 menuData
計算出當前的 selectedKeys
并將其當做 openKeys
的初始值初始化組件內部 state。因為 openKeys
是由用戶所控制的,所以對于后續 openKeys
值的更新我們只需要配置相應的回調將其交給 Menu
組件控制即可。
import Menu from 'antd/lib/menu'; handleOpenChange = (openKeys) => { this.setState({ openKeys, }); }; <Menu style={{ padding: '16px 0', width: '100%' }} mode="inline" theme="dark" openKeys={openKeys} selectedKeys={this.selectedKeys(pathname, this.fullPathMenuData(menuData))} onOpenChange={this.handleOpenChange} > {this.renderMenu(this.fullPathMenuData(menuData))} </Menu>
這樣我們就實現了對于 selectedKeys
及 openKeys
的分別管理,開發者在使用側邊欄組件時只需要將應用當前的頁面路徑同步到側邊欄組件中的 pathname
屬性即可,側邊欄組件會自動處理相應的菜單高亮( selectedKeys
)和多級菜單的打開與關閉( openKeys
)。
4、知識點:正確區分 prop 與 state
上述這個場景也是一個非常經典的關于如何正確區分 prop 與 state 的例子。
selectedKeys
由傳入的 pathname
決定,于是我們就可以將 selectedKeys
與 pathname
之間的轉換關系封裝在組件中,使用者只需要傳入正確的 pathname
就可以獲得相應的 selectedKeys
而不需要關心它們之間的轉換是如何完成的。而 pathname
作為組件渲染所需的基礎數據,組件無法從自身內部獲得,所以就需要使用者通過 props 將其傳入進來。
另一方面, openKeys
作為組件內部的 state,初始值可以由 pathname
計算而來,后續的更新則與組件外部的數據無關而是會根據用戶的操作在組件內部完成,那么它就是一個 state,與其相關的所有邏輯都可以徹底地被封裝在組件內部而不需要暴露給使用者。
簡而言之,一個數據如果想成為 prop 就必須是組件內部無法獲得的,而且在它成為了 prop 之后,所有可以根據它的值推導出來的數據都不再需要成為另外的 props,否則將違背 React 單一數據源的原則。對于 state 來說也是同樣,如果一個數據想成為 state,那么它就不應該再能夠被組件外部的值所改變,否則也會違背單一數據源的原則而導致組件的表現不可預測,產生難解的 bug。
5、組合式開發:應用菜單
嚴格來說,在這一小節中著重探討的應用菜單部分的思路并不屬于組合式開發思想的范疇,更多地是如何寫出一個支持無限級子菜單及自動匹配當前路由的菜單組件。組件當然是可以隨意插拔的,但前提是應用該組件的父級部分不依賴于組件所提供的信息。這也是我們在編寫組件時所應當遵循的一個規范,即組件可以從外界獲取信息并在此基礎上進行組件內部的邏輯判斷。但當組件向其外界拋出信息時,更多的時候應該是以回調的形式讓調用者去主動觸發,然后更新外部的數據再以 props 的形式傳遞給組件以達到更新組件的目的,而不是強制需要在外部再配置一個回調的接收函數去直接改變組件的內部狀態。
從這點上來說,組合式開發與組件封裝其實是有著異曲同工之妙的,關鍵都在于對內部狀態的嚴格控制。不論一個模塊或一個組件需要向外暴露多少接口,在它的內部都應該是解決了某一個或某幾個具體問題的。就像工廠產品生產流水線上的一個環節,在經過了這一環節后產品相較于進入前一定產生了某種區別,不論是增加了某些功能還是被打上某些標簽,產品一定會變得更利于下游合作者使用。更理想的情況則是即使刪除掉了這一環節,原來這一環節的上下游依然可以無縫地銜接在一起繼續工作,這就是我們所說的模塊或者說組件的可插拔性。
在前后端分離架構的背景下,前端已經逐漸代替后端接管了所有固定路由的判斷與處理,但在動態路由這樣一個場景下,我們會發現單純前端路由服務的靈活度是遠遠不夠的。在用戶到達某個頁面后,可供下一步邏輯判斷的依據就只有當前頁面的 url,而根據 url 后端的路由服務是可以返回非常豐富的數據的。
常見的例子如頁面的類型。假設應用中營銷頁和互動頁的渲染邏輯并不相同,那么在頁面的 DSL 數據之外,我們就還需要獲取到頁面的類型以進行相應的渲染。再比如頁面的 SEO 數據,創建和更新時間等等,這些數據都對應用能夠在前端靈活地展示頁面,處理業務邏輯有著巨大的幫助。
甚至我們還可以推而廣之,徹底拋棄掉由 react-router 等提供的前端路由服務,轉而寫一套自己的路由分發器,即根據頁面類型的不同分別調用不同的頁面渲染服務,以多種類型頁面的方式來組成一個完整的前端應用。
為了解決大而全的方案在實踐中不夠靈活的問題,我們是不是可以將其中包含的各個模塊解耦后,獨立發布出來供開發者們按需取用呢?讓我們先來看一段理想中完整的企業管理系統應用架構部分的偽代碼:
const App = props => ( <Provider> // react-redux bind <ConnectedRouter> // react-router-redux bind <MultiIntlProvider> // intl support <AclRouter> // router with access control list <Route path="/login"> // route that doesn't need authentication <NormalLayout> // layout component <View /> // page content (view component) </NormalLayout> <Route path="/login"> ... // more routes that don't need authentication <Route path="/analysis"> // route that needs authentication <LoginChecker> // hoc for user login check <BasicLayout> // layout component <SiderMenu /> // sider menu <Content> <PageHeader /> // page header <View /> // page content (view component) <PageFooter /> // page footer </Content> </BasicLayout> </LoginChecker> </Route> ... // more routes that need authentication <Route render={() => <div>404</div>} /> // 404 page </AclRouter> </MultiIntlProvider> </ConnectedRouter> </Provider> );
在上面的這段偽代碼中,我們抽象出了多語言支持、基于路由的權限管理、登錄鑒權、基礎布局、側邊欄菜單等多個獨立模塊,可以根據需求添加或刪除任意一個模塊,而且添加或刪除任意一個模塊都不會對應用的其他部分產生不可接受的副作用。這讓我們對接下來要做的事情有了一個大體的認識,但在具體的實踐中,如 props 如何傳遞、模塊之間如何共享數據、如何靈活地讓用戶自定義某些特殊邏輯等都仍然面臨著巨大的挑戰。我們需要時刻注意,在處理一個具體問題時哪些部分應當放在某個獨立模塊內部去處理,哪些部分應當暴露出接口供使用者自定義,模塊與模塊之間如何做到零耦合以至于使用者可以隨意插拔任意一個模塊去適應當前項目的需要。
從一個具體的前端應用直接切入開發技巧與理念的講解,所以對于剛入門 React 的朋友來說可能存在著一定的基礎知識部分梳理的缺失,這里為大家提供一份較為詳細的 React 開發者學習路線圖,希望能夠為剛入門 React 的朋友提供一條規范且便捷的學習之路。
到此react的路由鑒權映梳理完了歡迎大家轉發交流分享 轉載請注明出處 ,附帶一個近期相關項目案例代碼給大家一個思路:
react-router-config
以上就是本文的全部內容,希望對大家的學習有所幫助,也希望大家多多支持億速云。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。