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

导航

[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路由鉴权(完善) https://juejin.im/post/6844903924441284615
快速打造react管理系统(项目) https://juejin.im/post/6844903981945208839
权限控制的类型 https://juejin.im/post/6844903882338861063
React-Router实现前端路由鉴权:https://juejin.im/post/6857055615739985933
react-router-config路由鉴权:https://github.com/leishihong/react-router-config

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 230,321评论 6 543
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 99,559评论 3 429
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 178,442评论 0 383
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 63,835评论 1 317
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 72,581评论 6 412
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 55,922评论 1 328
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 43,931评论 3 447
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 43,096评论 0 290
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 49,639评论 1 336
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 41,374评论 3 358
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 43,591评论 1 374
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 39,104评论 5 364
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 44,789评论 3 349
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 35,196评论 0 28
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 36,524评论 1 295
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 52,322评论 3 400
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 48,554评论 2 379

推荐阅读更多精彩内容