组件库开发日志-Menu | 青训营笔记

53 阅读3分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第10天

需求分析

垂直菜单

  • 点击可激活(高亮)
  • 子菜单点击后展开
  • 菜单项可禁用(点击后无响应)

水平菜单

  • 点击可激活
  • 子菜单悬停后展开
  • 菜单项可禁用

开发难点

如何改变组件状态

菜单项(subMenumenuItem)都有一个index,值为其在列表中的索引值。

menu组件有一个active状态,值为菜单项的index,用于控制哪个菜单项为激活状态。初始active为0,即默认激活第一个菜单项。

使用上下文(context)传递父组件的active状态,以及设置状态的函数(setActive)。

active === index时,表示激活菜单项。

如何设置菜单项的index值

  • 使用Children.mapReact.cloneElement(childElement, {index}),将map的index传入菜单项,当做菜单项的index

如何添加子菜单

子菜单(subMenu)最外层是一个列表项li,其中使用一个div标签作为其名称,具体的菜单项用ul+li标签的形式表示。

子菜单如何展开

对于horizontalhover时展开;对于vertical,点击时展开。

因此要传递mode属性,使用css样式控制。

特别注意hover的计时器要公有,不能放在闭包里。

子菜单的菜单项如何激活

index变为string类型,按照xx-xx-xx的形式编号,例如:第2项为下拉菜单,则其下属第3个菜单项编号为1-3

开发流程

定义接口

Menu

export interface MenuProps {
  // 原生标签参数
  className?: string;
  children: React.ReactNode;
  style?: React.CSSProperties;
  // 自定义参数
  defaultIndex?: string; // 默认激活项
  mode?: 'vertical' | 'horizontal'; // 横向或纵向菜单
}

MenuItem

export interface BaseMenuItemProps {
  index?:string; //本项编号
  disabled?: boolean; // 是否禁用
}
// 另一种加入原生标签参数的方法
export type MenuItemProps = Partial<BaseMenuItemProps & 
  React.LiHTMLAttributes<HTMLElement>>

SubMenu

interface BaseSubMenuProps {
  index?: string;
  title?: string; // 子菜单名称
  disabled?: boolean; 
}

export type SubMenuProps = Partial<BaseSubMenuProps & 
  React.LiHTMLAttributes<HTMLElement>>

定义上下文

Menu

interface IMenuContext {
  index: string;
  mode: 'vertical' | 'horizontal';
  select?: (index: string)=>void; 
}
// 在组件外创建上下文,必须带默认值
export const MenuContext = createContext<IMenuContext>
  ({index:'0', mode:'horizontal'})
// 要传递的内容
const passedContext: IMenuContext = {
  index: active,
  mode: mode,
  select(index) {
    setActive(index) // 传递设置激活状态的函数
  }
}

创建组件

概览

// react组件
<Menu mode='vertical'>
    <MenuItem>first</MenuItem>
    <SubMenu title='submenu'>
        <MenuItem>one</MenuItem>
        <MenuItem>two</MenuItem>
        <MenuItem>three</MenuItem>
    </SubMenu>
    <MenuItem disabled>second</MenuItem>
    <MenuItem>third</MenuItem>
</Menu>
// 编译后的基本结构(伪代码)
<ul class="vertical">
  <li>first</li>
  <li>
      <div>submenu</div>
      <li>one</li>
      <li>two</li>
      <li>three</li>
  </li>
  <li class="disabled">second</li>
  <li>third</li>
</ul>

MenuItem

const context = useContext(MenuContext)
const handleClick = ()=>{ // 触发点击时,将父组件的idx改为当前的
  if (context.select && !disabled && typeof index === 'string') {
    context.select(index)
  }
}
const classnames = Classnames('menu-item', className, {
  'is-disabled':disabled, // 用样式控制是否禁用
  'is-active': context.index === index // 父组件选的idx和当前idx相等时设置高亮
})
return (
  <li style={style} className={classnames} onClick={handleClick}>
    {children}
  </li>
)

Menu

const renderChildren = Children.map(children , (child, index)=>{
  // 必须是FC实例
  // 使用断言限制泛型类型
  const childElement = child as 
    React.FunctionComponentElement<MenuItemProps> 
  // 一个个拿到child,及他们的idx
  // 不能通过childElement.props.index修改index,只能用clone的节点
  if (childElement.type.name === 'MenuItem'  || 
      childElement.type.name === 'SubMenu') 
    return React.cloneElement(childElement, {index:index.toString()})
  // 不允许添加MenuItem之外的标签
  else console.warn('Menu中有非MenuItem的标签')
})

return (
  <ul style={style} className={classnames}>
    <MenuContext.Provider value={passedContext}>
      {renderChildren}
    </MenuContext.Provider>
  </ul>
)

SubMenu

使用.menu-opened样式控制子菜单展开,因此定义一个状态menuOpen判断是否添加此样式

mode='vertical'时处理click事件,点击展开子菜单

const handleClick = () => {
  if (context.select && !disabled && typeof index === 'string') {
    context.select(index)
  }
  // 为水平菜单时则添加click事件
  if (context.mode === 'vertical') {
    return {
      onClick: setMenuOpen(!menuOpen) 
    }
  }
}

mode='horizontal'时处理hover事件,悬停展开子菜单

let handleHover = {}
let _timer:any = null // 计时器要公有,不能放在闭包里
// 使用类似防抖的方式,当鼠标移开时还能够暂时保持展开状态
let hover = (flag: boolean, delay = 300)=>{
  return ()=>{
    if (_timer) clearTimeout(_timer)
    _timer = setTimeout(()=>{
      setMenuOpen(flag)
    },delay)
  }
}
// 为垂直菜单时则添加hover事件
if (context.mode === 'horizontal') {
  handleHover = {
    onMouseEnter: hover(true, 100),
    onMouseLeave: hover(false)
  }
}

渲染组件

const renderChildren = () => {
  const frag = Children.map(children, (child, i) => {
    const childElement = child as 
      React.FunctionComponentElement<MenuItemProps>
    if (childElement.type.name === 'MenuItem') {
      return React.cloneElement(childElement, {index:`${index}-${i}`})
    } else {
      console.warn('SubMenu中有非MenuItem的标签')
    }
  })
  const fragClasses = Classnames("submenu", { 'menu-opened': menuOpen })
  return (
    <ul className={fragClasses}>
      {frag}
    </ul>
  )
}
return (
  <li className={classnames} {...handleHover} >
    <div className="submenu-title" onClick={handleClick}>
      {title}
    </div>
    {renderChildren()}
  </li>
)

组件演示

GIF 2023-02-11 22-17-48.gif