大家好,我是刚加入掘金的"三重堂堂主"(公众号:咪仔和汤圆,欢迎关注~)
未经授权,禁止转载~
因为新搭了个后台项目,刚好在做权限和路由这一块,就和大家一起探讨下~
也是为了填上搭建工程系列的坑,重新梳理一下前端路由和React-Router相关知识。
更新:
- 更新
react-router-dom
的版本至最新版:v6.11.2
(update: 2023-5-24)
一、添加路由
接着搭建工程,将react-router
添加到项目中。安装react-router-dom
,写本篇文章的时候react-router
版本是6(v6.11.2)
:
npm i react-router-dom
为什么只安装react-router-dom
不安装react-router
:因为react-router-dom
里面包含了核心库,也就是react-router
,react-router-dom
相当于是在react-router
的外面包了一层适用于dom
环境的壳,装饰了一下。
二、初步使用
如果不熟悉react-router
,建议先按照官网教程走一遍,这里我们简单的写一些路由:
// src/app.tsx
import ReactDOM from 'react-dom'
import store from '@/store'
import './global.less'
import 'antd/dist/antd.less'
import Welcome from './views/welcome'
import Settings from './views/settings'
import UserCenter from './views/userCenter'
import { Provider } from 'react-redux'
import React from 'react'
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import NotFound from './views/404'
import UserItem from './views/userCenter/userItem'
ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
<BrowserRouter>
<Routes>
<Route path='/' element={<Welcome />}>
<Route path='settings' element={<Settings />}></Route>
<Route path='user-center' element={<UserCenter />}>
<Route
index
element={
<main style={{ padding: '1rem' }}>
<p>Select an user</p>
</main>
}
/>
<Route path=':userId' element={<UserItem />}></Route>
</Route>
</Route>
<Route path='*' element={<NotFound />}></Route>
</Routes>
</BrowserRouter>
</Provider>
</React.StrictMode>,
document.getElementById('app')
)
启动项目后路由生效了,但是当我们在子路由刷新的时候,会提示找不到界面。
这是因为BrowserRouter
的机制引起问题:BrowserRouter
会以当前的url
请求服务器资源,服务器拿到url
以后去寻找对应的资源。又因为我们应用是SPA
,打出来的产物入口只有一个index.html
,服务器找不到请求过来的url
对应的资源,就会返回404
,在根路径下请求能够成功是因为更目录下指向了index.html
。解决方法也很简单,就是让我们的网页服务器在找不到路由对应的相应资源的时候,都返回index.html
。分为dev
环境和prod
环境:
在生产环境上比较好配置,以nginx
为例(这里我没有去验证,本地没有nginx
环境,有环境的可以验证下):
location / {
try_files $uri /index.html;
}
在dev
开发环境中的时候,由于我们使用的是dev-server
,所以需要配置historyApiFallback
为true
,原因在这,此时在子路由上刷新界面,还是访问不了~
发现资源文件路径错了(因为相对路径的问题),所以还需要在webpack
的output
下配置资源的公共路径publicPath
为根目录。
// webpack.dev.ts
devServer: {
static: resolve(__dirname, '../dist'),
compress: true,
hot: true,
// 增加配置
historyApiFallback: true,
port: 8080
},
// webpack.common.ts
output: {
filename: '[name].[chunkhash].js',
path: resolve(__dirname, '../dist'),
publicPath: '/'
},
这个时候我们就能够正常访问了。
三、useRoutes
上述的路由写法是JSX
,需要我们进行类似xml
一样的配置写法。而在我们前端实际的业务项目里面,这样写不是很方便,并且不容易扩展,没有抽离成为配置场景。所以官方给我们提供了一个hook
,让我们用配置(js
对象)的形式来表达出整个项目的路由,这个钩子就是useRoutes
,和我们使用<Routes>
、<Route>
的方式是一模一样的,只是表达方式不同。这一节我们就将JSX
路由写法改为配置式的写法。
修改为配置式,useRoutes
需要在外层包裹上Router
,因为我们是DOM
环境,所以我们修改一下ReactDOM.render
里面的内容,然后在src
下新建一个routes
文件夹,文件夹里新建文件config.tsx
,用来承载抽离出的路由配置:
// app.tsx
const App: FC = () => {
const Element = useRoutes(routes)
return <Provider store={store}>{Element}</Provider>
}
ReactDOM.render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
document.getElementById('app')
)
// src/routes/config.tsx
import React, { Suspense } from 'react'
import type { RouteObject } from 'react-router-dom'
import { Spin } from 'antd'
// 一个动态导入
// 为什么不写成lazyLoad(path: string)
// https://webpack.js.org/api/module-methods/#dynamic-expressions-in-import
function lazyLoad(Comp: React.LazyExoticComponent<any>): React.ReactNode {
return (
<Suspense
fallback={
<Spin
size='large'
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
/>
}
>
<Comp />
</Suspense>
)
}
// 路由 抽离成为JS对象形式
const routes: RouteObject[] = [
{
path: '/',
element: lazyLoad(React.lazy(() => import('@/views/welcome'))),
children: [
{
path: 'settings',
element: lazyLoad(React.lazy(() => import('@/views/settings')))
},
{
path: 'user-center',
element: lazyLoad(React.lazy(() => import('@/views/userCenter'))),
children: [
{ index: true, element: 'select a user' },
{
path: ':userId',
element: lazyLoad(
React.lazy(() => import('@/views/userCenter/userItem'))
)
}
]
}
]
},
{ path: '*', element: lazyLoad(React.lazy(() => import('@/views/404'))) }
]
export default routes
到这就把路由抽离为配置式的了,并且有利于我们后续扩展(鉴权、埋点、图标等)。
四、路由参数的范围限制
在我们的例子中,/user-center/:uerId
这个url
的路由是有路由参数userId
的。当我们直接输入url
比如/user-center/21111
的时候,如果在我们实际业务路由中,没有这个userId
,可能我们的业务就会出错,或者没给定默认值导致显示异常。所以我们需要去处理这个“不应该存在的url
”,让他重定向到404
。为什么react-router
不能自动重定向到404
,一般来说路由参数和业务有关,react-router
不知道我们的业务需求,他只能是检测url
,对于/user-center/21111
是符合要求的。
所以我们在userItem.tsx
里面,需要去限制好我们的路由参数:
// src/views/userCenter/userItem.tsx
import { FC, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { getInvoices } from './data'
const UserItem: FC = () => {
const invoices = getInvoices()
const navigate = useNavigate()
const { userId = null } = useParams()
useEffect(() => {
if (!invoices.some((inv) => inv.number === Number(userId))) {
navigate('/404', { replace: true })
}
}, [])
return <div style={{ border: '1px solid' }}>「{userId}」</div>
}
export default UserItem
五、权限需求的讨论
对于一些管理后台,或者对于一些有角色区分的C端业务来说,需要控制不同角色的用户显示不同的内容、限制路由的访问、菜单的不同显示等。因此需要设置一套系统的权限(因为权限设置的一些部分和react-router
相关,所以这里一起讨论)。
首先针对登录,系统中可能某一些界面需要登录后才能操作,某一些界面游客也能看见。需要在路由配置中增加表示需要登录的字段,这个字段的作用范围必须是路由层级向下包容的。举个例子,url
为/a
的时候需要登录,在路由中配置表示需要登录的字段。如果url
变为/a
的子路由/a/b
,则/a/b
也是需要登录的。假设有一个/a/c
,他不是/a
的子路由,而是和/a
是平级的(有这种情况,并不是所有人都合理设计),这个时候我们在/a
的配置就会不会管住/a/c
。
其次针对菜单(多出现在管理后台这种)进行讨论,不同的角色人群需要看见不同的菜单。那么,就需要通过某种方法去筛选,目前主要有两种方式:第一种后端服务返回当前用户能够访问的所有路由,前端拿到路由配置以后再去初始化;第二种,前端保存着所有的路由配,后端只需要返回当前用户的角色字段,前端拿到角色字段去手动筛出路由,并限制好不能访问的路由。第一种的明显缺点就是后端控制了路由,不利于前端的扩展,并且,针对业务模块的权限控制难以展开(比如某一个界面上某一个按钮需要权限控制)。针对第二种,不管你是把所有路由写在一起,在路由里配置访问限制,还是分所谓的动态路由、静态路由,用静态路由merge
根据角色不同所划分的动态路由,最终目的都是产生一个正确的路由配置,能够起到限制作用。
然后,针对显示的内容不同,或者某一个操作模块的权限控制,很自然的,想到的是添加一个针对角色的权限hook
。
有一种情况是针对权限的元子操作(可以理解为区分了很多细小的权限),即我的一个角色比如PM可能有[a,b,c,d,e]
这几种权限,而我另外一种角色比如OM可能有[c,d,e,f,g]
这几种权限,后端返回当前这个人能操作的权限id
数组,前端针对这个权限id数组去筛选和限制路由。为什么会有这种呢?考虑到角色的权限范围可能会随着业务变化,比如某一时期PM是[a,b,c,d,e]
,而到了另外一个时期是[a,c,d,f,g]
。这种情况一般不会出现在路由里面,一般是出现在更细粒度的功能上。菜单上通过角色来控制,一般就是够用了,如果出现需要细粒度控制路由,修改路由筛选逻辑即可。而在页面内容或者功能上,我们则需要再一个针对细粒度权限的hook
。
当然说这么多,其实还是不安全的,毕竟是在用户侧运行前端代码,还是需要后端也做好相应的措施。
哪里说的不对的,或者值得商榷的,欢迎留言讨论。
六、角色定义
基于以上的讨论,我们首先需要定义一个角色体系,然后将其补充到userInfo
中:
// 添加src/content/user.ts
// “运营后台式”的 用户角色
export enum USER_ROLE_ENUM {
ADMIN = 'admin',
PRODUCT_MANAGER = 'pm',
OPERATION_MANAGER = 'om',
INTERN = 'intern',
GUEST = 'guest'
}
// src/store/user.ts
export interface UserStateProps {
userId: string
name: string
phone: string
// 添加角色字段
role: USER_ROLE_ENUM
}
const initState = (): UserStateProps => {
return {
userId: '',
name: '',
phone: '',
// 默认给个游客角色
role: USER_ROLE_ENUM.GUEST
}
}
七、路由定义
需要路由有定义是否需要登录字段,定义哪些角色能访问,因此我们需要在路由里加个字段来存放我们自定义配置,扩展RouteObject
。在src
下面新增types
文件夹用来存放项目各模块定义的type
,然后里面新建routes.ts
存放我们的路由定义。
增加一个meta
字段承载自定义配置,auth
代表该页面是否需要登录,roles
代表哪些角色才能访问,unRoles
代表哪些角色不能访问(加这个字段是为了避免只屏蔽一个角色的时候roles
写很长一串)。
// src/types/routes.d.ts
import type { RouteObject } from 'react-router-dom'
import { USER_ROLE_ENUM } from '@/constants/user'
// 扩展Route定义
export interface RouteProps extends RouteObject {
meta?: {
auth?: boolean
roles?: USER_ROLE_ENUM[]
unRoles?: USER_ROLE_ENUM[]
}
children?: RouteProps[]
}
最新的react-router-dom v6.11.2
改了路由定义(说实话改成了一坨shit)。
// src/types/routes.d.ts 适用于v6.4.3以后(好像是这个版本
import type { IndexRouteObject, NonIndexRouteObject } from 'react-router-dom'
import { USER_ROLE_ENUM } from '@/constants/user'
interface CustomRouteFields {
meta?: {
auth?: boolean
// roles和unRoles冲突的时候,冲突的部分以unRoles为准
roles?: USER_ROLE_ENUM[] // 空数组代表没有谁可以访问
unRoles?: USER_ROLE_ENUM[] // 空数组代表没有谁不可以访问
}
}
type AppIndexRouteObject = IndexRouteObject & CustomRouteFields
type AppNonIndexRouteObject = Omit<NonIndexRouteObject, 'children'> &
CustomRouteFields & {
children?: (AppIndexRouteObject | AppNonIndexRouteObject)[]
}
export type RouteProps = AppIndexRouteObject | AppNonIndexRouteObject
将项目中路由配置改造一下:
// src/routes/config.tsx
// 将setting 和 user-center中添加上meta
// 只贴了改动部分
const routes: RouteProps[] = [
{
path: '/',
children: [
{
path: 'settings',
meta: {
auth: true,
roles: [USER_ROLE_ENUM.ADMIN]
},
element: lazyLoad(React.lazy(() => import('@/views/settings')))
},
{
path: 'user-center',
element: lazyLoad(React.lazy(() => import('@/views/userCenter'))),
meta: {
auth: true,
unRoles: [USER_ROLE_ENUM.GUEST]
},
children: [
...
]
}
]
}
]
八、登录拦截
需要两个条件:界面需要登录 + 用户未登录,同时满足的时候才需要跳到登录界面,并且登录成功以后,需要回到原来的界面。通过react-router
的matchRoutes
来取到当前的路由层级信息,从而取到我们的meta
,然后再去登录鉴权判断。
先把Auth
模块简单模拟出来:
// src/utils/auth.ts
import { USER_ROLE_ENUM } from '@/constants/user'
import { dispatch } from '@/store'
import { init, setUserInfo } from '@/store/user'
/**
* 这里本该是读取登录态
* 我们直接写死 每次进来都是未登录
*/
let isLogin = false
const signIn = () => {
isLogin = true
dispatch(
setUserInfo({
userId: '123',
name: 'awefeng',
phone: '',
role: USER_ROLE_ENUM.ADMIN
})
)
}
const signOut = () => {
isLogin = false
dispatch(init())
}
export function useAuth() {
return {
signIn,
signOut,
isLogin
}
}
每次渲染的时候,用react-router
的matchRoutes
把当前的路由定位出来,判断是否需要登录以及是否登录,如果没有,就跳转到/login
,如果已经登录了,就返回需要渲染的children
,因此我们将这个逻辑抽成一个组件,取名为RouterAuth
:
// src/routes/config.tsx
export const RouterAuth: FC = ({ children }) => {
const { isLogin } = useAuth()
const location = useLocation()
// 匹配当前层级路由树
const mathchs = matchRoutes(routes, location)
// 建议打个断点这里调一下,matchs是返回的层级路由
// 第一个元素为根路由 最后一个元素为当前路由
// 所以我们从前往后匹配
const isNeedLogin = mathchs?.some((item) => {
const route: RouteProps = item.route
// 没有配置字段的直接返回
if (!route.meta) return false
// 返回是否需要登录
return route.meta.auth
})
if (isNeedLogin && !isLogin) {
console.log('需要登录')
// 跳转到登录 state保存源路由
return <Navigate to='/login' state={{ from: location.pathname }} replace />
}
// return children as React.ReactElement
return <Fragment>{children}</Fragment>
}
然后用这个组件去包裹需要渲染的元素,这样每次渲染一个路由的时候就会去检测。
const App: FC = () => {
const Element = useRoutes(routes)
return (
<Provider store={store}>
<RouterAuth>{Element}</RouterAuth>
</Provider>
)
}
顺便修改一下login的登录逻辑:
import { FC, Fragment } from 'react'
import { Button } from 'antd'
import { useAuth } from '@/utils/auth'
import { useLocation, useNavigate } from 'react-router-dom'
const Login: FC = () => {
const { signIn } = useAuth()
const location = useLocation()
const navigate = useNavigate()
const state: any = location.state
const from = state ? state.from : '/'
return (
<Fragment>
<h2>欢迎登录</h2>
<Button
type='primary'
onClick={() => {
signIn()
navigate(from)
}}
>
登录
</Button>
</Fragment>
)
}
export default Login
当我们首次进入/login
的时候,不需要登录,再进入/settings
的时候,由于我们配置auth
为true
,并且没有登录,因此跳转到登录链接,(假)登录以后就回到了原来的/settings
,界面上也显示了我们登录以后获取到的userInfo
中的name
。
九、菜单过滤
基于用户的角色,进行路由的过滤。
在初始化的时候,需要根据当前用户的角色过滤一次,当用户的身份变更的时候(登录、登出、用户信息更新等动作),也需要去更新我们的路由。
首选考虑一下过滤的规则,当前一层级路由已经限制了用户角色的时候,筛选出的子路由也要根据配置字段筛选,因此我们需要写一个函数来递归,这个函数接收默认或者需要筛选的路由,产出筛选以后的路由(到这里的时候我将原来的router/config.tsx
分割为了router/config.tsx
和router/index.tsx
,结构更清晰一点):
// src/router/index.tsx
// 通过用户角色筛选路由
export function screenRoutesByRole(routes: RouteProps[]) {
const { role } = store.getState().user
return routes
.map((route) => {
if (route.meta) {
const { roles: canIn, unRoles: cantIn } = route.meta
// 以unRoles 优先
if (Array.isArray(cantIn) && cantIn.includes(role)) return null
if (Array.isArray(canIn) && !canIn.includes(role)) return null
}
if (!route.children) return route
route.children = screenRoutesByRole(route.children)
return route
})
.filter((i) => i !== null) as RouteProps[]
}
修改一下APP
的渲染逻辑,每一次userInfo
中的role
改变的时候,去重新获取路由:
// src/app.tsx
// 用户角色改变的时候重新获取routes
const App: FC = () => {
const { role } = store.getState().user
const curRoutes = useMemo(() => {
return screenRoutesByRole(routes)
}, [role])
const Element = useRoutes(curRoutes)
return <RouterAuth>{Element}</RouterAuth>
}
为什么不是跳到登录界面呢?因为在<APP/>
里我们首先是去过滤路由,然后再去检查的Auth
,这也符合一般性理解,既然你都没权限查看这个路由,我就直接跳到404
就好了,不需要其他动作。
十、功能权限控制
先说基于角色的控制,这个很简单,只在我们的useAuth
的导出里面加一个canUse
,这个函数接收一个角色枚举值或者一组角色枚举值,如果当前用户的角色满足,就返回true
,否则返回false
:
// src/utils/auth.ts
// 角色功能控制
const canUse = (canUseRole: USER_ROLE_ENUM | USER_ROLE_ENUM[]): boolean => {
const { role } = store.getState().user
if (Array.isArray(canUseRole)) return canUseRole.includes(role)
return role === canUseRole
}
export function useAuth() {
return {
signIn,
signOut,
isLogin,
canUse
}
}
这个随便测试一下就好了,比如在/settings
里加一个只有ADMIN
看得见的button
。
基于细粒度权限的功能控制其实是一样的,后台的userInfo
肯定会返回这个人的权限集,拿到这个权限集再去做相同判断就是了。这里就不重复写了,可以自己写一下。