上一篇文章中有同窗提到路由鑑權,因爲時間關係沒有寫,本文將針對這一特性對vue
和react
作專門說明,但願同窗看了之後可以受益不淺,對你的項目可以有所幫助,本文借鑑了不少大佬的文章篇幅也是比較長的。javascript
單獨項目中是但願根據登陸人來看下這我的是否是有權限進入當前頁面。雖然服務端作了進行接口的權限,可是每個路由加載的時候都要去請求這個接口太浪費了。有時候是經過SESSIONID來校驗登錄權限的。前端
在正式開始react
路由鑑權以前咱們先看一下vue的路由鑑權是如何工做的:vue
通常咱們會相應的把路由表角色菜單配置在後端,當用戶未經過頁面菜單,直接從地址欄訪問非權限範圍內的url時,攔截用戶訪問並重定向到首頁。java
vue
的初期是能夠經過動態路由的方式,按照權限加載對應的路由表AddRouter
,可是因爲權限交叉,致使權限路由表要作判斷結合,想一想仍是挺麻煩的,因此採用的是在beforeEach
裏面直判斷用非動態路由的方式react
在使用 Vue的時候,框架提供了路由守衛功能,用來在進入某個路有前進行一些校驗工做,若是校驗失敗,就跳轉到 404 或者登錄頁面,好比 Vue 中的
beforeEnter
函數:webpack
...
router.beforeEach(async(to, from, next) => {
const toPath = to.path;
const fromPath = from.path;
})
...複製代碼
// 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')
},
...
]複製代碼
用戶登錄信息請求後端接口,返回菜單、權限、版權信息等公共信息,存入vuex。此處用到權限字段以下:git
_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()
})複製代碼
路由是幹什麼的?github
根據不一樣的 url 地址展現不一樣的內容或頁面。web
單頁面應用最大的特色就是隻有一個 web 頁面。於是全部的頁面跳轉都須要經過javascript實現。當須要根據用戶操做展現不一樣的頁面時,咱們就須要根據訪問路徑使用js控制頁面展現內容。算法
React Router 是專爲 React 設計的路由解決方案。它利用HTML5 的history API,來操做瀏覽器的 session history (會話歷史)。
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便可。
使用了 HTML5 的 history API (pushState, replaceState and the popstate event) 用於保證你的地址欄信息與界面保持一致。
主要屬性:
basename:設置根路徑
getUserConfirmation:獲取用戶確認的函數
forceRefresh:是否刷新整個頁面
keyLength:location.key的長度
children:子節點(單個)
爲舊版本瀏覽器開發的組件,一般簡易使用BrowserRouter。
爲項目提供聲明性的、可訪問的導航
主要屬性:
to:能夠是一個字符串表示目標路徑,也能夠是一個對象,包含四個屬性:
pathname:表示指向的目標路徑
search: 傳遞的搜索參數
hash:路徑的hash值
state: 地址狀態
replace:是否替換整個歷史棧
innerRef:訪問部件的底層引用
同時支持全部a標籤的屬性例如className,title等等
React-router 中最重要的組件,最主要的職責就是根據匹配的路徑渲染指定的組件
主要屬性:
path:須要匹配的路徑
component:須要渲染的組件
render:渲染組件的函數
children :渲染組件的函數,經常使用在path沒法匹配時呈現的’空’狀態即所謂的默認顯示狀態
重定向組件
主要屬性: to:指向的路徑
<Switch>
嵌套組件:惟一的渲染匹配路徑的第一個子 <Route> 或者 <Redirect>
在以前的版本中,React Router 也提供了相似的
onEnter
鉤子,但在 React Router 4.0 版本中,取消了這個方法。React Router 4.0 採用了聲明式的組件,路由即組件,要實現路由守衛功能,就得咱們本身去寫了。
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的話呢就先判斷是否已登錄
})複製代碼
// 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
就結束了並完成了咱們想要的效果
不少人會發現,有時候達不到咱們想要的效果,那麼怎麼辦呢,接着往下看
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-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
對象,不然在後續處理頁面頁眉等與當前路由參數相關的需求時就會變得很是麻煩。
解決了與應用路由相結合的問題,具體到佈局組件內部,其中最重要的兩部分就是頁面的頁眉和頁腳部分,而頁眉又能夠分爲應用頁眉與頁面頁眉兩部分。
應用頁眉指的是整個應用層面的頁眉,與具體的頁面無關,通常來講會包含用戶頭像、通知欄、搜索框、多語言切換等這些應用級別的信息與操做。頁面頁眉則通常來說會包含頁面標題、麪包屑導航、頁面通用操做等與具體頁面相關的內容。
在以往的項目中,尤爲是在項目初期許多開發者由於對項目自己尚未一個總體的認識,不少時候會傾向於將應用頁眉作成一個展現型組件並在不一樣的頁面中直接調用。這樣作固然有其方便之處,好比說頁面與佈局之間的數據同步環節就被省略掉了,每一個頁面均可以直接向頁眉傳遞本身內部的數據。
但從理想的項目架構角度來說這樣作倒是一個反模式(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>複製代碼
在過去關於組件庫的討論中咱們曾經得出過代碼優於配置的結論,即須要使用者自定義的部分,應該儘可能拋出回調函數讓使用者可使用代碼去控制自定義的需求。這是由於組件做爲極細粒度上的抽象,配置式的使用模式每每很難知足使用者多變的需求。但在企業管理系統中,做爲一個應用級別的解決方案,能使用配置項解決的問題咱們都應該儘可能避免讓使用者編寫代碼。
配置項(配置文件)自然就是一種集中式的管理模式,能夠極大地下降應用複雜度。以頁眉爲例來講,若是咱們每一個頁面文件中都調用了頁眉組件,那麼一旦頁眉組件出現問題咱們就須要修改全部用到頁眉組件頁面的代碼。除去 debug 的狀況外,哪怕只是修改一個頁面標題這樣簡單的需求,開發者也須要先找到這個頁面相對應的文件,並在其 render
函數中進行修改。這些隱性成本都是咱們在設計企業管理系統解決方案時須要注意的,由於就是這樣一個個的小細節形成了自己並不複雜的企業管理系統在維護、迭代了一段時間後應用複雜度陡增。理想狀況下,一個優秀的企業管理系統解決方案應該能夠作到 80% 以上非功能性需求變動均可以使用修改配置文件的方式解決。
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
對象的全部配置項,也就意味着咱們能夠對全部的這些配置項作統一的處理。這不只爲處理通用邏輯帶來了方便,同時對於編寫頁面代碼的同事來講也是一種約束,可以讓不一樣開發者寫出的代碼帶有更少的我的色彩,方便對於代碼庫的總體管理。
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>
);
}複製代碼
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>
);
}複製代碼
基於角色的訪問控制不直接將系統操做的各類權限賦予具體用戶,而是在用戶與權限之間創建起角色集合,將權限賦予角色再將角色賦予用戶。這樣就實現了對於權限和角色的集中管理,避免用戶與權限之間直接產生複雜的多對多關係。
具體到角色與權限之間,訪問控制列表指代的是某個角色所擁有的系統權限列表。在傳統計算機科學中,權限通常指的是對於文件系統進行增刪改查的權力。而在 Web 應用中,大部分系統只須要作到頁面級別的權限控制便可,簡單來講就是根據當前用戶的角色來決定其是否擁有查看當前頁面的權利。
下面就讓咱們按照這樣的思路實現一個基礎版的包含權限管理功能的應用路由。
在編寫權限管理相關的代碼前,咱們須要先爲全部的頁面路由找到一個合適的容器,即 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
會自動匹配最符合當前路徑的路由。這就使得使用者沒必要再去擔憂路由的書寫順序,感興趣的朋友能夠關注一下。
如今咱們的路由已經有了一個大致的框架,下面就讓咱們爲其添加具體的權限判斷邏輯。
對於一個應用來講,除去須要鑑權的頁面外,必定還存在着不須要鑑權的頁面,讓咱們先將這些頁面添加到咱們的路由中,如登陸頁。
<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 頁面告知用戶當前頁面路徑不存在。
<Switch>
{map(normalRoutes, route => (
this.renderNormalRoute(route)
))}
{map(authorizedRoutes, route => (
this.renderAuthorizedRoute(route)
))}
{this.renderNotFoundRoute()}
</Switch>複製代碼
須要鑑權的路由和不須要鑑權的路由做爲兩種不一樣的頁面,通常而言它們的頁面佈局也是不一樣的。如登陸頁面使用的就是普通頁面佈局:
在這裏咱們能夠將不一樣的頁面佈局與鑑權邏輯相結合以達到只須要在路由配置中配置相應的屬性,新增長的頁面就能夠同時得到鑑權邏輯和基礎佈局的效果。這將極大地提高開發者們的工做效率,尤爲是對於項目組的新成員來講純配置的上手方式是最友好的。
至此一個包含基礎權限管理的應用路由就大功告成了,咱們能夠將它抽象爲一個獨立的路由組件,使用時只須要配置須要鑑權的路由和不須要鑑權的路由兩部分便可。
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,以方便咱們直接讀取當前用戶的角色信息。一旦登陸用戶的角色發生變化,客戶端路由就能夠進行相應的判斷與響應。
對於頁面級別的權限管理來講,權限管理部分的邏輯是獨立於頁面的,是與頁面中的具體內容無關的。也就是說,權限管理部分的代碼並不該該成爲頁面中的一部分,而是應該在拿到用戶權限後建立應用路由時就將沒有權限的頁面替換爲重定向或無權限頁面。
這樣一來,頁面部分的代碼就能夠實現與權限管理邏輯的完全解耦,以致於若是抽掉權限管理這一層後,頁面就變成了一個無需權限判斷的頁面依然能夠獨立運行。而通用部分的權限管理代碼也能夠在根據業務需求微調後服務於更多的項目。
文中咱們從權限管理的基礎設計思想講起,實現了一套基於角色的頁面級別的應用權限管理系統並分別討論了無權限重定向及無權限顯示無權限頁面兩種無權限查看時的處理方法。
接下來咱們來看一下多級菜單是如何實現的
本節參考代碼:
在大部分企業管理系統中,頁面的基礎佈局所採起的通常都是側邊欄菜單加頁面內容這樣的組織形式。在成熟的組件庫支持下,UI 層面想要作出一個漂亮的側邊欄菜單並不困難,但由於在企業管理系統中菜單還承擔着頁面導航的功能,因而就致使了兩大難題,一是多級菜單如何處理,二是菜單項的子頁面(如點擊門店管理中的某一個門店進入的門店詳情頁在菜單中並無對應的菜單項)如何高亮其隸屬於的父級菜單。
爲了加強系統的可擴展性,企業管理系統中的菜單通常都須要提供多級支持,對應的數據結構就是在每個菜單項中都要有 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 這個屬性,這是爲何呢?
在傳統的企業管理系統中,爲不一樣的頁面配置頁面路徑是一件很是痛苦的事情,對於頁面路徑,許多開發者惟一的要求就是不重複便可,如上面的例子中,咱們把菜單數據配置成這樣也是能夠的。
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)))
), []);複製代碼
在這些工具函數的幫助下,多級菜單的高亮也再也不是問題了。
在側邊欄菜單中,有兩個重要的狀態:一個是 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
)。
上述這個場景也是一個很是經典的關於如何正確區分 prop 與 state 的例子。
selectedKeys
由傳入的 pathname
決定,因而咱們就能夠將 selectedKeys
與 pathname
之間的轉換關係封裝在組件中,使用者只須要傳入正確的 pathname
就能夠得到相應的 selectedKeys
而不須要關心它們之間的轉換是如何完成的。而 pathname
做爲組件渲染所需的基礎數據,組件沒法從自身內部得到,因此就須要使用者經過 props 將其傳入進來。
另外一方面, openKeys
做爲組件內部的 state,初始值能夠由 pathname
計算而來,後續的更新則與組件外部的數據無關而是會根據用戶的操做在組件內部完成,那麼它就是一個 state,與其相關的全部邏輯均可以完全地被封裝在組件內部而不須要暴露給使用者。
簡而言之,一個數據若是想成爲 prop 就必須是組件內部沒法得到的,並且在它成爲了 prop 以後,全部能夠根據它的值推導出來的數據都再也不須要成爲另外的 props,不然將違背 React 單一數據源的原則。對於 state 來講也是一樣,若是一個數據想成爲 state,那麼它就不該該再可以被組件外部的值所改變,不然也會違背單一數據源的原則而致使組件的表現不可預測,產生難解的 bug。
嚴格來講,在這一小節中着重探討的應用菜單部分的思路並不屬於組合式開發思想的範疇,更多地是如何寫出一個支持無限級子菜單及自動匹配當前路由的菜單組件。組件固然是能夠隨意插拔的,但前提是應用該組件的父級部分不依賴於組件所提供的信息。這也是咱們在編寫組件時所應當遵循的一個規範,即組件能夠從外界獲取信息並在此基礎上進行組件內部的邏輯判斷。但當組件向其外界拋出信息時,更多的時候應該是以回調的形式讓調用者去主動觸發,而後更新外部的數據再以 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的路由鑑權映梳理完了歡迎你們轉發交流分享轉載請註明出處 ,附帶一個近期相關項目案例代碼給你們一個思路:
同時,歡迎小夥伴們加微信羣一塊兒探討:
微信號
微信交流羣
釘釘交流羣