使用React + Antd4.x + React Router 6.x 封装菜单(多级菜单)和动态面包屑
1. 总览
1.1 效果图:
1.2 实现功能
- 根据路由表自动生成菜单
- 刷新页面可回显菜单
- 动态生成面包屑
2. 具体实现
2.1 根据路由表自动生成菜单
2.1.1 配置路由表
React Router V6
引入useRoutes
这个hook
来解析路由表,路由表的参数必须有path
和element
这两个,其他的根据项目可进行自定义。
下面是我定义的路由表接口:
export interface IRouterMap {
path: string,
auth: number, // 是否需要鉴权
title: string,
key: string, // 与path保持一致
element: any,
hidden?: boolean, // 是否显示在菜单上, 默认要显示。加该参数主要是隐藏用于重定向的菜单
children? : IChildRouterMap[]
}
export interface IChildRouterMap extends IRouterMap {
parentpath: string // 父级路径 用于面包屑和回显两个逻辑 如果是三级菜单 那么parentpath = /一级路径/二级路径 以此类推
}
以下截图是路由表一部分↓
你可能会发现有重复项,那是路由重定向的功能~
2.1.2 引入组件 渲染菜单
<Menu
items={menuList}
mode="inline"
selectedKeys={[selectedKeys]}
openKeys={openKeys}
onClick={handleMenuClick}
onOpenChange={handleOpenChange}
/>
Menu
组件里展示的标题是label字段,所以我们需要将路由表里的title赋值给label字段(需要递归处理)
const [menuList, setMenuList] = useState<IChildRouterMap[]>([])
React.useEffect(() => {
// 菜单文字的处理
const items = RouterMapAuth[0].children?.filter(item => !item.hidden)
handleMenuLabel(items as IChildRouterMap[])
}, [])
const handleMenuLabel = (menuItem: IChildRouterMap[]) => {
let list: IChildRouterMap[] = []
menuItem?.forEach((item: any) => {
item.label = item.title
list.push(item)
if (item.children) handleMenuLabel(item.children)
})
setMenuList(list)
}
2.2 刷新页面可回显菜单
2.2.1 存取关键字段
openKeys
当前展开的 SubMenu 菜单项 key 数组selectedKeys
当前选中的菜单项 key 数组onOpenChange
SubMenu 展开/关闭的回调
我们会用到以上三个属性以及点击事件去实现该功能。
- 当点击菜单时,我们可以获取到当前菜单项,菜单key,keyPath(是个数组)
- 我们首先需要把
key
存到本地,- 然后判断
keyPath
的长度是否大于1
,
- 如果大于1说明点击的是多级菜单,那么我们就需要把数组最后一项添加到给
openKeys
里- 如果小于1说明点击的是普通层级的菜单,我们就把
openKeys
进行清空
- 接下来就是跳转功能
- 如果当前菜单项的
parentpath === /
那么说明是普通层级的菜单直接跳转navigate(item.props.path)
- 如果不等于 就跳转到
navigate(`${item.props.parentpath}/${item.props.path}`)
当点击父级菜单时(触发
openChange
),我们只需要把回调参数赋值给openKeys
即可
直接贴代码:
const navigate = useNavigate()
const { pathname } = useLocation()
const [selectedKeys, setSelectedKeys] = useState<string>('')
const [openKeys, setOpenKeys] = useState<Array<string>>([])
const [menuList, setMenuList] = useState<IChildRouterMap[]>([])
const { t } = useTranslation()
React.useEffect(() => {
// 从当前url里取出路径 回显对应的菜单
SStorage.setItem('selectedKeys', pathname.slice(pathname.lastIndexOf('/') + 1))
// 当多级菜单时 还需设置openKey 回显默认展开的嵌套菜单
if (pathname.lastIndexOf('/') !== 0) {
SStorage.setItem('openKeys', pathname.match(/(?<=\/).*?(?=\/)/g))
}
// 回显默认选中的菜单和展开的嵌套菜单
SStorage.getItem('selectedKeys') ? setSelectedKeys(SStorage.getItem('selectedKeys')) : setSelectedKeys('homepage')
setOpenKeys(SStorage.getItem('openKeys'))
}, [pathname])
const handleMenuClick: MenuProps['onClick'] = ({ item, key, keyPath }: any) => {
// 存储key到本地 刷新页面选择的菜单也不会丢失
SStorage.setItem('selectedKeys', key)
if (keyPath.length > 1) {
// 点击多级菜单时 存储被点击的root menu key, 用来回显
SStorage.setItem('openKeys', [keyPath[keyPath.length - 1]])
} else {
// 当点击一级菜单时,清空curNestedKey,这样刷新的时候就不会展开没有被选中的二级菜单
SStorage.setItem('openKeys', [])
}
if (item.props.parentpath === '/') {
navigate(item.props.path)
} else {
navigate(`${item.props.parentpath}/${item.props.path}`)
}
}
const handleOpenChange: MenuProps['onOpenChange'] = (openKeys: string[]) => {
setOpenKeys(openKeys)
}
2.3 动态生成面包屑
2.3.1 分析
- 面包屑数据需要时数组格式,并且数据量 >= 3 时,需要给非第一项和最后一项加上点击跳转功能
- 面包屑标题来源 可以用之前
hidden
为true
的路由项(因为他们是父级重定向的,可以单独筛选出来)以下称为routesParents
。- 拿到路由表里没有被隐藏的项(
hidden
为false
),需要用到扁平化数组的方法。- 当每次页面路径变化时,拿到当前路径最后一段(最后一个
/
后的内容),然后和扁平化之后的路由项(以下称为item
)进行比较
- 当
item.parentpath === /
时,说明是一级菜单。直接往面包屑数组里push
当前item
的title
属性- 当
item.parentpath !== /
时,说明是多级菜单,那么我们就要拿到item
的parentpath
,并用/
分割成数组,好用来与routesParents
进行遍历比较 拿到当前父级菜单的title.- 然后就是双重循环进行添加
title
和path
代码:
export default function BreadCrumb() {
const { pathname } = useLocation()
const { t } = useTranslation()
const [breadCrumb, setBreadCrumb] = useState<IBreadCrumbs[]>([])
const routes = RouterMapAuth[0].children.filter(item => !item.hidden)
let routesParents: IChildRouterMap[] = [] // 隐藏起来的路由(可以利用他们的title属性设置面包屑)
React.useEffect(() => {
routesParents = handleFlattenRoutes(RouterMapAuth[0].children).filter(item => item.hidden)
const path = pathname.slice(pathname.lastIndexOf('/') + 1)
handleGetBreadcrumb(handleFlattenRoutes(routes), path)
}, [pathname])
const handleFlattenRoutes = (routes: IChildRouterMap[]) => {
return routes.reduce((pre, next) => {
return pre.concat(Array.isArray(next.children) ? handleFlattenRoutes(next.children) : next)
},[])
}
const handleGetBreadcrumb = (routes: IChildRouterMap[], path: string) => {
let arr = []
let breadPath = [] // 面包屑需要跳转的地址
routes.map(item => {
if (item.path === path) {
if (item.parentpath === '/') {
setBreadCrumb([{ title: item.title }])
} else {
// 当为三级及以上菜单时,需要给面包屑的第二级加上跳转功能
const parentpath = item.parentpath.split('/').filter(item => item)
parentpath.map((item, index) => {
routesParents.map(item2 => {
if (item === item2.path) {
if (index < parentpath.length - 1) {
// 除了最后一项 其他的都需要把path存进去
breadPath.push(item2.path)
}
if (parentpath.length >= 2 && index !== 0) {
// 说明时三级及以上的菜单层级
// 不给面包屑的第一个层级加跳转功能
arr.push({ title: item2.title, path: breadPath[index - 1] })
} else {
arr.push({ title: item2.title })
}
}
})
})
arr.push({ title: item.title })
setBreadCrumb(arr)
}
}
})
}
return (
<div className={style['breadCrumb']}>
{
breadCrumb.map(item =>
<div className={style['breadCrumb-item']} key={item.title}>
{
item.path ?
<NavLink to={item.path} >{t(item.title)}</NavLink> :
<div className={style['breadCrumb-item-label']}>{t(item.title)}</div>
}
</div>
)
}
</div>
)
}