react + react-router v6 + antd5实现动态路由和动态菜单

前言

最近在写一个练手的react后台项目,使用react-router v6 + antd5来实现动态路由和动态菜,在实现动态路由和动态菜单部分还是遇到了不少问题,但最后还是解决了大部分问题,今天就来分享了下我是如何实现并解决问题的

动态菜单

菜单是使用的antd5的菜单组件,在 4.20.0 版本后,提供了 <Menu items={[...]} /> 的简写方式,有更好的性能和更方便的数据组织方式,不再需要自行拼接 JSX

看了看官方示例,label和key是必须要有的,代表菜单项标题和菜单项的唯一标志

由于后端也是我来写,所以怎么方便怎么来,直接在后端整理好数据再返回给客户端,icon图标则由前端根据返回的标识字段ps_icon来创建

然后直接赋值给<Menu items={[...]} />就行了

tsx 复制代码
const [menus, setMenus] = useState<any[]>([])
useEffect(() => {
    const { id } = localcache.getCacha('userinfo')
    // 获取菜单列表
    getMenus(id).then((res) => {
      setMenus(res.data.menus)
    })
}, [])

<Sider trigger={null} collapsible collapsed={collapsed} width={256}>
    {menus.length && (
      <Menu
        style={{ height: '100%' }}
        defaultSelectedKeys={selectKey}
        defaultOpenKeys={openKey}
        mode="inline"
        theme={state.isDark ? 'dark' : 'light'}
        onClick={handleMenuOnClick}
        items={menus}
      />
    )}
  </Sider>

但是还缺少了菜单的图标,菜单的图标也是动态的,服务端返回的菜单列表中带有图标的标识,再根据标识动态的创建图标,首先需要先单独安装一下antd的图标npm install --save @ant-design/icons

创建一个处理菜单的ts文件,定义一个addIconToMenu方法来为菜单创建图标,方法接收一个数组并且根据图标表示递归创建图标

ts 复制代码
// menuTool.ts
import React from 'react'
import * as Icons from '@ant-design/icons'
const iconList: any = Icons

/**
 * 菜单列表添加icon
 * @param menuData 菜单列表
 * @returns
 */
export function addIconToMenu(menuData: any) {
  for (let i = 0; i < menuData.length; i++) {
    if (menuData[i].ps_icon) {
      menuData[i].icon = React.createElement(iconList[menuData[i].ps_icon])
    }

    if (menuData[i].children) {
      menuData[i].children = addIconToMenu(menuData[i].children)
    }
  }

  return menuData
}

在主页面导入这个方法传入服务端返回的菜单列表并执行,将返回的结果重新setState,图标就成功的动态创建出来了

js 复制代码
// main.tsx
useEffect(() => {
    const { id } = localcache.getCacha('userinfo')
    getMenus(id).then((res) => {
      setMenus(addIconToMenu(JSON.parse(JSON.stringify(res.data.menus))))
    })
}, [])

动态路由

为什么需要动态路由呢,虽然可以根据自己角色的权限动态生成菜单,然后点击子菜单跳转不同的页面,但是这样防不了一些用户在地址栏去输入一些不属于自己权限的页面地址,所以路由也需要动态去生成

路由使用的是react-router v6的<HashRouter/>组件和useRouteshook,router文件夹中定义两个组件,分别是baseRouterindex组件,baseRouter组件负责定义一些基本的路由,index组件负责注册路由

js 复制代码
// baseRouter.tsx
import { lazy } from 'react'
import type { RouteObject } from 'react-router-dom'
import { Navigate } from 'react-router-dom'
const Main = lazy(() => import('@/views/main'))
const Login = lazy(() => import('@/views/login'))
const NotFound = lazy(() => import('@/views/notFound'))

const baseRouter: RouteObject[] = [
  {
    path: '/',
    element: <Navigate to="/login" />
  },
  {
    path: '/login',
    element: <Login />
  },
  {
    path: '/main/*',
    element: <Main />,
    children: [{ path: '', element: <></> }]
  },
  {
    path: '*',
    element: <NotFound />
  }
]
export default baseRouter

// index.tsx
import { useEffect } from 'react'
import { useRoutes } from 'react-router-dom'
import { shallowEqual } from 'react-redux'
import { useAppSelector } from '@/store'
import baseRouter from './baseRouter'

function GetRoutes() {
  const { routes } = useAppSelector(
    (state) => ({
      routes: state.common.routes
    }),
    shallowEqual
  )
  useEffect(() => {
    baseRouter[2].children = routes
  }, [routes])

  const element = useRoutes(baseRouter)
  return <>{element}</>
}
export default GetRoutes

然后在主页面里处理路由配置,由于是跨组件添加路由配置所以使用redux状态管理,index组件监听到动态路由的配置发生改变就重新执行useRoutes定义路由,在处理菜单的ts文件中再定义一个addRouteToMenu方法,根据菜单列表递归添加路由配置

js 复制代码
// main.ts
useEffect(() => {
    const { id } = localcache.getCacha('userinfo')
    getMenus(id).then((res) => {
        setMenus(addIconToMenu(JSON.parse(JSON.stringify(res.data.menus))))
        dispatch(changeRoutesAction(addRouteToMenu(res.data.menus)))
    })
}, [])

// menuTool.ts
/**
 * 添加路由配置
 * @param menuData 菜单列表
 * @returns 动态路由表
 */
export function addRouteToMenu(menuData: IMenuListType[]): IRoutesType[] {
  let temp: IRoutesType[] = []
  menuData.forEach((menu) => {
    if (menu.ps_level !== '0') {
      const path = `${menu.ps_c}/${menu.ps_a}`
      const elementPath = `/${menu.ps_c}/${menu.ps_a}`
      const obj: IRoutesType = {
        path,
        element: LazyLoad(elementPath)
      }
      temp.push(obj)
    }
    if (menu.children) {
      const result = addRouteToMenu(menu.children)
      temp = temp.concat(result)
    }
  })
  return temp
}

LazyLoad是一个延迟加载的组件,从React库中导入和lazy实现懒加载,并通过动态import()语句来导入要加载的模块,如果加载失败则尝试导入一个备选模块

js 复制代码
// LazyLoad.tsx
import { Suspense, lazy } from 'react'

function LazyLoad(url: string) {
  const Module = lazy(() => {
    return new Promise((resolve) => {
      import(/* @vite-ignore */ '../views/main' + url)
        .then((res) => resolve(res))
        .catch((err) => {
          resolve(import('../views/' + 'notFound'))
          console.log(err)
        })
    })
  })

  return (
    <Suspense>
      <Module />
    </Suspense>
  )
}

export default LazyLoad

动态import()加载模块产生的问题

但是打包运行的时候发现页面报错,无法获取动态导入的模块,这是因为这个项目是由vite构建的,webpack才是使用import来导入导出的

Vite支持使用特殊的 import.meta.glob 函数从文件系统导入多个模块,然后再配合@loadable/component动态加载模块,打包后运行就没问题了

js 复制代码
// LazyLoad.tsx
import { Suspense } from 'react'
import loadable from '@loadable/component'
const modules = import.meta.glob('@/views/main/*/*.tsx')
const loadables: any = loadable

function LazyLoad(url: string) {
  const ComponentNode = loadables(async () => {
    return modules[`/src/views/main${url}.tsx`]()
  })
  return (
    <Suspense>
      <ComponentNode />
    </Suspense>
  )
}
export default LazyLoad

补充

刷新页面匹配不到路由

如果用户刷新页面是匹配不到路由的,因为服务器还没返回菜单列表,动态路由还没有创建成功,所以需要使用react-Router v6库中提供的useLocation获取当前的url地址,动态路由创建完成后再使用useNavigate进行路由的跳转

js 复制代码
// mian.tsx
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
const navigate = useNavigate()
const { pathname } = useLocation()

useEffect(() => {
const { id } = localcache.getCacha('userinfo')
getMenus(id).then((res) => {
  dispatch(changeRoutesAction(addRouteToMenu(res.data.menus)))
  setMenus(addIconToMenu(JSON.parse(JSON.stringify(res.data.menus))))
  navigate(pathname)
})
}, [])

子菜单点击跳转路由

子菜单点击的时候可以获取菜单项的唯一标志,根据这个标志遍历递归菜单列表找到对应的节点就可以根据节点的路径来进行路由的跳转了

js 复制代码
// mian.tsx
// 子菜单点击触发的方法
const handleMenuOnClick: MenuProps['onClick'] = (e) => {
    if (!state.menuList.length) return
    const menu = findObjectById(state.menuList, parseInt(e.key))
    navigate(`/main/${menu?.ps_c}/${menu?.ps_a}`)
}

// menuTool.tsx
/**
 * 根据id查找对应的菜单对象
 * @param menuData 菜单列表
 * @param idToFind key
 * @returns 对应的菜单对象
 */
export function findObjectById(menuData: IMenuListType[], idToFind: number): IMenuListType | null {
  for (let i = 0; i < menuData.length; i++) {
    if (menuData[i].key === idToFind) {
      return menuData[i]
    } else if (menuData[i].children) {
      const result = findObjectById(menuData[i].children!, idToFind)
      if (result) {
        return result
      }
    }
  }
  return null
}

菜单项和子菜单项的初始展开

当在某个页面刷新页面后菜单项和子菜单项的初始展开也都会消失,菜单项和子菜单项的初始展开是根据defaultOpenKeysdefaultSelectedKeys属性来决定的

所以defaultOpenKeysdefaultSelectedKeys对应的值还是和上面一样通过遍历递归菜单列表找到对应的节点,因为对应的节点就有当前菜单项的唯一标志和父菜单项的唯一标志

js 复制代码
// mian.tsx
const [selectKey, setSelectKey] = useState<string[]>([])
const [openKey, setOpenKey] = useState<string[]>([])
const { pathname } = useLocation()
useEffect(() => {
    const { id } = localcache.getCacha('userinfo')
    getMenus(id).then((res) => {
      // ......
      const menu = findObjectByPath(res.data.menus, pathname)
      setSelectKey([menu?.key + ''])
      setOpenKey([menu?.ps_pid + ''])
    })
}, [])


/**
 * 根据路径查找对应的菜单对象
 * @param menuData 菜单列表
 * @param path 路径
 * @returns 对应的菜单对象
 */
export function findObjectByPath(menuData: IMenuListType[], path: string): IMenuListType | null {
  for (let i = 0; i < menuData.length; i++) {
    const menuPath = `/main/${menuData[i]?.ps_c}/${menuData[i]?.ps_a}`
    if (menuPath === path) {
      return menuData[i]
    } else if (menuData[i].children) {
      const result = findObjectByPath(menuData[i].children!, path)
      if (result) {
        return result
      }
    }
  }
  return null
}

总结

以上就是我对动态路由和动态菜单的实现,如果还有更好的方式也欢迎大家一起分享交流,学习学习

相关推荐
Pedantic4 小时前
SwiftUI 手势层级(Gesture Hierarchy)详解
前端
飘尘4 小时前
前端转型全栈(Java后端)的快速上手指引
前端·后端·全栈
一颗烂土豆4 小时前
Meshopt 压缩深度解析,为什么它比 Draco 更快
前端·javascript·webgl
YFF菲菲兔5 小时前
调度系统和调和系统的桥梁
react.js
浏览器工程师5 小时前
AI Agent 接浏览器任务,先别让它一路点到底
前端·后端
雨季mo浅忆5 小时前
VSCode自动格式化三要素
前端
爱勇宝6 小时前
深扒 Anthropic 1680 位工程师简历:应届生几乎没机会,AI 公司最缺的不是博士
前端·后端·程序员
kyriewen7 小时前
同事每天催我 Code Review,我写了个脚本让 AI 替我 review PR——现在他反过来催 AI 了
前端·javascript·ai编程
user20585561518139 小时前
Windows 项目安装时报 `node-sass` 错误,如何快速处理
前端
LiaCode9 小时前
Redis 在生产项目的使用
前端·后端