这个教程的最终效果如下:
特点:
- 支持小红点,
- 支持菜单互斥,
- 支持消息数量显示、
- 支持暗黑暗模式
废话少说,下面开撸
环境设置
基于这篇文章,你应该首先已经了解了如何创建react
项目,用create-react-app
也好,用vite
也罢。首先你要会创建项目。我的组件UI采用的是MUI, 所以要安装MUI,当然你也可以使用其它的UI组件。当你学习完这个小节并且充分理解后,你就可以随心所欲的做出修改。
安装环境
## npm安装方式
## MUI
npm install @mui/material @emotion/react @emotion/styled
## 字体
npm install @fontsource/roboto
## 图标
npm install @mui/icons-material
## 动画
npm install react-transition-group --save
## yarn安装方式
yarn add @mui/material @emotion/react @emotion/styled
yarn add @fontsource/roboto
yarn add @mui/icons-material
yarn add react-transition-group
布局样式部份使用了bootstrap中的布局样式。所以还要导入bootstrap的样式。
npm i bootstrap@5.3.2
或者你直接把bootstrap
布局包放到项目文件下直接引用也是一样的。其实我就是这么干的。
我写了一个CssBaseLine
组件,把要引用的样式在这个组件里引用进来就OK了。
import CssBaseline from '@mui/material/CssBaseline';
import '../SCSS/public.css';
import '../SCSS/components.css';
import '../SCSS/bootstrap5.3.0/css/bootstrap-utilities.min.css';
import '../SCSS/bootstrap5.3.0/css/bootstrap-grid.min.css';
export default function AdapterCss() {
return <CssBaseline />
}
这样,你只要在你的App进口组件中引用这个CSSBaseline
就OK了
数据准备
为了减少对第三方组件的依赖,这个部件的内部状态我采用useReact来进行管理。这省去了写一大堆代码的麻烦。
菜单的配置当然越简单越好,能用Json的格式最好,万一你想把配置信息放在服务器上呢,这就方便许多了。我们以JSON数组的方式来进行配置管理。菜单最多可以配置二级菜单。也就是二级菜单以上的菜单就没必要支持了,如果你有二级以的需要,通过这个篇幅学习以后我想你弄个三级四级的支持也不是什么难事。
示例
const sideMenuDataTest = [
{ id: "init", title: "系统初始化", icon: DataUsageIcon },
{ id: "management", title: "用户管理", icon: GroupAddIcon },
{
id: "userMsg", title: "角色管理", icon: PersonIcon, children: [
{ id: "", title: "权限管理", icon: VerifiedUserIcon },
{ id: "pwdMsg", title: "密码管理", icon: PasswordIcon },
{ id: "keyMsg", title: "私钥管理", icon: VpnKeyIcon },
{ id: "agentMsg", title: "权限管理", icon: HealthAndSafetyIcon },
]
},
{ id: "advMsg", title: "广告管理", icon: FeaturedVideoIcon },
{ id: "plyMsg", title: "评论管理", icon: ReplyAllIcon },
{
id: "title", title: "文章管理", icon: null, children: [
{ id: "caogaoMsg", title: "草稿件", icon: null },
{ id: "newFile", title: "新建文章", icon: null },
{ id: "firstMsg", title: "置顶管理", icon: null },
{ id: "recMsg", title: "推荐管理", icon: null },
{ id: "classMsg", title: "类型管理", icon: null },
{ id: "emailMsg", title: "邮箱管理", icon: null },
]
},
{ id: "system", title: "系统设置", icon: null },
{ id: "userCenter", title: "个人中心", icon: null }
];
通过对上面的数据分析,我想你应该不难看出其数据结构的逻辑。有二级菜单的就包含children
项,反之则没有,就这么简单。
状态管理器
这么个相对较复杂组件,还要有高度自定义的特性,我们肯定不能直接封装在一个组件文件里面,肯定要解耦。那么各级组件的状态的流通就直接关系到封装的成败及性能高低了。通过启篇的Gif图可以看到,菜单组成可能分成以下几个大的部分:
1. 主菜单头,里面包含logo 及 项目标题
2. 一菜单栏,包含功能图标及菜单功能标题
3. 二级菜单栏,也称组菜单。含有子菜单
4. 收缩按钮
以上各个组件里还是若干小组件,而菜单的实时状态也是要求无障碍贯穿的。这就需要一系列的状态提供器(provider),但这之前我们要理解useReducer
的相关使用方法:
使用Reducer
useReducer
是一个 React Hook
语法如下:
const [state, dispatch] = useReducer(reducer, initialArg, init?)
具体的用法及介绍请参考我的下一篇技术文章:React之useReducer
定义SideMenuProvider.jsx文件
import { useReducer, createContext, useState } from 'react';
import React from 'react';
/**
* 获取菜单项的id集合, 菜单项的ID必须唯一,用于初始化菜单项的徽章,本菜单的每个Item都有一个id属性,用于唯一标识菜单项。
* @param menuConfig
* @returns
*/
function getIdSet(menuConfig){
let ids = {};
menuConfig.forEach((element) => {
const name = element.id;
ids = { ...ids, [name]: 0 };
if (element.children) {
const children = element.children;
children.forEach(el => {
const name1 = el.id;
ids = { ...ids, [name1]: 0 };
})
}
});
return ids;
}
//菜单的内部状态的初始值,用react的reducer来管理, 用context来向子组件传递通信。
const initState = {
activeItemId: null, //当点击一个菜单项时记录活动菜单项
hoverItemId: null, //当点击一个菜单项组标题时,记录打开的GroupMenu的名称。
open: true, //菜单项的展开模式,true为展开,false为折叠
showDivider: true, //菜单项的分割线模式,true为显示,false为不显示
}
const reducer = (state, action) => {
return {
...state,
...action
}
}
export const SideMenuState = createContext(initState); //菜单的内部状态
export const SideMenuBadge = createContext(null); //菜单的徽章
export const DispatchMenuState = createContext(null); //菜单的内部状态的更新函数
export const DispatchMenuBadge = createContext(null); //菜单的徽章的更新函数
export const SideMenuData = createContext([]); //菜单的数据
/**
* 菜单的上下文Context
* @param children
* @param menuData
* @returns
*/
function SideMenuProvider({ children, menuData }) {
const [badge, updateBadge] = useState(getIdSet(menuData));
const [menuState, updateMenuState] = useReducer(reducer, initState);
const updateBadgeHandler = (id, count) => {
updateBadge((state) => {
return {
...state,
[id]: count
}
})
}
return (
<SideMenuState.Provider value={ menuState }>
<SideMenuBadge.Provider value={badge}>
<DispatchMenuState.Provider value={updateMenuState}>
<DispatchMenuBadge.Provider value={updateBadgeHandler}>
<SideMenuData.Provider value={menuData}>
{
children
}
</SideMenuData.Provider>
</DispatchMenuBadge.Provider>
</DispatchMenuState.Provider>
</SideMenuBadge.Provider>
</SideMenuState.Provider>
)
}
export default SideMenuProvider;
规定每个菜单项的id
必须唯一,这个应该不难理解吧。
getIdSet()
函数的功能是根据配置菜单数据返回所有菜单项的ID集合,用于确定你单击了哪一项菜单。
initState
是reducer的初始状态。 其内部数据项在上面代码的备注中我已经写的很清楚了。
状态定义好了,那么Hook当然也要提供上,要不然有什么意义呢。
定义_SMenuHooks.jsx文件
import { useContext } from 'react';
import { DispatchMenuBadge, DispatchMenuState, SideMenuBadge, SideMenuData, SideMenuState } from './SideMenuProvider';
//获取边栏菜单的状态
export function useSideMenuState() {
return useContext(SideMenuState);
}
//获取边栏菜单的小红点状态
export function useSideMenuBadge() {
return useContext(SideMenuBadge);
}
//更新边栏菜单小红点的工具,用法:
// const update = useSideMenuBadgeUpdate();
// update("menuItemId", 50)
export function useSideMenuBadgeUpdate() {
return useContext(DispatchMenuBadge);
}
//更新边栏菜单工具
export function useSideMenuStateUpdate() {
return useContext(DispatchMenuState);
}
//获取菜单配置项
export function useSideMenuData() {
return useContext(SideMenuData);
}
现在状态有了,引用状态的Hooks也有了,其它的就是如何定义组件了,是不是容易了许多。
菜单头的定义
直接上代码:_SideMenuHeader.jsx
import Box from '@mui/system/Box';
import Avatar from '@mui/material/Avatar';
import Typography from '@mui/material/Typography';
import Stack from '@mui/system/Stack';
import { useSideMenuState } from './_SMenuHooks';
//菜单头
const SideMenuHeader = ({
logo, //图标
title, //标题
onClick //单击事件
}) => {
const { open } = useSideMenuState();
const clickEvent = () => {
onClick && onClick();
}
return (
<Box
onClick={clickEvent}
className="p-3">
<Stack
spacing={2}
direction={"row"}
justifyContent="start"
alignItems={"center"}
className="w-100"
>
<Avatar
sx={{
width: 35,
height: 35,
cursor: "pointer",
transition: '0.2s',
transform: open ? 'scale(1)' : 'scale(1.2)',
}}
src={logo}
variant="rounded"
alt={title}
>
{
title && title.substring(0, 1).toUpperCase()
}
</Avatar>
<Typography className="text-truncate" variant="h5" sx={{pl: 0.5}} > {title} </Typography>
</Stack>
</Box>
)
};
export default SideMenuHeader;
菜单项
_SideMenuItem.jsx
import PropTypes from 'prop-types';
import Tooltip from '@mui/material/Tooltip';
import Badge from '@mui/material/Badge';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import Avatar from '@mui/material/Avatar';
import SvgIcon from '@mui/material/SvgIcon';
import { useSideMenuBadge, useSideMenuState, useSideMenuStateUpdate } from './_SMenuHooks';
/**
* 主菜单项组件
* @param title: 菜单项标题
* @param id: 菜单项ID
* @param icon: 菜单项图标
* @param onClick: 菜单项单击事件
* @returns
*/
const SideMenuItem = ({
title,
id,
icon = null,
onClick,
}) => {
const {activeItemId, open} = useSideMenuState();
const badgeCount = useSideMenuBadge();
const updateMenuState = useSideMenuStateUpdate();
//单击事件
const itemClickeEvent = () => {
updateMenuState({ activeItemId: id });
onClick(id, title, [id], [title]);
}
return (
<ListItemButton
selected={ activeItemId == id }
onClick={itemClickeEvent}
>
<Tooltip title={open ? null : title} arrow placement="right">
<Badge badgeContent={badgeCount[id]} color="error">
<ListItemIcon
sx={{
'& svg': {
transition: '0.2s',
transform: open ? 'scale(1)' : 'scale(1.2)',
},
'&:hover, &:focus': {
'& svg:first-of-type': {
transform: open ? 'scale(1)' : 'scale(1.3)',
}
},
}}>
{
icon == null ?
<Avatar
sx={{
width: 30,
height: 30,
fontSize: 18,
transition: '0.2s',
transform: open ? 'scale(1)' : 'scale(1.2)'
}}
variant="rounded">
{title.substring(0, 1).toUpperCase()}
</Avatar> :
<SvgIcon component={icon} />
}
</ListItemIcon>
</Badge>
</Tooltip>
<ListItemText primary={title} />
</ListItemButton>
);
};
export default SideMenuItem;
子菜单项
_SideMenuSubItem.jsx
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import Typography from '@mui/material/Typography';
import Avatar from '@mui/material/Avatar';
import Tooltip from '@mui/material/Tooltip';
import Badge from '@mui/material/Badge';
import SvgIcon from '@mui/material/SvgIcon';
import CssBaseline from '@mui/material/CssBaseline';
import { useSideMenuBadge, useSideMenuState, useSideMenuStateUpdate } from './_SMenuHooks';
/**
* 子菜单项组件
* @param icon: 菜单项图标
* @param title: 菜单项标题
* @param id: 菜单项ID
* @param groupId: 菜单项组ID
* @param groupTitle: 菜单项组标题
* @param onClick: 菜单项单击事件
* @returns
*/
function SideMenuSubItem({
icon = null,
title,
id,
groupId,
groupTitle,
onClick
}) {
const { activeItemId, open } = useSideMenuState();
const updateMenuState = useSideMenuStateUpdate();
const badgeCount = useSideMenuBadge();
const handleClick = () => {
updateMenuState({ activeItemId: id });
onClick(id, title, [groupId, id], [groupTitle, title])
};
return (
<ListItemButton
onClick={handleClick}
selected={ activeItemId == id }
sx={{
transition: "padding 0.3s",
pl: open ? 5 : 2.5,
}}>
<CssBaseline />
<Tooltip title={open ? null : title} arrow placement="right">
<Badge badgeContent={badgeCount[id]} color="error">
<ListItemIcon
sx={{
'& svg': {
transition: '0.2s',
transform: open ? 'scale(1)' : 'scale(1.2)',
},
'&:hover, &:focus': {
'& svg:first-of-type': {
transform: open ? 'scale(1)' : 'scale(1.3)',
}
},
}}>
{
icon == null ?
<Avatar
sx={{
width: 24,
height: 24,
fontSize: 16,
transition: '0.2s',
transform: open ? 'scale(1)' : 'scale(1.2)',
}}
variant="rounded"
> {title.substring(0, 1).toUpperCase()} </Avatar> :
<SvgIcon component={icon} sx={{ fontSize: 16 }} />
}
</ListItemIcon>
</Badge>
</Tooltip>
<ListItemText
primary={
<Typography
sx={{ display: 'inline' }}
component="span"
variant="body1"
color="text.secondary"
>
{ title }
</Typography>
}
/>
</ListItemButton>
);
}
export default SideMenuSubItem;
菜单组项
_SideMenuGroup.jsx
import React from 'react';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import Collapse from '@mui/material/Collapse';
import ExpandLess from '@mui/icons-material/ExpandLess';
import ExpandMore from '@mui/icons-material/ExpandMore';
import Avatar from '@mui/material/Avatar';
import Badge from '@mui/material/Badge';
import Tooltip from '@mui/material/Tooltip';
import SvgIcon from '@mui/material/SvgIcon';
import { useSideMenuState, useSideMenuStateUpdate, useSideMenuBadge } from './_SMenuHooks';
import SMenuSubItem from './_SideMenuSubItem';
/**
* 含有子菜单的菜单项
* @param props
* @returns
*/
function SideMenuGroup({
id, //菜单项的ID名称
icon = null, //图标
title, //标题
childrenData, //子菜单
onClick, //单击事件
}) {
const { hoverItemId, open } = useSideMenuState();
const updateMenuState = useSideMenuStateUpdate();
const badgeCount = useSideMenuBadge();
const handleClick = () => {
updateMenuState({hoverItemId: hoverItemId === id ? null : id})
};
return (
<React.Fragment>
<ListItemButton onClick={handleClick}>
<Tooltip title={open ? null : title} arrow placement="right">
<Badge badgeContent={badgeCount[id]} color="error">
<ListItemIcon
sx={{
'& svg': {
transition: '0.2s',
transform: open ? 'scale(1)' : 'scale(1.2)',
},
'&:hover, &:focus': {
'& svg:first-of-type': {
transform: open ? 'scale(1)' : 'scale(1.3)',
}
},
}}>
{
icon == null ?
<Avatar
sx={{
width: 30,
height: 30,
fontSize: 18,
transition: '0.2s',
transform: open ? 'scale(1)' : 'scale(1.2)'
}}
variant="rounded"
>
{title.substring(0, 1).toUpperCase()}
</Avatar> :
<SvgIcon component={icon} />
}
</ListItemIcon>
</Badge>
</Tooltip>
<ListItemText primary={title} />
{hoverItemId === id ? <ExpandLess /> : <ExpandMore />}
</ListItemButton>
<Collapse in={ hoverItemId === id } timeout="auto" unmountOnExit>
<List component="div" dense={true} disablePadding>
{
childrenData === undefined ? null :
childrenData.map(function (itemData, index) {
return <SMenuSubItem
icon = { itemData.icon }
title = { itemData.title }
id = {itemData.id}
groupId = {id}
groupTitle={title}
onClick={onClick}
key={index} />
})
}
</List>
</Collapse>
</React.Fragment>
);
}
export default SideMenuGroup;
菜单收缩按钮
_SToggleButton.jsx
import IconButton from '@mui/material/IconButton';
import { useSideMenuState, useSideMenuStateUpdate } from './_SMenuHooks';
/**
* 菜单的展开/收起按钮
* @param {*} param0
* @returns
*/
function SToggleButton({icon}) {
const menuState = useSideMenuState();
const updateMenuState = useSideMenuStateUpdate();
const clickHandler = () => {
updateMenuState({ open: !menuState.open})
}
return (
<IconButton onClick={clickHandler}>
{ icon }
</IconButton>
)
}
export default SToggleButton;
一口气所有组件就撸完了。也不是太难对不对。接下来就是组装了。把上面的状态及组件有机的组件起来就完美了。注意看我的手势,我是有手势的。
菜单组件
SideMenu.jsx
import { ReactNode } from 'react';
import Box from '@mui/system/Box';
import SideMenuItem from './_SideMenuItem';
import Divider from '@mui/material/Divider';
import SideMenuGroup from './_SideMenuGroup';
import { useSideMenuData, useSideMenuState } from './_SMenuHooks';
import { List } from '@mui/material';
import Paper from '@mui/material/Paper';
/**
* 菜单的主体组件
* @returns
*/
function SideMenu({
header,
footer,
onClick
}) {
const menuData = useSideMenuData();
const { open } = useSideMenuState();
const openWidth = 300;
const minWidth = 65;
return (
<Paper
className="d-flex overflow-hidden h-100"
elevation={1}
sx={{
transition: "width 0.3s",
width: open ? openWidth : minWidth,
}}
>
<Box className='d-flex flex-column'>
{
header == null ?
null :
<>
{ header }
<Divider />
</>
}
<Box sx={{ flex: 1, overflowY: "auto", overflowX:"hidden", width: open ? openWidth : minWidth}}>
<List sx={{width: openWidth}}>
{
menuData.map((item, index) => {
const subItemsData = item.children || null;
if (subItemsData == null) {
return <SideMenuItem
id={item.id}
title={item.title}
icon={item.icon}
onClick={onClick}
key={index}
/>
}
return <SideMenuGroup
icon={item.icon}
id={item.id}
title={item.title}
childrenData={item.children}
onClick={onClick}
key={index} />
})
}
</List>
</Box>
{
footer == null ?
null :
<>
<Divider />
{footer}
</>
}
</Box>
</Paper>
);
}
export default SideMenu;
好了,我们刚撸了一个相当棒的布局菜单。撸得我一手的油。怎么用呢,简单:
菜单项测试
SideMenuTest.jsx
import DataUsageIcon from '@mui/icons-material/DataUsage';
import PersonIcon from '@mui/icons-material/Person';
import GroupAddIcon from '@mui/icons-material/GroupAdd';
import VerifiedUserIcon from '@mui/icons-material/VerifiedUser';
import FeaturedVideoIcon from '@mui/icons-material/FeaturedVideo';
import PasswordIcon from '@mui/icons-material/Password';
import VpnKeyIcon from '@mui/icons-material/VpnKey';
import HealthAndSafetyIcon from '@mui/icons-material/HealthAndSafety';
import ReplyAllIcon from '@mui/icons-material/ReplyAll';
import VerticalSplitIcon from '@mui/icons-material/VerticalSplit';
import Box from '@mui/material/Box';
import SideMenuHeader from "../framework-kakaer/SMenu/_SideMenuHeader";
import SideMenu from "../framework-kakaer/SMenu/SideMenu";
import SideMenuProvider from "../framework-kakaer/SMenu/SideMenuProvider";
import SToggleButton from '../framework-kakaer/SMenu/_SToggleButton';
//菜单的测试数据
const sideMenuDataTest = [
{ id: "init", title: "系统初始化", icon: DataUsageIcon },
{ id: "management", title: "用户管理", icon: GroupAddIcon },
{
id: "userMsg", title: "角色管理", icon: PersonIcon, children: [
{ id: "", title: "权限管理", icon: VerifiedUserIcon },
{ id: "pwdMsg", title: "密码管理", icon: PasswordIcon },
{ id: "keyMsg", title: "私钥管理", icon: VpnKeyIcon },
{ id: "agentMsg", title: "权限管理", icon: HealthAndSafetyIcon },
]
},
{ id: "advMsg", title: "广告管理", icon: FeaturedVideoIcon },
{ id: "plyMsg", title: "评论管理", icon: ReplyAllIcon },
{
id: "title", title: "文章管理", icon: null, children: [
{ id: "caogaoMsg", title: "草稿件", icon: null },
{ id: "newFile", title: "新建文章", icon: null },
{ id: "firstMsg", title: "置顶管理", icon: null },
{ id: "recMsg", title: "推荐管理", icon: null },
{ id: "classMsg", title: "类型管理", icon: null },
{ id: "emailMsg", title: "邮箱管理", icon: null },
]
},
{ id: "system", title: "系统设置", icon: null },
{ id: "userCenter", title: "个人中心", icon: null }
];
function SideMenuTest() {
const onClickHandler = (id, title, idPath, titlePath) => {
console.log("ClickedItem => ", idPath);
}
return (
<SideMenuProvider menuData={sideMenuDataTest}>
<Box
className='d-flex overflow-hidden position-absolute w-100 h-100 p-0 m-0'
sx={{ top: 0, left: 0, bottom: 0, right: 0 }}>
<Box className='h-100'>
<SideMenu
header={<SideMenuHeader title="侧边菜单测试系统" />}
onClick={onClickHandler} />
</Box>
<Box className='d-flex overflow-hidden position-relative flex-grow-1'>
<Box className="d-flex flex-column overflow-auto p-0 m-0 position-relative flex-grow-1 flex-nowrap">
<Box className="p-0 w-100 h-100">
<SToggleButton icon={<VerticalSplitIcon />} />
</Box>
</Box>
</Box>
</Box>
</SideMenuProvider>
)
}
export default SideMenuTest;
这下总会了吧。什么,还不会?不知道怎么进?我擦!!!我这都一件不剩了,你还不知道下一步:
入口
_SideMenuItem
// import SideMenuProvider from "../kakaer/SMenu/SideMenuProvider";
import SideMenuTest from "./SideMenuTest";
function App() {
return <SideMenuTest />
}
export default App;
完美!!
大家还可以加入一个暗模式功能,想想看,构思一下怎么实现,看下图:这个就当是留给自己的作业吧。