react antd Menu 递归菜单总结

250 阅读4分钟

环境

"antd": "3.26.20",
"react": "^16.8.6",
"react-dom": "^16.8.6",
"react-router-dom": "^5.0.0",
"react-scripts": "3.0.1",

功能

  • 路由表生成菜单
  • 页面刷新展开激活菜单defaultOpenKeys
  • 定位 selectedKeys

效果

image.png

代码

路由结构

interface Route {
  key: string // 路径
  meta: {
    label: string // 名称
    hidden?: boolean // 隐藏
  }
  children?: Route[] // 子路由
}

路由表 src/config/routes.js

// import Login from '../pages/login'
// import Admin from '../pages/admin'
// import Home from '../pages/home'
// import Category from '../pages/category'
// import Product from '../pages/product'
// import Role from '../pages/role'
// import User from '../pages/user'
// import Bar from '../pages/charts/bar'
// import Line from '../pages/charts/line'
// import Pie from '../pages/charts/pie'

export default [
  { key: '/login', /* value: Login, */ meta: { label: '登录', hidden: true } },
  {
    key: '/sub1',
    // value: Admin,
    children: [{ key: '/home', /* value: Home, */ meta: { label: '首页', icon: 'home' } }],
  },
  {
    key: '/sub2',
    // value: Admin,
    meta: { label: '商品', icon: 'android' },
    children: [
      { key: '/category', /* value: Category, */ meta: { label: '品类管理', icon: 'database' } },
      {
        key: '/product',
        meta: { label: '商品管理', icon: 'barcode', isMenuItem: true },
        children: [
          { key: '/product/add-update', meta: { label: '添加/修改商品', icon: 'edit' } },
          { key: '/product/:id', meta: { label: '商品详情', icon: 'form' } },
        ],
      },
    ],
  },
  {
    key: '/sub3',
    // value: Admin,
    meta: { label: '人员', icon: 'team' },
    children: [
      { key: '/role', /* value: Role, */ meta: { label: '角色管理', icon: 'user' } },
      { key: '/user', /* value: User, */ meta: { label: '账号管理', icon: 'robot' } },
    ],
  },
  {
    key: '/sub4',
    // value: Admin,
    meta: { label: '统计', icon: 'area-chart' },
    children: [
      { key: '/charts/bar', /* value: Bar, */ meta: { label: '柱图', icon: 'database' } },
      { key: '/charts/line', /* value: Line, */ meta: { label: '线图', icon: 'barcode' } },
      { key: '/charts/pie', /* value: Pie, */ meta: { label: '饼图', icon: 'pie-chart' } },

      // test
      {
        key: '/aaaaaaaaaaaaaaaaaaaa',
        meta: { label: 'aaaaaaaaaaaaaaaaaaaa', icon: 'team' },
        children: [
          { key: '/bbbbbbbbbbbbbbbb ', meta: { label: 'bbbbbbbbbbbbbbbb', icon: 'home' } },
          {
            key: '/ccccccccccccccccccc',
            meta: { label: 'ccccccccccccccccccc', icon: 'user' },
            children: [
              {
                key: '/dddddddddddddddddddddddddd',
                meta: { label: 'dddddddddddddddddddddddddd', icon: 'home' },
                children: [
                  { key: '/eeee', meta: { label: 'eeee', icon: 'home' } },
                  {
                    key: '/ffffff',
                    meta: { label: 'ffffff', icon: 'home' },
                    children: [
                      { key: '/ggggg', meta: { label: 'ggggg', icon: 'home' } },
                      { key: '/hhhhhhh', meta: { label: 'hhhhhhh', icon: 'home' } },
                    ],
                  },
                ],
              },
            ],
          },
        ],
      },
    ],
  },
]

工具方法 src/utils/menu.js(核心算法)

import React from 'react'
import { Link } from 'react-router-dom'
import { Menu, Icon } from 'antd'

const { SubMenu } = Menu
// 因为路径有可能是 '/product/:id' 如效果图展示的路由, 所以需要正则匹配
const _regExpColonStart = /^:[\w-]+$/
const _regExpMatePathname = (routeKey, pathname) => {
  const routeKeyArr = routeKey.split('/')
  const pathnameArr = pathname.split('/')
  if (routeKeyArr.length !== pathnameArr.length) return false
  return routeKeyArr.every((item, i) => item === pathnameArr[i] || _regExpColonStart.test(item))
}

// UI: 生成菜单组件
export const getMenuList = (routes) => {
  return routes.reduce((result, route) => {
    // 隐藏直接跳过
    if (route.meta && route.meta.hidden) {
    }
    //没有children,空数组也算没有
    else if (!route.children || !route.children.length || (route.meta && route.meta.isMenuItem)) {
      result.push(
        <Menu.Item key={route.key}>
          <Link to={route.key}>
            <Icon type={route?.meta.icon} />
            <span>{route?.meta.label}</span>
          </Link>
        </Menu.Item>,
      )
    }
    // children只有一条,从孙子往后可能多
    else if (route.children && route.children.length === 1) {
      result.push(...getMenuList(route.children))
    }
    // children有2条以上
    else if (route.children && route.children.length > 1) {
      result.push(
        <SubMenu
          key={route.key}
          title={
            <span>
              <Icon type={route?.meta.icon} />
              <span>{route?.meta.label}</span>
            </span>
          }
        >
          {getMenuList(route.children)}
        </SubMenu>,
      )
    }

    return result
  }, [])
}

/**
 * 获取所有SumMenu的key
 * 对应的效果是Menu全部展开
 * 菜单少的时候可以用
 * @param {[]} routes 路由表
 * @param {[]} arr 收集器, 最开始是空数组
 * @returns {[]} arr收集后的结果, 放到 antd Menu defaultOpenKeys 中
 */
export const getAllSubMenuKeys = (routes, arr = []) => {
  return routes.reduce((result, route) => {
    // 隐藏直接跳过
    if (route.meta && route.meta.hidden) return result

    // 0
    if (!route.children || !route.children.length) return result

    // 1 孙子及后代可能多
    if (route.children && route.children.length === 1) return getAllSubMenuKeys(route.children, result)

    // 2
    if (route.children && route.children.length > 1) {
      result.push(route.key)
      return getAllSubMenuKeys(route.children, result)
    }

    return result
  }, arr)
}

/**
 * 获取当前路由所有SumMenu的key
 * 应对的效果是激活的当前路由展开(判断最复杂, 文章重要写的就是这个方法!)
 * 菜单多的时候可以用
 * @param {[]} routes 路由表
 * @param {string} pathname react 中的 this.props.location.pathname
 * @param {object} _obj 收集器集合
 * @param {[]} _obj.resultRoutes 收集route, 最开始是空数组, 不符合要求会清空
 * @param {boolean} _obj.find 是否已经找到标记
 * @returns {object} _obj 收集后的结果,主要是 _obj.resultRoutes 放到 antd Menu defaultOpenKeys 中
 */
export const getRouteMatched = (routes, pathname, _obj = { resultRoutes: [], find: false }) => {
  // debugger
  let i = 0 // 下标, 每一列的开始值为0, 用于标记已经比较至当前列的哪一个route了
  const length = routes.length // route.children的长度

  return routes.reduce((_o, route) => {
    if (_o.find) return _o //若已经找到了, 跳过之后的所有判断, 以免清空收集器obj中的数据
    // 路由表中有隐藏标记直接跳过
    if (route.meta && route.meta.hidden) {
      i++ // 当前列的下标 i 自增+1
      return _o
    }

    // {0}没有children的情况
    if (!route.children || !route.children.length) {
      // 如果找到了!!
      if (route.key === pathname || _regExpMatePathname(route.key, pathname)) {
        _o.find = true // 标记已经找到
        _o.resultRoutes.push(route) // 收集, 面包屑的最后一项
      }
      // 不相等, 且不是当前列的最后一项
      else if (route.key !== pathname && i + 1 !== length) {
        i++ // 当前列的下标 i 自增+1
      }
      // 不相等, 但已经比到最后一项
      else if (route.key !== pathname && i + 1 === length) {
        _o.resultRoutes = [] // 因为没有找到, 清空数组. 也包括祖先的key, e.g ['银河系', '太阳系', '火星']
      }

      return _o // 返回引用收集器
    }
    // {1,}有children的情况
    else {
      /**
       * 也有可能找到, 比如:
       * /product 里还有 /product/detail
       * /product 里还有 /product/64baffb4068cd6606a85fadf
       * /product/:id 匹配 /product/64baffb4068cd6606a85fadf
       */
      if (route.key === pathname || _regExpMatePathname(route.key, pathname)) {
        _o.find = true // 标记已经找到
        _o.resultRoutes.push(route) // 收集, 因为后代可能有命中的, 若没有命中则全部清空
        return _o // 既然已经找到了, 虽然还有 children, 但也停止递归了
      }
      // 不收集只有一个children的route, 不渲染到Menu, 也不渲染到Breadcrumb
      if (route.children.length >= 2) {
        _o.resultRoutes.push(route) // 收集, 因为后代可能有命中的, 若没有命中则全部清空
      }
      i++ // 当前列的下标 i 自增+1
      return getRouteMatched(route.children, pathname, _o)
    }
    // return _o
  }, _obj)
}

UI组件 src/components/left-nav/index.jsx

import React, { Component } from 'react'
import ReactImg from '../react-img' // 一个 logo 标题, 可以删除
import './index.less'
import { Link, withRouter } from 'react-router-dom'
import { Menu } from 'antd'
import routes from '../../config/routes'
import { getMenuList, getAllSubMenuKeys, getRouteMatched } from '../../utils/menu'

class LeftNav extends Component {
  state = {
    theme: 'dark',
  }

  // 性能优化, 一般写同步代码
  UNSAFE_componentWillMount() {
    this.menuList = getMenuList(routes) // 只在生命周期里渲染一次!
  }

  render() {
    const expandAll = getAllSubMenuKeys(routes)
    console.log('全部展开---', expandAll)
    const routesInfo = getRouteMatched(routes, this.props.location.pathname)
    console.log('展开激活---', routesInfo)
    return (
      <div>
        <Link to="/" className="logo-box">
          <ReactImg size={35} />
          <span className="logo-txt" style={{ fontSize: '16px', paddingLeft: '6px' }}>
            React 16 练习项目
          </span>
        </Link>
        <Menu
          theme={this.state.theme}
          // defaultOpenKeys 挺复杂, 我用的是低耦合的写法, routes路由表随便写!
          // defaultOpenKeys={['/ffffff']} // defaultOpenKeys很笨, 写谁激活谁, 不会激活所有祖先级
          // defaultOpenKeys={expandAll} // 获取全部
          defaultOpenKeys={routesInfo.resultRoutes.map((route) => route.key)} // 获取激活
          selectedKeys={
            routesInfo.resultRoutes.map((route) => route.key).splice(routesInfo.resultRoutes.length - 2) // 拿到倒数两项
          }
          mode="inline"
          className="the-menu"
        >
          {this.menuList}
        </Menu>
      </div>
    )
  }
}

export default withRouter(LeftNav)

分部页面路由注册

也可以根据路由表src/config/routes.js写循环

<Switch>
  <Route path="/home" component={Home} />
  <Route path="/category" component={Category} />
  <Route path="/product" component={Product} />
  <Route path="/role" component={Role} />
  <Route path="/user" component={User} />
  <Route path="/charts/bar" component={Bar} />
  <Route path="/charts/line" component={Line} />
  <Route path="/charts/pie" component={Pie} />
  {/* 为了测试无限递归, 注释掉下面 */}
  <Redirect to="/home" component={Home} />
</Switch>

<Switch>
  <Route path="/product" exact component={ProductHome} />
  <Route path="/product/add-update" exact component={AddUpdate} />
  <Route path="/product/:id" component={Detail} />
  <Redirect to="/product" />
</Switch>