[React 从零实践02-后台] 权限控制

5,929 阅读12分钟

导航

[react] Hooks

[React 从零实践01-后台] 代码分割
[React 从零实践02-后台] 权限控制
[React 从零实践03-后台] 自定义hooks
[React 从零实践04-后台] docker-compose 部署react+egg+nginx+mysql
[React 从零实践05-后台] Gitlab-CI使用Docker自动化部署

[源码-webpack01-前置知识] AST抽象语法树
[源码-webpack02-前置知识] Tapable
[源码-webpack03] 手写webpack - compiler简单编译流程
[源码] Redux React-Redux01
[源码] axios
[源码] vuex
[源码-vue01] data响应式 和 初始化渲染
[源码-vue02] computed 响应式 - 初始化,访问,更新过程
[源码-vue03] watch 侦听属性 - 初始化和更新
[源码-vue04] Vue.set 和 vm.$set
[源码-vue05] Vue.extend

[源码-vue06] Vue.nextTick 和 vm.$nextTick

[部署01] Nginx
[部署02] Docker 部署vue项目
[部署03] gitlab-CI

[深入01] 执行上下文
[深入02] 原型链
[深入03] 继承
[深入04] 事件循环
[深入05] 柯里化 偏函数 函数记忆
[深入06] 隐式转换 和 运算符
[深入07] 浏览器缓存机制(http缓存机制)
[深入08] 前端安全
[深入09] 深浅拷贝
[深入10] Debounce Throttle
[深入11] 前端路由
[深入12] 前端模块化
[深入13] 观察者模式 发布订阅模式 双向数据绑定
[深入14] canvas
[深入15] webSocket
[深入16] webpack
[深入17] http 和 https
[深入18] CSS-interview
[深入19] 手写Promise
[深入20] 手写函数
[深入21] 算法 - 查找和排序

前置知识

(1) 一些单词

graph:图,图表
intelligence:智能的
contrast:对比

persistence:持久化
( data persistence:数据持久化 )

(2) 权限控制的类型

  • 登陆权限控制
    • 是否登陆
      • 登陆才能访问的页面/路由
      • 不登陆就可以访问的页面/路由,比如 login 页面
  • 页面权限控制
    • 菜单
      • 菜单中的页面/路由是否显示
      • 如果只是控制菜单,还不够,因为如果注册了所有路由,即使菜单隐藏,还是可以通过地址栏访问到
    • 页面
      • 页面的路由是否注册
      • 退一步,如果不根据权限就行路由注册,即使注册了所有路由,没权限就从定向到404,这样虽然不是最好,但也能用
    • 按钮
      • 页面中的按钮(增、删、改、查)是否显示
  • 接口权限控制
    • 兜底
      • 路由可能配置失误,按钮可能忘了加权限,这种时候请求控制可以用来兜底,越权请求将在前端被拦截
      • 通过axios请求响应拦截来实现

(3) react-router-dom 中的 Redirect 组件

  • Redirect => to => state
  • 当to属性是一个对象时 state 属性可以传递一个对象,在to页面中可以通过 this.props.state 获取,应用场景:比如重定向到login页面,登陆成功后要返回之前所在的页面,就可以把当前的location信息通过state带入到login页面

(4) react-router-dom 实现在 Form 未保存时跳转别的路由提示

  • ( Prompt ) 组件 和 ( router.getUserConfirmation ) 配合
  • Prompt
    • message 属性:字符串 或者 函数
      • 函数
        • 返回true,允许跳转
        • 返回false,不允许跳转,没有任何提示
        • 返回字符串,会弹出是否可以跳转的弹窗,提示就是字符串内的内容,确定和取消
      • 字符串
        • 将上面的返回字符串
    • when:boolean
      • true:弹窗
      • false:顺利跳转
  • router.getUserConfirmation(message, callback)
    • 问题:为什么需要getUserConfirmation?
    • 因为:Prompt默认使用window.confirm,丑,可以通过getUserConfirmation自定义样式DOM,阻止默认弹窗
    • 参数:
      • messag:就是Prompt的message指定的字符串
      • callback:true允许跳转,false不允许跳转
在表单组件中使用 Prompt
<Prompt message={() => isSave ? true : '表单还未保存,真的需要跳转吗?'} ></Prompt>



ReactDOM.render(
  <Provider store={store}>
    <Router getUserConfirmation={getUserConfirmation}> // ----------- getUserConfirmation
      <App />
    </Router>
  </Provider>,
  document.getElementById('root')
);
function getUserConfirmation(message: string, callback: any) {
  Modal.confirm({ // ----------------------------------------------- antd Modal
    content: message, // ------------------------------------------- message就是Pormpt组件的message返回的字符串
    cancelText: '取消',
    okText: '确定',
    onCancel: () => {
      callback(false) // ------------------------------------------- callback(false) 不跳转
    },
    onOk: () => {
      callback(true) // -------------------------------------------- callback(true) 跳转
    }
  })
}

(5) react-router-config 源码分析

  • react-router-config 官网

  • 为啥要分析 react-router-config

  • 因为做路由权限时,需要向route配置对象中添加一些权限相关的自定义属性,但我们又想用集中式路由来管理

  • react-router-config => renderRoutes 源码分析

renderRoutes 一个最重要的api
----

import React from "react";
import { Switch, Route } from "react-router";
function renderRoutes(routes, extraProps = {}, switchProps = {}) {
  return routes ? (
    <Switch {...switchProps}>
      {routes.map((route, i) => (
        <Route
          key={route.key || i}
          path={route.path}
          exact={route.exact}
          strict={route.strict}
          render={props =>
            route.render ? (
              route.render({ ...props, ...extraProps, route: route })
            ) : (
              <route.component {...props} {...extraProps} route={route} />
            )
          }
        />
      ))}
    </Switch>
  ) : null;
} 
export default renderRoutes;


解析:
1. renderRoutes()只遍历一层routes,不管你嵌套多少层routes数组,你都需要在对应的组件中再次调用renderRoutes()传入该层该routes
2. 所以:在每层的render和componet两个属性中,都需要传入该层的route配置对象,在组件中通过props.route.routes获取该层的routes (重要)
3. exact和strict都是boolean类型的数据,所以当配置对象中不存在这两个属性时,boolen相当于传入false即不生效
4. render属性是一个函数,(routesProps) => {...} ,routeProps包含 match, location and history

(6) antd4版本以上 自定义图标组件

import { createFromIconfontCN } from '@ant-design/icons';

const MyIcon = createFromIconfontCN({
  scriptUrl: '//at.alicdn.com/t/font_8d5l8fzk5b87iudi.js', // 在 iconfont.cn 上生成 => Symbol方式!!!!!
});

ReactDOM.render(<MyIcon type="icon-example" />, mountedNode);

(7) 添加别名 @ 映射 src 在TS的项目中

    1. create-react-app构建的项目,eject后,找到 config/webpack.config.js => resolve.alias
    1. tsconfig.json 中删除 baseUrlpaths,添加 "extends": "./paths.json"
    1. 在根目录新建 paths.json 文件,写入 baseUrlpaths 配置
  • 教程地址
1. webpack.config.js => resolve => alias
module.export = {
   resolve: {
     alias: {
      "@": path.resolve(__dirname, '../src')
     }
   }
 }
2. 根目录新建 paths.json 写入以下配置
{
  "compilerOptions": {
    "baseUrl": "src",
    "paths": {
      "@/*": ["*"]
    }
  }
}
3. 在 tsconfig.json 中做如下修改,添加( extends ), 删除(  baseUrl,paths )
{
  // "baseUrl": "src",
  // "paths": {
  //   "@/*": ["src/*"]
  // },
  "extends": "./paths.json"
}

(8) create-react-app 配置全局的 scss ,而不需要每次 @import

  • 安装 sass-resources-loader
  • 修改 config/webpack.config.js 如下
  • 注意:很多教程修改use:getStyleLoaders().concat()这样修改不行
const getStyleLoaders = (cssOptions, preProcessor) => {
  const loaders = [......].filter(Boolean);
  if (preProcessor) {
    loaders.push(......);
  }
  if (preProcessor === 'sass-loader') { // ------------ 如果第二个参数是 sass-loader,就 push sass-resources-loader
    loaders.push({
      loader: 'sass-resources-loader',
      options: {
          resources: [
            // 这里按照你的文件路径填写../../../ 定位到根目录下, 可以引入多个文件
            path.resolve(__dirname, '../src/style/index.scss'),
          ]
      }
    })
  }
  return loaders;
};

(9) eslint 检查 react-hooks 语法

  • eslint-plugin-react-hooks
  • 比如:可以检查 hooks 不能在循环,条件等地方使用,不能在回调中使用等等
  • 安装:yarn add eslint-plugin-react-hooks --dev
  • 使用:在 .eslintrc.js 中 添加 pluginrules 配置
/* eslint-disable */
module.exports = {
  "env": {
    "es6": true, // 在开发环境,启用es6语法,包括全局变量
    "node": true,
    "browser": true
  },
  "parser": "babel-eslint", // 解析器
  "parserOptions": { // 解析器选项
    "ecmaVersion": 6, // 启用es6语法,不包括全局变量
    "sourceType": "module",
    "ecmaFeatures": { //额外的语言特性
      "jsx": true // 启用jsx语法
    }
  },
  "plugins": [
    // ...
    "react-hooks" 
  ],
  rules: {
    'no-console': 'off', // 可以console
    'no-debugger': 'off',
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  },
}
/* eslint-disable */


(一) react中实现权限控制

(1) ( 嵌套路由注册 ) 和 ( menu ) 和 ( breadcrumb面包屑 ) 共用同一份 ( routes )

  • 好处
    • 路由注册的path和menu的path共用一个,而不用分开维护
    • 集中式路由,统一维护,虽然router4是分布式路由思想
  • 注意点
    • ( menu根据不同权限显示隐藏 ) 和 ( 路由根据权限注册和不注册 ) 是两个概念,如果只是控制menu的显示隐藏,而所有的路由都注册的话,即使页面上没有出现别的权限的菜单,但是通过地址栏输入地址等方式还是可以导航到路由注册的页面,这就需要不在权限的路由不注册或者跳转到404页面或者做提示没权限等处理
    • ( 子菜单 ) 用 ( subs ) 数组属性表示,( 嵌套路由 ) 用 ( routes ) 数组属性表示
    • menu是树形菜单,所以注册路由时要递归遍历注册每一层,menu中有子菜单我们用 subs 表示
    • 如果menu的item存在subs,则该item层级不应该有 pathcomponent 属性
    • ( 即只有menu.item有上面这两个属性,submenu没有,因为不需要显示和跳转 )
    • 全局下renderRoutes遍历一次routes,即只注册第一层的routes,嵌套路由存在routes属性,在相应的路由页面中再次调用renderRoutes注册路由,但是再递归遍历所有的menu相关的subs进行路由注册
  • 代码
  • routes
routes是这样一个数组
----


const totalRoutes: IRouteModule[] = [
  {
    path: '/login',
    component: Login,
  },
  {
    path: '/404',
    component: NotFound,
  },
  {
    path: '/',
    component: Layout,
    routes: [ 
      // routes:用于嵌套路由,注意不是嵌套菜单
      // subs:主要还遍历注册menu树形菜单,和渲染menu树形菜单,在不同系统的路由中定义了subs
      // ----------------------------------------------------------- 嵌套路由通过 renderRoutes 做处理
      ...adminRoutes, // ------------------------------------ ( 后台系统路由 ),单独维护,同时用于menu
      ...bigScreenRoutes, // -------------------------------- ( 大屏系统路由 ),单独维护,同时用于menu
    ]
  }
]

---- 分割线 ---- 

const adminRoutes: IRouteModule[] = [{ 
  // ---------------- adminRoutes 用于menu的树形菜单的 ( 渲染 )和 ( 路由注册,注册可以在同一层级,因为mune视口一样 )
  title: '首页',
  icon: 'anticon-home--line',
  key: '/admin-home',
  path: '/admin-home',
  component: AdminHome,
}, {
  title: 'UI',
  icon: 'anticon-uikit',
  key: '/admin-ui',
  subs: [{ 
    // -------------------------------------------------------- subs用于注册路由,并且用于menu树形菜单的渲染 
    // -------------------------------------------------------- ( 路由注册:其实就是在不同的地方渲染 <Route /> 组件 )
    // -------------------------------------------------------- ( 菜单渲染:其实就是menu菜单在页面上显示 )
    title: 'Antd',
    icon: 'anticon-ant-design',
    key: '/admin-ui/antd',
    subs: [{
      title: '首页',
      icon: 'anticon-codev1',
      key: '/admin-ui/antd/index',
      path: '/admin-ui/antd/index',
      component: UiAntd,
    }]
  }, {
    title: 'Vant',
    icon: 'anticon-relevant-outlined',
    key: '/admin-ui/vant',
    path: '/admin-ui/vant',
    component: UiAntd,
  }]
}]
  • renderRoutes - 重点
import React from 'react'
import { IRouteModule } from '../../global/interface'
import { Switch, Route } from 'react-router-dom'

/**
 * @function normolize
 * @description 递归的对route.subs做normalize,即把所有嵌套展平到一层,主要对menu树就行路由注册
 * @description 因为menu树都在同一个路由视口,所以可以在同一层级就行路由注册
 * @description 注意:path 和 component 在存在subs的那层menu-route对象中同时存在和同时不存在
 */
function normolize(routes?: IRouteModule[]) {
	let result: IRouteModule[] = []
	routes?.forEach(route => {
		!route.subs
			? result.push(route)
			: result = result.concat(normolize(route.subs)) // ---------------- 拼接
	})
	return result
}


/**
 * @function renderRoutes
 * @description 注册所有路由,并向嵌套子路由组件传递 route 对象属性,子组件就可以获取嵌套路由属性 routes
 */
const renderRoutes = (routes?: IRouteModule[], extraProps = {}, switchProps = {}) => {
	return routes
		? <Switch {...switchProps}>
			{normolize(routes).map((route, index) => { // --------------------- 先对subs做处理,再map
				return route.path && route.component &&
				// path 并且 component 同时存在才进行路由注册
				// path 和 componet 总是同时存在,同时不存在
					<Route
						key={route.key || `${index + +new Date()}`}
						path={route.path}
						exact={route.exact}
						strict={route.strict}
						render={props => {
							return route.render
								? route.render({ ...props, ...extraProps, route: route })
								: <route.component {...props} {...extraProps} route={route} /> 
								// 向嵌套组件中传递 route属性,通过route.routes在嵌套路由组件中可以再注册嵌套路由
						}} />
			})}
		</Switch>
		: null
}

export {
	renderRoutes
}
  • menu
	/**
	 * @function renderMenu
	 * @description 递归渲染菜单
	 */
	const renderMenu = (adminRoutes: IRouteModule[]) => {
		return adminRoutes.map(({ subs, key, title, icon }) => {
			return subs
				?
				<SubMenu key={key} title={title} icon={<IconFont type={icon || 'anticon-shouye'} />}>
					{renderMenu(subs)}
				</SubMenu>
				:
				<Menu.Item key={key} icon={<IconFont type={icon || 'anticon-shouye'} />} >{title}</Menu.Item>
		})
	}
  • 嵌套路由
	<Layout className={styles.layoutAdmin}> 
    // -------------------------------------------------------------------------------- Layout 是 '/' 路由对应的组件
    // ---------------- {renderRoutes(props.route.routes)} 就是在 '/' 路由中渲染的 <Route path="" compoent="" />组件
			<Sider>
				<Menu
					mode="inline"
					theme="dark"
					onClick={goPage}
				>
					{renderMenu(adminRoutes)}
				</Menu>
			</Sider>
			<Layout>
				<Header className={styles.header}>
					<ul className={styles.topMenu}>
						<li>退出</li>
					</ul>
				</Header>
				<Content className={styles.content}>
					{renderRoutes(props.route.routes)} // --------------- 再次执行,注册嵌套的路由,成为父组件的子组件
				</Content>
			</Layout>
		</Layout>

(2) 在(1)的基础上加入权限 ( 登陆,页面,菜单 )

  • 要达到的效果 ( 菜单和路由两个方面考虑 )
    • menu根据权限显示和隐藏 注意menu中由于存在树形,为了控制粒度更细,在 submenu 和 menu.item 上都加入权限的判断比较好
    • router根据权限注册和不注册
  • 需要添加的字段
    • needLoginAuth:boolen
      • 表示路由/菜单是否需要登陆权限
      • ( 只要登陆,后端就会返回角色,不同角色的权限可以用rolesAuth数组表示,如果返回的角色在rolesAuth数组中,就注册路由 或 显示菜单)
      • 如果 needLoginAuth是false,则就不需要有 rolesAuth 字段了,即任何角色都会有的路由或菜单
    • rolesAuth:array
      • 该路由注册/菜单显示 需要的角色数组
    • meta: object
      • 可以把 needLoginAuthrolesAuth 放入 meta 对象中,便于管理
    • visiable
      • visiable主要用于 list 和 detail 这两种类型的页面,详情页在menu中是不展示的,但是需要注册Route,需要用字段来判断隐藏掉详情页
  • 模拟需求
    • 角色有两种:user 和 admin
    • 菜单权限
      • 首页:登陆后,两种角色都可以访问
      • UI:
        • ui 这个菜单两种角色都显示
        • ui/antd 这个菜单只有 admin 可以访问和显示
        • ui/vant 这个菜单两种角色都可以显示
      • JS:
        • 只有admin可以显示
  • 代码
  • 改造后的routes
const totalRoutes: IRouteModule[] = [  {    path: '/login',    component: Login,    meta: {      needLoginAuth: false    }  },  {    path: '/404',    component: NotFound,    meta: {      needLoginAuth: false    }  },  {    path: '/',    component: Layout,    meta: {      needLoginAuth: true,      rolesAuth: ['user', 'admin']
    },
    routes: [       // routes:用于嵌套路由,注意不是嵌套菜单      // subs:主要还遍历注册menu树形菜单,和渲染menu树形菜单,在不同系统的路由中定义了subs      // 嵌套路由通过 renderRoutes函数 做处理      ...adminRoutes, // --------------------------- 后台系统路由表      ...bigScreenRoutes, // ----------------------- 大屏系统路由表    ]
  }
]


---- 分割线 ----


const adminRoutes: IRouteModule[] = [{  title: '首页',  icon: 'anticon-home--line',  key: '/admin-home',  path: '/admin-home',  component: AdminHome,  meta: {    needLoginAuth: true,    rolesAuth: ['user', 'admin']
  },
}, {
  title: 'UI',
  icon: 'anticon-uikit',
  key: '/admin-ui',
  meta: {
    needLoginAuth: true,
    rolesAuth: ['user', 'admin']
  },
  subs: [{ // subs用于注册路由,并且用于menu树形菜单
    title: 'Antd',
    icon: 'anticon-ant-design',
    key: '/admin-ui/antd',
    meta: {
      needLoginAuth: true,
      rolesAuth: ['user','admin']
    },
    subs: [{
      title: '首页',
      icon: 'anticon-codev1',
      key: '/admin-ui/antd/index',
      path: '/admin-ui/antd/index',
      component: UiAntd,
      meta: {
        needLoginAuth: true,
        rolesAuth: ['user', 'admin']
      },
    }, {
      title: 'Form表单',
      icon: 'anticon-yewubiaodan',
      key: '/admin-ui/antd/form',
      path: '/admin-ui/antd/form',
      component: UiAntdForm,
      meta: {
        needLoginAuth: true,
        rolesAuth: ['admin']
      },
    }]
  }, {
    title: 'Vant',
    icon: 'anticon-relevant-outlined',
    key: '/admin-ui/vant',
    path: '/admin-ui/vant',
    component: UiVant,
    meta: {
      needLoginAuth: true,
      rolesAuth: ['user', 'admin']
    },
  }]
}, {
  title: 'JS',
  icon: 'anticon-js',
  key: '/admin-js',
  meta: {
    needLoginAuth: true,
    rolesAuth: ['user', 'admin']
  },
  subs: [{
    title: 'ES6',
    icon: 'anticon-6',
    key: '/admin-js/es6',
    path: '/admin-js/es6',
    component: JsEs6,
    meta: {
      needLoginAuth: true,
      rolesAuth: ['user', 'admin']
    },
  }, {
    title: 'ES5',
    icon: 'anticon-js',
    key: '/admin-js/es5',
    path: '/admin-js/es5',
    component: UiAntd,
    meta: {
      needLoginAuth: true,
      rolesAuth: ['user', 'admin']
    },
  }]
}]
  • 对routes和menu过滤的函数
/**
 * @function routesFilter routes的权限过滤
 */
export function routesFilter(routes: IRouteModule[], roles: string) {
	return routes.filter(({ meta: { needLoginAuth, rolesAuth }, routes: nestRoutes, subs }) => {
		if (nestRoutes) { // 存在routes,对routes数组过滤,并重新赋值过滤后的routes
			nestRoutes = routesFilter(nestRoutes, roles) // 递归
		} 
		if (subs) { // 存在subs,对subs数组过滤,并重新赋值过滤后的subs
			subs = routesFilter(subs, roles) // 递归
		}
		return !needLoginAuth
			? true
			: rolesAuth?.includes(roles)
				? true
				: false
	})
}
  • renderRoutes 登陆权限的验证,路由注册过滤即路由注册权限,menu的过滤显示隐藏不在这里进行


/**
 * @function renderRoutes
 * @description 注册所有路由,并向嵌套子路由组件传递 route 对象属性,子组件就可以获取嵌套路由属性 routes
 */
const renderRoutes = (routes: IRouteModule[], extraProps = {}, switchProps = {}) => {
	const history = useHistory()
	const token = useSelector((state: {app: {loginMessage: {token: string}}}) => state.app.loginMessage.token)
	const roles = useSelector((state: {app: {loginMessage: {roles: string}}}) => state.app.loginMessage.roles)
	if (!token) {
		history.push('/login') // token未登录去登陆页面,即登陆权限的验证!!!!!!!!!!!!!!!!!!!!
	}
	routes = routesFilter(routes, roles) // 权限过滤,这里只用于路由注册,menu过滤还需在menu页面调用routesFilter
	routes = normalize(routes) // 展平 subs

	return routes
		? <Switch {...switchProps}>
			{
				routes.map((route, index) => { // 先对subs做处理
					return route.path && route.component &&
						// path 并且 component 同时存在才进行路由注册
						// path 和 componet 总是同时存在,同时不存在
						<Route
							key={route.key || `${index + +new Date()}`}
							path={route.path}
							exact={route.exact}
							strict={route.strict}
							render={props => {
								return route.render
									? route.render({ ...props, ...extraProps, route: route })
									: <route.component {...props} {...extraProps} route={route} />
								// 向嵌套组件中传递 route属性,通过route.routes在嵌套路由组件中可以再注册嵌套路由
							}} />
				})}
		</Switch>
		: null
}
  • menu的过滤
	/**
	 * @function renderMenu
	 * @description 递归渲染菜单
	 */
	const renderMenu = (adminRoutes: IRouteModule[]) => {
		const roles =
			useSelector((state: { app: { loginMessage: { roles: string } } }) => state.app.loginMessage.roles) ||
			getLocalStorage('loginMessage').roles;
        // 这里用 eslint-plugin-react-hooks 会报错,因为 hooks 必须放在最顶层
        // useSelector

		adminRoutes = routesFilter(adminRoutes, roles) // adminRoutes权限过滤!!!!!!!!!!!!!!!!!!!!!

		return adminRoutes.map(({ subs, key, title, icon }) => {
			return subs
				?
				<SubMenu key={key} title={title} icon={<IconFont type={icon || 'anticon-shouye'} />}>
					{renderMenu(subs)}
				</SubMenu>
				:
				<Menu.Item key={key} icon={<IconFont type={icon || 'anticon-shouye'} />} >{title}</Menu.Item>
		})
	}

(3) breadcrumb 面包屑

  • 面包屑要解决的基本问题
    • 对于导航到详情页的动态路由,要显示到面包屑
    • 对于有menu.item即routes中有component的route对象,要能够点击并导航
    • 对于submenu的item不能点击,并置灰
    • 如何判断是否可以点击? 如果routes具有subs数组,就不可以点击;只有menu.item的route可以点击
  • 因为面包屑是根据当前的url的pathname来进行判断的,所以无需做持久化,只要刷新地址栏不变就不会变
  • 但是有点需要注意:就是退出登陆时,应该清除掉 localStorage 中的用于缓存menu等所有数据,而刷新时候不需要,如果退出时不清除localStorage,登陆重定向到首页,就会加载首页的面包屑和缓存的menu,造成不匹配
import { Breadcrumb } from 'antd'
import React from 'react'
import { useHistory, useLocation } from 'react-router-dom'
import styles from './breadcrumb.module.scss'
import { routesFilter } from '@/utils/render-routes/index'
import adminRoutes from '@/router/admin-routes'
import { useSelector } from 'react-redux'
import { IRouteModule } from '@/global/interface'
import { getLocalStorage } from '@/utils'
import _ from 'lodash'


const CustomBreadcrumb = () => {
  const roles = useSelector((state: any) => state.app.loginMessage.roles) || getLocalStorage('loginMessage').roles
  const pathname = useLocation().pathname // 获取url的path
  const history = useHistory()

  // routeParams => 获取useParams的params对象,对象中包含动态路由的id属性
  const routeParams = getLocalStorage('routeParams')

  // 深拷贝 权限过滤后的adminRoutes
  const routesAmin = _.cloneDeep([...routesFilter(adminRoutes, roles)]) // 权限过滤,为了和menu同步

  // generateRouteMap => 生成面包屑的 path,title映射
  const generateRouteMap = (routesAmin: IRouteModule[]) => {
    const routeMap = {}
    function step(routesAmin: IRouteModule[]) {
      routesAmin.forEach((item, index) => {
        if (item.path.includes(Object.keys(routeParams)[0])) { // 动态路由存在:符号,缓存该 route,用于替换面包屑的最后一级名字
          item.path = item.path.replace(`:${Object.keys(routeParams)[0]}`, routeParams[Object.keys(routeParams)[0]])
          // 把动态路由参数(:id) 替换成真实的(params)
        }
        routeMap[item.path] = item.title
        item.subs && step(item.subs)
      })
    }
    step(routesAmin) // 用于递归
    return routeMap
  }
  const routeMap = generateRouteMap(routesAmin)

  // generateBreadcrumbData => 生成面包屑的data
  const generateBreadcrumbData = (pathname: string) => {
    const arr = pathname.split('/')
    return arr.map((item, index) => {
      return arr.slice(0, index + 1).join('/')
    }).filter(v => !!v)
  }
  const data = generateBreadcrumbData(pathname)

  // pathFilter 
    // 面包屑是否可以点击导航
    // 同时用来做可点击,不可点击的 UI
  const pathFilter = (path: string) => {
    // normalizeFilterdAdminRoutes => 展平所有subs
    function normalizeFilterdAdminRoutes(routesAmin: IRouteModule[]) {
      let normalizeArr: IRouteModule[] = []
      routesAmin.forEach((item, index: number) => {
        item.subs
          ?
          normalizeArr = normalizeArr.concat(normalizeFilterdAdminRoutes(item.subs))
          :
          normalizeArr.push(item)
      })
      return normalizeArr
    }
    const routes = normalizeFilterdAdminRoutes(_.cloneDeep(routesAmin))

    // LinkToWhere => 是否可以点击面包屑
    function LinkToWhere(routes: IRouteModule[]) {
      let isCanGo = false
      routes.forEach(item => {
        if (item.path === path && item.component) {
          isCanGo = true
        }
      })
      return isCanGo
    }
    return LinkToWhere(routes)
  }

  // 点击时的导航操作
  const goPage = (item: string) => {
    pathFilter(item) && history.push(item)
    // 函数组合,可以点击就就跳转
  }

  // 渲染 breadcrumb
  const renderData = (item: string, index: number) => {
    return (
      <Breadcrumb.Item key={index} onClick={() => goPage(item)}>
        <span
          style={{
            cursor: pathFilter(item) ? 'pointer' : 'not-allowed',
            color: pathFilter(item) ? '#4DB2FF' : 'silver'
          }}
        >
          {routeMap[item]}
        </span>
      </Breadcrumb.Item>
    )
  }

  return (
    <Breadcrumb className={styles.breadcrumb} separator="/">
      {data.map(renderData)}
    </Breadcrumb>
  )
}

export default CustomBreadcrumb
  • 上面的面包屑存在的问题
    • 需求:面包屑在点击到详情时,更新全局面包屑
    • 不足:使用localstore,在子组件set,在父组件get,但是父组件先执行,子组件后执行,并且localstore不会更新组件,所以导致面包屑不更新
    • 代替:在子组件 es6detail 中 dispatch 了一个action,但不是在onClick的事件中,触发了警告

// 需求:面包屑在点击到详情时,更新全局面包屑
// 不足:使用localstore,在子组件set,在父组件get,但是父组件先执行,子组件后执行,并且localstore不会更新组件,所以导致面包屑不更新
// 代替:在子组件 es6detail 中 dispatch 了一个action,但不是在onClick的事件中,触发了警告
  // 之所以还这样做,是要在子组件es6detail更新后,b更新CustomBreadcrumb
  // 因为子组件es6detail更新了store,而父组件 CustomBreadcrumb 有引用store中的state,所以会更新
  // 不足:触发了警告
const CustomBreadcrumb = () => {
  const roles = useSelector((state: any) => state.app.loginMessage.roles) || getLocalStorage('loginMessage').roles
  const pathname = useLocation().pathname // 获取url的path
  const history = useHistory()

  // routeParams => 获取useParams的params对象,对象中包含动态路由的id属性
  const routeParams = getLocalStorage('routeParams')
  // debugger

  // 深拷贝 权限过滤后的adminRoutes
  const routesAmin = _.cloneDeep([...routesFilter(adminRoutes, roles)]) // 权限过滤,为了和menu同步

  // generateRouteMap => 生成面包屑的 path,title映射
  const generateRouteMap = (routesAmin: IRouteModule[]) => {
    const routeMap = {}
    function step(routesAmin: IRouteModule[]) {
      routesAmin.forEach((item, index) => {
        if (item.path.includes(routeParams && Object.keys(routeParams)[0])) { // 动态路由存在:符号,缓存该 route,用于替换面包屑的最后一级名字
          item.path = item.path.replace(`:${Object.keys(routeParams)[0]}`, routeParams[Object.keys(routeParams)[0]])
          // 把动态路由参数(:id) 替换成真实的(params)
        }
        routeMap[item.path] = item.title
        item.subs && step(item.subs)
      })
    }
    step(routesAmin) // 用于递归
    return routeMap
  }
  const routeMap = generateRouteMap(routesAmin)

  // generateBreadcrumbData => 生成面包屑的data
  const generateBreadcrumbData = (pathname: string) => {
    const arr = pathname.split('/')
    return arr.map((item, index) => {
      return arr.slice(0, index + 1).join('/')
    }).filter(v => !!v)
  }
  const data = generateBreadcrumbData(pathname)

  // pathFilter 
  // 面包屑是否可以点击导航
  // 同时用来做可点击,不可点击的 UI
  const pathFilter = (path: string) => {
    // normalizeFilterdAdminRoutes => 展平所有subs
    function normalizeFilterdAdminRoutes(routesAmin: IRouteModule[]) {
      let normalizeArr: IRouteModule[] = []
      routesAmin.forEach((item, index: number) => {
        item.subs
          ?
          normalizeArr = normalizeArr.concat(normalizeFilterdAdminRoutes(item.subs))
          :
          normalizeArr.push(item)
      })
      return normalizeArr
    }
    const routes = normalizeFilterdAdminRoutes(_.cloneDeep(routesAmin))

    // LinkToWhere => 是否可以点击面包屑
    function LinkToWhere(routes: IRouteModule[]) {
      let isCanGo = false
      routes.forEach(item => {
        if (item.path === path && item.component) {
          isCanGo = true
        }
      })
      return isCanGo
    }
    return LinkToWhere(routes)
  }

  // 点击时的导航操作
  const goPage = (item: string) => {
    pathFilter(item) && history.push(item)
    // 函数组合,可以点击就就跳转
  }

  // 渲染 breadcrumb
  const renderData = (item: string, index: number) => {
    return (
      <Breadcrumb.Item key={index} onClick={() => goPage(item)}>
        <span
          style={{
            cursor: pathFilter(item) ? 'pointer' : 'not-allowed',
            color: pathFilter(item) ? '#4DB2FF' : 'silver'
          }}
        >
          {routeMap[item]}
        </span>
      </Breadcrumb.Item>
    )
  }

  return (
    <Breadcrumb className={styles.breadcrumb} separator="/">
      {data.map(renderData)}
    </Breadcrumb>
  )
}

export default CustomBreadcrumb

(4) menu数据持久化

  • 相关属性
    • openKeys
    • onOpenChange()
    • selectedKeys
    • onClick()
  • 存入localStorage,在effect中初始化
import React, { useEffect, useState } from 'react'
import { renderRoutes, routesFilter } from '@/utils/render-routes/index'
import styles from './index.module.scss'
import { Button, Layout, Menu } from 'antd';
import adminRoutes from '@/router/admin-routes'
import { IRouteModule } from '@/global/interface'
import IconFont from '@/components/Icon-font'
import { useHistory } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { getLocalStorage, setLocalStorage } from '@/utils';
import CustomBreadcrumb from '@/components/custorm-breadcrumb';
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';

const { SubMenu } = Menu;
const { Header, Sider, Content } = Layout;

const Admin = (props: any) => {
	const [collapsed, setcollapsed] = useState(false)
	const [selectedKeys, setSelectedKeys] = useState(['/admin-home'])
	const [openKeys, setOpenKeys]: any = useState(['/admin-home'])
	const history = useHistory()

	useEffect(() => {
		// 初始化,加载持久化的 selectedKeys 和 openKeys
		const selectedKeys = getLocalStorage('selectedKeys')
		const openKeys = getLocalStorage('openKeys')
		setSelectedKeys(v => v = selectedKeys)
		setOpenKeys((v: any) => v = openKeys)
	}, [])

	/**
	 * @function renderMenu
	 * @description 递归渲染菜单
	 */
	const renderMenu = (adminRoutes: IRouteModule[]) => {
		const roles =
			useSelector((state: { app: { loginMessage: { roles: string } } }) => state.app.loginMessage.roles) ||
			getLocalStorage('loginMessage').roles;

		const adminRoutesDeepClone = routesFilter([...adminRoutes], roles) // adminRoutes权限过滤

		return adminRoutesDeepClone.map(({ subs, key, title, icon, path }) => {
			return subs
				?
				<SubMenu key={key} title={title} icon={<IconFont type={icon || 'anticon-shouye'} />}>
					{renderMenu(subs)}
				</SubMenu>
				:
				!path.includes(':') && <Menu.Item key={key} icon={<IconFont type={icon || 'anticon-shouye'} />} >{title}</Menu.Item>
			// 动态路由不进行显示,因为一般动态路由是详情页
			// 虽然不显示,但是需要注册路由,只是menu不显示
		})
	}

	// 点击 menuItem 触发的事件
	const goPage = ({ keyPath, key }: { keyPath: any[], key: any }) => {
		history.push(keyPath[0])
		setSelectedKeys(v => v = [key])
		setLocalStorage('selectedKeys', [key]) // 记住当前点击的item,刷新持久化
	}

	// 展开/关闭的回调
	const onOpenChange = (openKeys: any) => {
		setOpenKeys((v: any) => v = openKeys)
		setLocalStorage('openKeys', openKeys) // 记住展开关闭的组,刷新持久化
	}

	const toggleCollapsed = () => {
		setcollapsed(v => v = !v)
	};

	return (
		<Layout className={styles.layoutAdmin}>
			<Sider collapsed={collapsed}>
				<Menu
					mode="inline"
					theme="dark"
					onClick={goPage}
					// inlineCollapsed={} 在有 Sider 包裹的情况下需要在Sider中设置展开隐藏
					inlineIndent={24}
					selectedKeys={selectedKeys}
					openKeys={openKeys}
					onOpenChange={onOpenChange}
				>
					{renderMenu([...adminRoutes])}
				</Menu>
			</Sider>
			<Layout>
				<Header className={styles.header}>
					<aside>
						<span onClick={toggleCollapsed}>
							{collapsed
								? <MenuUnfoldOutlined className={styles.toggleCollapsedIcon} />
								: <MenuFoldOutlined className={styles.toggleCollapsedIcon} />
							}
						</span>
					</aside>
					<ul className={styles.topMenu}>
						<li onClick={() => history.push('/login')}>退出</li>
					</ul>
				</Header>
				<Content className={styles.content}>
					<CustomBreadcrumb />
					{renderRoutes(props.route.routes)}
					{/* renderRoutes(props.route.routes) 再次执行,注册嵌套的路由,成为父组件的子组件 */}
				</Content>
			</Layout>
		</Layout>
	)
}

export default Admin

项目源码

资料

react路由鉴权(完善) juejin.cn/post/684490…
快速打造react管理系统(项目) juejin.cn/post/684490…
权限控制的类型 juejin.cn/post/684490…
React-Router实现前端路由鉴权:juejin.cn/post/685705…
react-router-config路由鉴权:github.com/leishihong/…