React实例之完善布局菜单(二)

我们继续未完的课程。

我们已经设计完所有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要通过 MUIcreateSvgIcon 函数进行封装才能用于本菜单的配置。通过上面的配置可以看出,组菜单有 children 项。没有这一属性的就是一级菜单。很容易分别。

我们在 SMenu 项目目录下再健一个目录 SMenu菜单目录, 我取了一个同名的目录名称。用这个目录来存放SMenu的所有子组件。

创建菜单Recucer数据

我们在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;

我们姑且先用 组件来代表这个测试工程,先不用管它。或者留空又或者随便写点啥都行。现在再次回到菜单目录 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的样式, sxMui 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;

大功告成。菜单组件全部设计完成。那么如何应用呢,我们下章详解。

你可能感兴趣的:(前端react技术积累,react.js,javascript,前端,前端框架)