赞
踩
我们继续未完的课程。
我们已经设计完所有theme的有关逻辑和代码了。接下来就是菜单部分,首先,菜单分为菜单头和菜单列表,还有收缩模式和缩略模式。为配置能用化的考虑,我们在菜单配置方面采用了 Json
数组。而菜单本身的数据状态和App的业务逻辑是没有关联的。所以我们尽可能的把两者隔离开。组件间的状态共享一般通过 Props 、state、 及Reducer 这么种方式。对于单层状态的上下传递我们采用 Props 和 state 就可以,但对于多层级的状传递这种方式就很不合适了。我们一般采用 Reducer 来管理组件的状态。说到Reducer ,一个是 React Redux , 一个是React本身中的Recuder功能。Redux是重量级的,用于整合App的业务逻辑部分是最合适不过的。但是对于那么细化的封闭的组合Reducer就更加合适了。对于这个SMenu组件,我们采用 Reducer 来完成整个组件的状态的管理。
我们在SMenu目录下创建 menuData.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'; //菜单的测试数据 const sideMenuConfigData = [ { 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 } ]; export default sideMenuConfigData;
设定:为了最大化的保证菜单的格调和美观,我们菜单最大支持到二级菜单。菜单支持 badge
数字提醒功能, 支持图标。为了设计上的统一,图标一定要是MUI的图标,其它svg要通过 MUI
的 createSvgIcon
函数进行封装才能用于本菜单的配置。通过上面的配置可以看出,组菜单有 children 项。没有这一属性的就是一级菜单。很容易分别。
我们在 SMenu 项目目录
下再健一个目录 SMenu菜单目录
, 我取了一个同名的目录名称。用这个目录来存放SMenu的所有子组件。
我们在SMenu菜单目录
下创建一个Provider
文件:SideMenuProvider.jsx
, 我们将有关的初始状态值及相关的Context
都放到这个文件 里,如下所示:
// SideMenuProvider.jsx import { useReducer, createContext, useState } from 'react'; /** * 获取菜单项的id集合, 用于初始化菜单项的徽章,本菜单的每个Item都有一个id属性,用于唯一标识菜单项。 * @param menuConfig * @returns */ function initBadge(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 subName = el.id; ids = { ...ids, [subName]: 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(initBadge(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;
你看,我们在设计Theme
的时候用的方法在这里又一次使用了。文件中我都做了想关说明了,一看就能明白。是不是很顺手。我们设计了众多的Context
, 那么我们就要提供相应的 Hook
使得我们可以在组件内部调用这些值。所以我们在相同的目录下再创建一个Hooks工具库:_SMenuHooks.jsx
// _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); }
至此,这个Provider
就创建完了。我们在SMenu项目目录
下创建一个App.jsx
做为这个示例的入口文件:
import SideMenuTest from "./SideMenuTest";
import SideMenuProvider from "./SMenu/SideMenuProvider";
import sideMenuConfigData from "./menuData";
function App() {
return (
<SideMenuProvider menuData={sideMenuConfigData}>
<SideMenuTest />
</SideMenuProvider>
)
}
export default App;
我们姑且先用 <SideMenuTest />
组件来代表这个测试工程,先不用管它。或者留空又或者随便写点啥都行。现在再次回到菜单目录 SMenu
,我们开始对菜单的组件进行设计
创建 _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'; import { redirect } from 'react-router-dom'; //菜单头 const SideMenuHeader = ({ logo, //图标 title, //标题 onClick //单击事件 }) => { const { open } = useSideMenuState(); const clickEvent = () => { if (onClick == null) { redirect("/"); } else { onClick(); console.log("clickEvent"); } } return ( <Box className="p-3 border-bottom" > <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} onClick={clickEvent} > { title && title.substring(0, 1).toUpperCase() } </Avatar> <Typography className="text-truncate" variant="h5" sx={{pl: 0.5}} > { title || "码蚁基地" } </Typography> </Stack> </Box> ) }; export default SideMenuHeader;
组件内 className
应用的就是Bootstrap
的样式, sx
是Mui
对 style
的封装。 组件 SideMenuHeader
接收一个 logo
文件地址、标题、和一个点击事件的回调。Logo
如果为空则以 标题的第一个字为 Logo
图像。点击回调是赋于Logo
的点击事件的。
这个功能是用来显示消息的红点提示,有两种方案,一个是显示一个小红点,一个是显示数字。大于99的显示 99+的徽章。为了更好的适配菜单,我们用 styled 函数对 Badge 进行了二次封。如下所示,创建 _SideMenuStyledBadge.jsx
// _SideMenuStyledBadge.jsx
import { styled } from '@mui/material/styles';
import Badge from '@mui/material/Badge';
const StyledBadge = styled(Badge)(({ theme }) => ({
'& .MuiBadge-badge': {
right: -22,
top: 16,
// border: `2px solid ${theme.palette.background.paper}`,
padding: '0 4px',
},
}));
export default StyledBadge;
就是没有子菜单项的菜单项, 创建文件 _SideMenuItem.jsx
import Tooltip from '@mui/material/Tooltip'; 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'; import StyledBadge from './_SideMenuStyledBadge'; import Badge from '@mui/material/Badge'; import { grey } from '@mui/material/colors'; /** * 主菜单项组件 * @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={open ? 0 : badgeCount[id]} anchorOrigin={{ vertical: 'top', horizontal: 'left', }} 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, bgcolor:grey[700], transition: '0.2s', transform: open ? 'scale(1)' : 'scale(1.2)' }} variant="rounded"> {title.substring(0, 1).toUpperCase()} </Avatar> : <SvgIcon component={icon} /> } </ListItemIcon> </Badge> </Tooltip> <StyledBadge badgeContent={badgeCount[id]} color="error"> <ListItemText primary={title}/> </StyledBadge> </ListItemButton> ); }; export default SideMenuItem;
二级菜单的子菜单项的组件设计 ,创建文件 _SideMenuSubItem.jsx
// _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 StyledBadge from './_SideMenuStyledBadge'; 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={open ? 0 : activeItemId === groupId ? 0 : badgeCount[id]} anchorOrigin={{ vertical: 'top', horizontal: 'left', }} 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> <StyledBadge badgeContent={badgeCount[id]} color="error"> <ListItemText primary={ <Typography sx={{ display: 'inline' }} component="span" variant="body1" color="text.secondary" > {title} </Typography> } /> </StyledBadge> </ListItemButton> ); } export default SideMenuSubItem;
我们现这个子菜单项与 菜单级合并,形成一个菜单组项,创建文件:_SideMenuGroup.jsx
// _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 StyledBadge from './_SideMenuStyledBadge'; import Box from '@mui/material/Box'; import Stack from '@mui/material/Stack'; import { grey } from '@mui/material/colors'; import { useSideMenuState, useSideMenuStateUpdate, useSideMenuBadge } from './_SMenuHooks'; import SMenuSubItem from './_SideMenuSubItem'; function IconItem ({ open, icon, title }) { return ( <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, bgcolor: grey[600], transition: '0.2s', transform: open ? 'scale(1)' : 'scale(1.2)' }} variant="rounded" > {title.substring(0, 1).toUpperCase()} </Avatar> : <SvgIcon component={icon} /> } </ListItemIcon> ) } /** * 含有子菜单的菜单项 * @param props * @returns */ function SideMenuGroup({ id, //菜单项的ID名称 icon = null, //图标 title, //标题 childrenData, //子菜单 onClick, //单击事件 }) { const { hoverItemId, open } = useSideMenuState(); const updateMenuState = useSideMenuStateUpdate(); const badgeCount = useSideMenuBadge(); const groupBadgeNumber = childrenData.map((item) => badgeCount[item.id]).reduce((a, b) => a + b, 0); const handleClick = () => { updateMenuState({hoverItemId: hoverItemId === id ? null : id}) }; return ( <Box> <ListItemButton onClick={handleClick}> <Tooltip title={open ? null : title} arrow placement="right"> <Badge badgeContent={open ? 0 : hoverItemId === id ? 0 : groupBadgeNumber} anchorOrigin={{ vertical: 'top', horizontal: 'left', }} // variant="dot" color="error"> <IconItem open={open} icon={icon} title={title} /> </Badge> </Tooltip> <Stack direction={"row"} justifyContent={"space-between"} sx={{ width: 300 }}> <StyledBadge badgeContent={ hoverItemId === id ? null : groupBadgeNumber } color="error"> <ListItemText primary={title} /> </StyledBadge> {hoverItemId === id ? <ExpandLess /> : <ExpandMore />} </Stack> </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> </Box> ); } export default SideMenuGroup;
注意,菜单组的 Badge 显示是通过计数子菜单的 badeg 来显示的,尽管我们在 Badge Context 中有配置,但组菜单的这个配置是不启作用的。所以上面就有了这个统计的设计:
const groupBadgeNumber = childrenData.map((item) => badgeCount[item.id]).reduce((a, b) => a + b, 0);
这里我设计了一个收缩菜单的按钮,你可以把它放在任何地方,不一定是在菜单组件内,可以是在整个 App
应用的任何地方都行, 创建文件:_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
,我一般内部组件的文件名前加一个下划线,以示区分,封装好的组件则不加下划线:
// SideMenu.jsx 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 SideMenuHeader from "./_SideMenuHeader"; /** * 菜单的主体组件 * * @returns */ function SideMenu({ title, logo, hClick, mClick, footer, }) { const menuData = useSideMenuData(); const { open } = useSideMenuState(); const openWidth = 300; const minWidth = 65; return ( <Box className="d-flex overflow-hidden h-100" elevation={1} sx={{ transition: "width 0.3s", width: open ? openWidth : minWidth, borderRight: 1, borderColor: "divider", }} > <Box className='d-flex flex-column'> <SideMenuHeader title={title} logo={logo} onClick={hClick} /> <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={mClick} key={index} /> } return <SideMenuGroup icon={item.icon} id={item.id} title={item.title} childrenData={item.children} onClick={mClick} key={index} /> }) } </List> </Box> { footer == null ? null : <> <Divider /> {footer} </> } </Box> </Box> ); } export default SideMenu;
大功告成。菜单组件全部设计完成。那么如何应用呢,我们下章详解。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。