React-Router6指北+项目权限设计

5,546 阅读13分钟

大家好,我是刚加入掘金的"三重堂堂主"(公众号:咪仔和汤圆,欢迎关注~)

未经授权,禁止转载~

因为新搭了个后台项目,刚好在做权限和路由这一块,就和大家一起探讨下~

也是为了填上搭建工程系列的坑,重新梳理一下前端路由和React-Router相关知识。

项目基于github.com/awefeng/fe-…

更新:

  1. 更新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-routerreact-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')
)

启动项目后路由生效了,但是当我们在子路由刷新的时候,会提示找不到界面。

添加路由后.gif

这是因为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,所以需要配置historyApiFallbacktrue原因在这,此时在子路由上刷新界面,还是访问不了~

image.png

发现资源文件路径错了(因为相对路径的问题),所以还需要在webpackoutput下配置资源的公共路径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: '/'
},

这个时候我们就能够正常访问了。

image.png

三、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-routermatchRoutes来取到当前的路由层级信息,从而取到我们的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-routermatchRoutes把当前的路由定位出来,判断是否需要登录以及是否登录,如果没有,就跳转到/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

640 (1).gif

当我们首次进入/login的时候,不需要登录,再进入/settings的时候,由于我们配置authtrue,并且没有登录,因此跳转到登录链接,(假)登录以后就回到了原来的/settings,界面上也显示了我们登录以后获取到的userInfo中的name

九、菜单过滤

基于用户的角色,进行路由的过滤。

在初始化的时候,需要根据当前用户的角色过滤一次,当用户的身份变更的时候(登录、登出、用户信息更新等动作),也需要去更新我们的路由。

首选考虑一下过滤的规则,当前一层级路由已经限制了用户角色的时候,筛选出的子路由也要根据配置字段筛选,因此我们需要写一个函数来递归,这个函数接收默认或者需要筛选的路由,产出筛选以后的路由(到这里的时候我将原来的router/config.tsx分割为了router/config.tsxrouter/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>
}

640 (2).gif

为什么不是跳到登录界面呢?因为在<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肯定会返回这个人的权限集,拿到这个权限集再去做相同判断就是了。这里就不重复写了,可以自己写一下。