这是我参与「第五届青训营 」伴学笔记创作活动的第10天
需求分析
垂直菜单
- 点击可激活(高亮)
- 子菜单点击后展开
- 菜单项可禁用(点击后无响应)
水平菜单
- 点击可激活
- 子菜单悬停后展开
- 菜单项可禁用
开发难点
如何改变组件状态
菜单项(subMenu
、menuItem
)都有一个index
,值为其在列表中的索引值。
menu
组件有一个active
状态,值为菜单项的index
,用于控制哪个菜单项为激活状态。初始active
为0,即默认激活第一个菜单项。
使用上下文(context
)传递父组件的active
状态,以及设置状态的函数(setActive
)。
当active === index
时,表示激活菜单项。
如何设置菜单项的index值
- 使用
Children.map
与React.cloneElement(childElement, {index})
,将map的index
传入菜单项,当做菜单项的index
如何添加子菜单
子菜单(subMenu
)最外层是一个列表项li
,其中使用一个div
标签作为其名称,具体的菜单项用ul
+li
标签的形式表示。
子菜单如何展开
对于horizontal
,hover
时展开;对于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>
)