react + Material UI递归实现侧边导航(多层级)

项目使用MUI 5实现,过程中需要使用到侧边导航,翻看了整个文档,最终决定使用accordion来实现多层级的侧边导航
先看效果图

react + Material UI递归实现侧边导航(多层级)_第1张图片
react + Material UI递归实现侧边导航(多层级)_第2张图片

先看完整代码
import React from 'react'
import { styled } from '@mui/material/styles'
import type { AccordionProps } from '@mui/material/Accordion'
import type { AccordionSummaryProps } from '@mui/material/AccordionSummary'
import MuiAccordionSummary from '@mui/material/AccordionSummary'
import MuiAccordionDetails from '@mui/material/AccordionDetails'
import { menuList } from './config'
import IconArrow from '~icons/common/arrow.svg'

const Accordion = styled((props: AccordionProps) => <Mui.Accordion disableGutters elevation={0} square {...props} />)(
  ({ theme }) => ({
    '&:not(:last-child)': {
      borderBottom: 0
    },
    '&:before': {
      display: 'none'
    }
  })
)
const AccordionSummary = styled((props: AccordionSummaryProps) => <MuiAccordionSummary {...props} />)(({ theme }) => ({
  backgroundColor: 'rgba(255, 255, 255, .05)',
  '& .MuiAccordionSummary-expandIconWrapper': {
    transform: 'rotate(90deg)'
  },
  '& .MuiAccordionSummary-expandIconWrapper.Mui-expanded': {
    transform: 'rotate(180deg)'
  }
}))

const AccordionDetails = styled(MuiAccordionDetails)(({ theme }) => ({
  padding: theme.spacing(2),
  cursor: 'pointer',
  display: 'flex',
  alignItems: 'center'
}))

const activeStyles = {
  backgroundColor: 'rgba(38, 91, 50, 0.1)',
  transition: 'background-color .6s',
  color: '#265B32'
}
interface NavData {
  key: string
  level: number
  name: string
  icon: string
  path?: string
  children?: AnyObject[]
}

function Menu() {
  const navigate = useNavigate()
  const { pathname, state } = useLocation()
  const [expanded, setExpanded] = React.useState<string | false>('panel1')
  const [currentNav, setCurrentNav] = useState('')
  const [selected, setSelected] = useState<string[]>([])

  const handleExpanded = (panel: string) => (event: React.SyntheticEvent, newExpanded: boolean) => {
    setExpanded(newExpanded ? panel : false)
  }

  const handleClick = (item: AnyObject) => () => {
    if (!item.children) {
      if (item.path !== pathname) {
        navigate(item.path, { state: item.key })
        setCurrentNav(item.path)
      }
    }
  }

  function getAllParentArr(data: AnyObject[], key: string) {
    for (const i in data) {
      if (data[i].key === key) {
        return [data[i]]
      }
      if (data[i]?.children) {
        const node = getAllParentArr(data[i].children, key) as AnyObject[]
        if (node) {
          return node.concat(data[i])
        }
      }
    }
  }

  useEffect(() => {
    setCurrentNav(pathname)
    const result = getAllParentArr(menuList, state) as AnyObject[]
    if (result && result.length) {
      setSelected(result.map((item: AnyObject) => item.key))
    }
  }, [pathname])

  function renderMenu(data: AnyObject[], level: number) {
    if (data) {
      return data.map((value: any) => {
        value.level = level + 1
        return renderSubMenu(value, value.level)
      })
    }
  }
  function renderSubMenu(data: NavData, level: number) {
    if (!data.children) {
      return (
        <AccordionDetails
          className={`hover:text-green-700 `}
          style={currentNav === data.path ? activeStyles : {}}
          key={data.key}
          onClick={handleClick(data)}
          sx={{ pl: data.level }}
        >
          {data.level === 1 && data.icon}
          <span className="text-size-14px ml-8px">{data.name}</span>
        </AccordionDetails>
      )
    } else {
      return (
        <Accordion defaultExpanded={true} onChange={handleExpanded(data.key)} key={data.key}>
          <AccordionSummary
            expandIcon={data.children && <IconArrow />}
            className={`hover:text-green-700`}
            id={data.key}
            onClick={handleClick(data)}
            sx={{ pl: data.level }}
          >
            <div className="flex items-center">
              <div className={` ${selected.includes(data.key) ? 'text-green-700' : ''}`}>
                {data.level === 1 && data.icon}
              </div>
              <span
                className={`font-rm text-size-14px ${data.level === 1 ? 'ml-4px' : 'ml-10px'} ${
                  selected.includes(data.key) ? 'text-green-700' : ''
                }`}
              >
                {data.name}
              </span>
            </div>
          </AccordionSummary>
          {data.children && renderMenu(data.children, level + 1)}
        </Accordion>
      )
    }
  }

  return (
    <div className="relative bg-white w-246px h-100vh border-r-grey-2 b-r b-r-dashed flex-shrink-0">
      <div className="py-24px ml-16px font-rb text-size-20px">STC</div>
      {renderMenu(menuList, 0)}
    </div>
  )
}
export default Menu

代码分析
1.实现accordion组件自定义样式
// 通过styled设置Accordion\AccordionSummary\AccordionDetails样式
const Accordion = styled((props: AccordionProps) => <Mui.Accordion disableGutters elevation={0} square {...props} />)(
  ({ theme }) => ({
    '&:not(:last-child)': {
      borderBottom: 0
    },
    '&:before': {
      display: 'none'
    }
  })
)
const AccordionSummary = styled((props: AccordionSummaryProps) => <MuiAccordionSummary {...props} />)(({ theme }) => ({
  backgroundColor: 'rgba(255, 255, 255, .05)',
  '& .MuiAccordionSummary-expandIconWrapper': {
    transform: 'rotate(90deg)'
  },
  '& .MuiAccordionSummary-expandIconWrapper.Mui-expanded': {
    transform: 'rotate(180deg)'
  }
}))

const AccordionDetails = styled(MuiAccordionDetails)(({ theme }) => ({
  padding: theme.spacing(2),
  cursor: 'pointer',
  display: 'flex',
  alignItems: 'center'
}))
2. 通过数据创建需要递归生成的accordion
// level 用来进行下级菜单的位置缩进
  function renderMenu(data: AnyObject[], level: number) {
    if (data) {
      return data.map((value: any) => {
        value.level = level + 1
        return renderSubMenu(value, value.level)
      })
    }
  }
  function renderSubMenu(data: NavData, level: number) {
  // 没有children的直接通过AccordionDetails渲染
    if (!data.children) {
      return (
        <AccordionDetails
          className={`hover:text-green-700 `}
          style={currentNav === data.path ? activeStyles : {}}
          key={data.key}
          onClick={handleClick(data)}
          sx={{ pl: data.level }}
        >
          {data.level === 1 && data.icon}
          <span className="text-size-14px ml-8px">{data.name}</span>
        </AccordionDetails>
      )
    } else {
      return (
      // 有children的通过Accordion渲染,可进行展开收缩
        <Accordion defaultExpanded={true} onChange={handleExpanded(data.key)} key={data.key}>
          <AccordionSummary
            expandIcon={data.children && <IconArrow />}
            className={`hover:text-green-700`}
            id={data.key}
            onClick={handleClick(data)}
            sx={{ pl: data.level }}
          >
            <div className="flex items-center">
              <div className={` ${selected.includes(data.key) ? 'text-green-700' : ''}`}>
                {data.level === 1 && data.icon}
              </div>
              <span
                className={`font-rm text-size-14px ${data.level === 1 ? 'ml-4px' : 'ml-10px'} ${
                  selected.includes(data.key) ? 'text-green-700' : ''
                }`}
              >
                {data.name}
              </span>
            </div>
          </AccordionSummary>
          // 递归继续渲染子级
          {data.children && renderMenu(data.children, level + 1)}
        </Accordion>
      )
    }
  }
3. 逻辑部分
// 控制导航展开或关闭
const handleExpanded = (panel: string) => (event: React.SyntheticEvent, newExpanded: boolean) => {
    setExpanded(newExpanded ? panel : false)
  }
// 点击当前导航,修改路由,并通过state存储key, 用于每次刷新页面时高亮已选中的导航所有父级
  const handleClick = (item: AnyObject) => () => {
    if (!item.children) {
      if (item.path !== pathname) {
        navigate(item.path, { state: item.key })
        setCurrentNav(item.path)
      }
    }
  }
// 通过子级key获取所有父元素key,用于高亮展示当前导航的所有父级文案
  function getAllParentArr(data: AnyObject[], key: string) {
    for (const i in data) {
      if (data[i].key === key) {
        return [data[i]]
      }
      if (data[i]?.children) {
        const node = getAllParentArr(data[i].children, key) as AnyObject[]
        if (node) {
          return node.concat(data[i])
        }
      }
    }
  }
// 路由改变时重置当前导航相关的所有父级
  useEffect(() => {
    setCurrentNav(pathname)
    const result = getAllParentArr(menuList, state) as AnyObject[]
    if (result && result.length) {
      setSelected(result.map((item: AnyObject) => item.key))
    }
  }, [pathname])
4. menuList部分结构
import Icon from '~icons/icon.svg'

export const menuList = [
  {
    key: 'k-1',
    name: 'Key1',
    icon: <Icon />,
    children: [
      {
        key: 'k-11',
        name: 'Key11',
        children: [
          {
            key: 'k-111',
            name: 'Key111',
            children: [
              {
                key: 'k-1111',
                name: 'Key111',
                path: '/key111'
              }
            ]
          }
        ]
      }
    ]
  },
  {
    key: 'demo',
    name: 'Demo',
    icon: <Icon />,
    path: '/demo'
  }
]

你可能感兴趣的:(前端开发,react.js,ui,javascript)