【NavMenu 导航菜单】:难搞的展开/收缩动画
前言
本篇文章讲述如何来实现一个导航菜单组件,导航菜单特别常见于后台管理系统这种较多页面层级的网页,往往通过点击某一项来控制页面跳转。它长这样
需求分析
导航菜单的功能不算复杂,总计出来就以下几点:
- 可以通过点击菜单的某一项实现特定的操作,例如路由跳转、页面跳转、事件监听等
- 可以嵌套子级菜单
- 具有一定的交互过渡效果(这个很重要,不然就太丑了)
- 外部可以获取到当前选中菜单项以及层级数据,用于更高级的需求场景
好了,具体需求我们先列出这么多,下面,就来看看如何实现一个导航菜单。
构思
导航菜单的基本结构
从交互上来看,导航菜单在展开后会露出包裹的子菜单,关闭时又会隐藏这些子菜单,所以应该是最外层有一个div
容器,然后有一个ul
列表包裹着所有子菜单项li
,要实现显示或隐藏,通过控制这个ul
的容器高度即可。
如何嵌套子菜单
有时候菜单的层级往往不止两层,而是有很多层,要如何实现嵌套呢?仔细想想,单独一层菜单的结构是 “容器 + 列表”;嵌套,则是把某个菜单项变成一个具有完整功能的菜单即可,所以,首先想到的就是** Tree 树结构**,可以通过递归来渲染出一个包含嵌套层级的导航菜单。
不过,这部分的实现没有那么简单,等我把 NavMenu 写出来之后,我发现它是花了我最多时间的一个部分。
如何实现伸缩动画效果
如何实现收缩/展开动画?很容易想到给ul
菜单项容器简单的设置transition
过渡效果,然后动态地改变ul
的height
值,它就会展示出过渡动画了
不过,动手实现的时候,我才发现这块好像也没那么简单,后面会详细解释。
单个展开模式
意思就是每次点击展开当前这个菜单时,其余的菜单自动收起,在 Element UI 中称为“手风琴”模式,不过我不会玩儿手风琴,所以完全没理解到这个名字内涵,所以我还是叫它“单个开关”模式吧。
要实现这个功能,就需要在点击展开某个菜单时,首先获取到上一个展开的菜单,关闭它,之后才展开当前点击的菜单。
开发
好了,前面分析了一大堆,下面应该上代码了。
首先,抽象一下组件层级以及它们的内部状态,最外部的容器组件是NavMenu
,子菜单项是NavMenuItem
,然后当需要嵌套子菜单时,还需要一个组件SubNavMenu
,导航菜单的使用方式如下:
<NavMenu>
{/* 单个菜单项 */}
<NavMenuItem label="项目1"></NavMenuItem>
{/* 嵌套菜单项 */}
<SubNavMenu label="项目2">
<NavMenuItem label="项目2-1"></NavMenuItem>
{/* 继续嵌套 */}
<SubNavMenu label="项目2-2">
<NavMenuItem label="项目2-2-1"></NavMenuItem>
</SubNavMenu>
</SubNavMenu>
</NavMenu>
画了个图,方便理解
NavMenuItem 菜单项
菜单项需要展示一个标题以及响应点击事件,还需要注意的是:有时候菜单项可以是一个标题(静态文本),或者是一个可以点击的链接,还可以是 React Router 的Link
组件,用来进行路由跳转;所以菜单项需要根据不同的输入,渲染出不同的元素。
下面是props
定义:
interface propsNavMenuItem{
to?: string; // 超链接
icon?: any; // 标题前面的图标
disabled?: boolean; // 是否禁用
label?: string; // 菜单项标题
children?: any; // 自定义的内容,可以是 <Link>
className?: string
}
组件的基本结构如下,返回一个li
元素:
function NavMenuItem(props: propsNavMenuItem) {
const {
to,
icon,
disabled = false,
children,
className = '',
label,
} = props;
// 根据接收到的 props 渲染不同的元素
const renderNavItem = () => {
if (to) {
// 超链接
return (
<a
className={'wdu-navMenuItem__label'}
href={to}
target='_blank'
referrerPolicy='no-referrer'>
{label}
</a>
);
} else if (!to && children) {
// <Link/>
return children;
} else if (!to && !children && label) {
// 纯文本标题
return (
<span className={'wdu-navMenuItem__label'}>
{label.toString()}
</span>
);
}
};
return (
<li className={navItemClassList}>
{icon && <span className={'wdu-navMenuItem__icon'}>{icon}</span>}
{renderNavItem()}
</li>
);
}
NavMenu 菜单
作为顶层容器组件,它接受的props
如下:
interface propsNavMenu {
single?: boolean; // 是否每次展开时只展开一个子菜单(手风琴模式)
children?: Array<JSX.Element>; // 子元素,只接受 NavMenuItem 和 SubNavMenu
className?: string; // 自定义类名,方便用户自定义样式
}
接下来是组件的基本结构,它是一个ul
元素,包含上面渲染的li
元素
function NavMenu(props: propsNavMenu) {
const { children, className = '', single = false } = props;
return (
<ul className={`wdu-navMenu ${className}`}>
{/* NavMenu 子元素 */}
</ul>
);
}
SubNavMenu 子菜单
这个组件的作用是复用NavMenu
的功能,包裹嵌套的子菜单,但是又跟NavMenu
有一些区别,下面是它的结构定义:
// 跟 NavMenuItem 的 props 接口很相似
interface propsSubNavMenu {
label: string; // 嵌套子菜单的标题
icon?: any; // 标题的 icon
disabled?: boolean;
onClick?: (event: MouseEvent, navMenuItemData: any) => any;
expand?: boolean; // 嵌套菜单是否展开
children?: any; // 嵌套菜单的子元素,NavMenuItem 或 SubNavMenu
}
function SubNavMenu(props: propsSubNavMenu) {
const {
children,
expand = false,
label,
} = props;
// 是否展开
const [menuExpand, setExpand] = useState(() => expand);
// 容器的高度
const [expandHeight, setExpandHeight] = useState<{
height: string | number;
}>({ height: 0 });
// 开关子菜单容器,主要方法是控制容器高度
useEffect(() => {
let height: string | number;
if (menuExpand) {
height = 'auto'
} else {
height = 0;
}
setExpandHeight((prev) => ({ ...prev, height }));
}, [menuExpand]);
return (
<ul className='wdu-subNavMenu'>
{/* 作为标题 */}
<NavMenuItem
className='wdu-subNavMenu__title'
expand={menuExpand}
onClick={handleClick}
label={label}
subMenuItem></NavMenuItem>
<div
className='wdu-subNavMenu__items'
style={expandHeight}
>
{childNodes}
</div>
</ul>
);
}
上面是经过了精简后的SubNavMenu
结构,后面会有更详细的介绍。
实现子菜单嵌套
这一部分将会是本文最不好理解的地方,文字比较多,我尽量清晰且有条理的去阐述这一部分的实现思路。
递归组件
首先来看一下组件渲染到页面以后的 DOM 结构
仔细观察,不难发现一个特点:每个导航菜单的构成都是ul
之内嵌套着li
以及ul
,li
就是菜单项的标题,嵌套的ul
就是一个完整的菜单结构,内部又是li
和嵌套的ul
,直到最底层。所以,这里使用了递归组件。
还有一个需要注意的地方:需要将SubNavMenu
容器设置为overflow: hidden
,才能让菜单闭合后,嵌套的子菜单元素不可见。
下面有一段 NavMenu 的模板代码:
<NavMenu>
<NavMenuItem label='菜单 1' />
<SubNavMenu label='菜单 2'>
<NavMenuItem label='菜单 2-1' />
<SubNavMenu label='菜单 2-2'>
<NavMenuItem label='菜单 2-2-1' />
</SubNavMenu>
</SubNavMenu>
</NavMenu>
这是一个三层嵌套的导航菜单,其中SubNavMenu
组件中仍然可以继续使用SubNavMenu
,这就是将组件递归地渲染。
下面是 SubNavMenu
组件的 JSX 模板部分代码,通过 React.Children
获取到子组件(NavMenuItem 或者 SubNavMenu),然后渲染到 SubNavMenu
组件中:
<ul className='wdu-subNavMenu'>
<NavMenuItem
className='wdu-subNavMenu__title'
expand={menuExpand}
onClick={handleClick}
label={label}
></NavMenuItem>
<div
className='wdu-subNavMenu__items'
style={expandHeight}
ref={refSubNavContainer}>
{childNodes}
</div>
</ul>
SubNavMenu
的结构是一个ul
元素中包含了一个作为子菜单项标题的NavMenuItem
组件(li
元素),然后下面是用于包裹子菜单项的div
,其子元素节点就是之前代码中演示过的NavMenuItem
或者SubNavMenu
。
处理缩进
上图中可以明显看到,每个嵌套的子菜单左侧都会有一定距离的缩进,就像写作文时每个自然段的缩进。那么,到底如何实现呢?先来看看第一种方法:给
SubNavMenu
容器设置margin-left
:
<ul className='wdu-subNavMenu' style={{marginLeft: '40px'}}>
<NavMenuItem
className='wdu-subNavMenu__title'
expand={menuExpand}
onClick={handleClick}
label={label}
></NavMenuItem>
<div
className='wdu-subNavMenu__items'
style={expandHeight}
ref={refSubNavContainer}>
{childNodes}
</div>
</ul>
渲染结果如下
可以看到,每个菜单项其实有鼠标悬浮效果的,简单的设置margin-left
虽然可以实现缩进,但是也会使得NavMenuItem
的宽度变小,这会让悬浮效果只能体现出一部分,而不是整个 100% 的父容器宽度,视觉效果上也就不满足要求。
鼠标悬浮效果是通过设置:hover
伪类改变NavMenuItem
的背景色来实现的,但是background-color
的作用范围是不包括元素的margin
的,而是padding + content
。所以不能使用margin
,而是要通过padding
来实现缩进,这个知识点其实大部人都知道,但是我因为 css 功底没那么好,所以算是踩了个坑,这里单独拿出来说一说。
为了实现缩进,需要当前这个SubNavMenu
组件继承上一级的缩进距离,然后以此类推下去即可
// 缩进距离,常量
const NEST_ITEM_INDENT = 20;
// 通过自定义 SubNavMenu 子元素的 props,把缩进距离 indent 一级一级传下去
const childNodes = bindImplicitProps(React.Children.toArray(children), {
indent: indent + NEST_ITEM_INDENT,
});
bindImplicitProps
函数一个工具方法,用于将给定的props
遍历绑定到每个子节点上
在 SubNavMenu
中,通过 indent
传递缩进:
<ul className='wdu-subNavMenu'>
<NavMenuItem
indent={indent}
></NavMenuItem>
<div
className='wdu-subNavMenu__items'
style={expandHeight}
>
{childNodes}
</div>
</ul>
在 NavMenuItem
中,接受 indent
然后设置到标题元素的style
上
<li
className={navItemClassList}
style={{ paddingLeft: `${indent}px` }}
>
{icon && <span className={'wdu-navMenuItem__icon'}>{icon}</span>}
{renderNavItem()}
{subMenuItem}
</li>
可以看到,此时的嵌套子菜单组件完全被 NavMenuItem
组件包裹了起来,只要给容器li
元素应用上一级菜单传递下来的缩进indent
就能实现整个SubNavMenu
容器的缩进,也就是style={{ paddingLeft:
${indent}px }}
的作用所在。
稍微总结一下:到目前为止,通过不断SubNavMenu
中嵌套NavMenuItem
以及SubNavMenu
,实现了子菜单的嵌套;接着,又通过给每个SubNavMenu
的容器设置padding-left
实现了不影响鼠标悬浮效果的缩进,效果如下:
实现单层导航菜单的伸缩动画效果
假设有一段模版代码:
<NavMenu>
<SubNavMenu label="菜单 1">
<NavMenuItem label='菜单 1' />
<NavMenuItem label='菜单 2' />
<NavMenuItem label='菜单 3' />
</SubNavMenu>>
</NavMenu>
渲染结果如下:
上图中,菜单的展开和闭合都有比较平滑的过渡动画,那么这是如何实现的呢?
首先,最容易想到的就是改变ul
元素的height
,菜单展开时,设置样式height: auto
(设置为 auto 是因为此时并不能知道容器展开后的确切高度),收起时设置样式height: 0
// SubNavMenu 组件
function SubNavMenu(props: propsSubNavMenu) {
// ......
// 容器的高度
const [expandHeight, setExpandHeight] = useState<{
height: string | number;
}>({ height: 0 });
// 开关子菜单容器,主要方法是控制容器高度
useEffect(() => {
let height: string | number;
if (menuExpand) {
height = 'auto'
} else {
height = 0;
}
setExpandHeight((prev) => ({ ...prev, height }));
}, [menuExpand]);
return (
<ul className='wdu-subNavMenu'>
<NavMenuItem
className='wdu-subNavMenu__title'
expand={menuExpand}
onClick={handleClick}
label={label}
subMenuItem></NavMenuItem>
<div
className='wdu-subNavMenu__items'
style={expandHeight}
>
{childNodes}
</div>
</ul>
);
}
渲染结果如下:仔细观察就能发现,展开和闭合时是没有过渡动画效果的,原因是
transition
动画只支持在明确的数值之间渲染过渡动画,很明显auto
不是一个数值,无法参与计算。所以,就需要手动获取到SubNavMenu
容器的高度,然后将其作为height
的值,这样过渡动画就能生效了。
function SubNavMenu(props: propsSubNavMenu) {
// .......
// 每个 NavMenuItem 的固定高度
const ITEM_HEIGHT = 50;
// 初始情况,SubNavMenu 还未展开,所以无法获取到容器高度,但是可以通过子节点个数来计算展开后的容器高度
const initialHeight = childNodes.length * ITEM_HEIGHT + 'px';
useEffect(() => {
let height: string | number;
if (menuExpand) {
height = initialHeight;
} else {
height = 0;
}
setExpandHeight((prev) => ({ ...prev, height }));
}, [menuExpand]);
return (
<ul className='wdu-subNavMenu'>
<NavMenuItem
className='wdu-subNavMenu__title'
expand={menuExpand}
indent={indent}
onClick={handleClick}
label={label}
subMenuItem></NavMenuItem>
<div
className='wdu-subNavMenu__items'
style={expandHeight}
ref={refSubNavContainer}
>
{childNodes}
</div>
</ul>
);
}
渲染结果如下:
实现嵌套导航菜单的伸缩动画效果
再次回忆一下嵌套的菜单结构每次菜单的展开与闭合都是控制绿色选框
div
的高度,在之前的代码中已经实现了单个SubNavMenu
的展开和闭合动画效果。
下面来看另一段模版代码,它多了一层嵌套:
<NavMenu>
<SubNavMenu label="菜单 1">
<NavMenuItem label='项目 1' />
<NavMenuItem label='项目 2' />
<NavMenuItem label='项目 3' />
<SubNavMenu label="菜单 4">
<NavMenuItem label='项目 4-1' />
<NavMenuItem label='项目 4-2' />
<NavMenuItem label='项目 4-3' />
</SubNavMenu>
</SubNavMenu>
</NavMenu>
渲染结果如下:可以看到,第一层的可以正常展开与闭合,但是嵌套的第二层菜单的展开却失效了,这是为什么?因为之前的代码中只是计算了当前这一层级的容器的展开高度,并没有考虑到容器之中还可以嵌套另一个容器,所以高度值不够,导致第二层菜单在视觉上好像高度一直为 0,但实际上它的
height
值是生效了的,但因为 SubNavMenu
容器的overflow: hidden
所以隐藏的部分就不可见了。
解决这个问题的核心逻辑如下:在当前菜单展开后,先缓存容器的高度,然后再将容器的高度从数值设置为auto
function SubNavMenu(props: propsSubNavMenu) {
// .......
// 缓存上次菜单展开后的高度
const [lastHeight, setLastHeight] = useState<number>();
// 在菜单展开后,给容器设置样式 height: auto
const keepChildItemExpandable = (e: TransitionEvent) => {
if (e.propertyName !== 'height' && e.propertyName !== 'width') return;
let height: string | number;
if (menuExpand) {
height = 'auto';
} else {
height = 0;
}
setExpandHeight((prev) => ({ ...prev, height }));
};
// 计算 tansition 的精确数值
const setComputedHeightToContainer = () => {
if (refSubNavContainer.current) {
const { clientHeight } = refSubNavContainer.current;
refSubNavContainer.current.style.height = clientHeight + 'px';
return clientHeight;
}
};
const handleClick = () => {
// 菜单展开后,缓存当前容器高度
if (menuExpand && refSubNavContainer.current) {
const clientHeight = setComputedHeightToContainer();
setLastHeight(clientHeight);
}
// 延迟 50 ms 是为了等待 DOM 重排和重绘结束
setTimeout(() => setExpand(!menuExpand), 50);
};
return (
<ul className='wdu-subNavMenu'>
<NavMenuItem
className='wdu-subNavMenu__title'
expand={menuExpand}
indent={indent}
onClick={handleClick}
label={label}
></NavMenuItem>
<div
className='wdu-subNavMenu__items'
style={expandHeight}
ref={refSubNavContainer}
onTransitionEnd={keepChildItemExpandable}>
{childNodes}
</div>
</ul>
);
}
当父容器的高度为auto
时,被嵌套的子菜单展开时就可以自然而然的撑开父容器的高度,从而将自己显示出来
实现单个开关模式
“单个开关”模式就是当前层级中,同一时间只能有一个菜单被展开,当前点击某个菜单展开后,其余的菜单要能自动闭合起来。
本文的实现思路很简单:在当前菜单展开后,关闭上一个展开的菜单。简要的步骤如下:
- 每一个
SubNavMenu
内部都生成一个唯一的组件 ID - 父级菜单中维护一个变量,表示当前展开的菜单的 ID
- 当前
SubNavMenu
展开之前,先查询父级菜单中保存的 ID ,然后决定闭合还是展开
首先,SubNavMenu
需要为自己生成一个 ID
// react 18 新增的 hooks
const menuId = useId();
useEffect(() => {
if (menuExpand) {
// 提交自己的 id 到父级
submitExpandId(menuId);
}
}, [menuExpand]);
单个开关模式的生效范围只限于同一层级,所以需要在每个SubNavMenu
内部都创建一个变量来记录当前展开的子菜单项。不过,再仔细想一想,整个导航菜单组件每次只能展开一个子菜单,不论它所处的层级如何,所以其实没有必要在每个SubNavMenu
内部都创建 ID 变量,而是把这个变量提升到最顶层的NavMenu
中,这样无论嵌套了多少层子菜单,也不会有冗余的代码逻辑
const NavContext = React.createContext<NavMenuContext>({});
const NavProvider = NavContext.Provider;
function NavMenu(props: propsNavMenu) {
const { children, className = '', single = false } = props;
// 上次展开的子菜单 id
const [lastExpandItemId, setLastExpandItemId] = useState<string>();
const childNodes = bindImplicitProps(React.Children.toArray(children), {
lastExpandItem: lastExpandItemId,
submitExpandId: setLastExpandItemId,
});
const contextValue = {
single,
selectedItem,
};
return (
<ul className={`wdu-navMenu ${className}`}>
<NavProvider value={contextValue}>{childNodes}</NavProvider>
</ul>
);
}
在 react 中,说到跨层级组件通信,最先想到的一定是 context
;这里用React.createContext
来保存当前展开的子菜单 ID ,这样所有的SubNavMenu
都可以访问到lastExpandItemId
,而且还可以很方便的提交自己的 ID
function SubNavMenu(props: propsSubNavMenu) {
const {
children,
expand = false,
label,
indent = NEST_ITEM_INDENT,
lastExpandItem,
submitExpandId,
} = props;
useEffect(() => {
if (menuExpand) {
// 展开时,提交自己的 id 到顶层 context 中
submitExpandId(menuId);
}
}, [menuExpand]);
useEffect(() => {
if (!single) return;
// 根据 lastExpandItem 判断自身是否需要闭合
if (lastExpandItem !== undefined && lastExpandItem !== menuId) {
setComputedHeightToContainer();
setExpand(false);
}
}, [lastExpandItem]);
// 将相同的 props 传递给子组件
const [lastExpandItemIndex, setLastExpandItemIndex] = useState<
number | undefined
>();
const childNodes = bindImplicitProps(React.Children.toArray(children), {
indent: indent + NEST_ITEM_INDENT,
lastExpandItem: lastExpandItemIndex,
submitExpandId: setLastExpandItemIndex,
});
return (
<ul className='wdu-subNavMenu'>
// 菜单标题
<NavMenuItem
className='wdu-subNavMenu__title'
expand={menuExpand}
indent={indent}
onClick={handleClick}
subMenuItem></NavMenuItem>
<div
className='wdu-subNavMenu__items'
style={expandHeight}
ref={refSubNavContainer}
onTransitionEnd={keepChildItemExpandable}>
{childNodes}
</div>
</ul>
);
}
菜单项目的选中样式
接下来别忘了还有NavMenuItem
组件,它既可以作为菜单项也可以作为菜单标题,每次点击菜单展开的时候,实际上就是点击了NavMenuItem
,为了显示出当前选中的是哪个菜单项,需要在点击后给当前菜单项加上一个特定的样式上图中,选中当前菜单项后背景会变蓝,同时之前选中的菜单项背景会被移除。
这个需求的实现原理还是跟之前的“单个开关”模式一样;首先,还是在顶层的NavMenu
组件中注册一个context value
,然后分发到每个NavMenuItem
中:
// 当前点击的菜单项的 ID
const [selectedItem, setSelectedItem] = useState<string>();
const contextValue = {
submitSelectedItem: setSelectedItem,
};
在NavMenuItem
中,单击事件触发后,就提交当前 ID ,然后通过对比上一个被点击的菜单项的 ID 来动态设置当前菜单项的背景样式:
function NavMenuItem(props: propsNavMenuItem) {
const {
// .....
expand = false,
subMenuItem = false, // 当前组件是否是 SubNavMenu 标题
} = props;
const { selectedItem, submitSelectedItem } = useContext(NavContext);
const menuId = useId();
const handleClick = (event: MouseEvent) => {
if (disabled) return;
// 忽略 SubNavMenu 的标题 NavMenuItem
if (!subMenuItem) submitSelectedItem(menuId);
};
return (
<li
className={navItemClassList}
style={{ paddingLeft: `${indent}px` }}
onClick={handleClick}>
{icon && <span className={'wdu-navMenuItem__icon'}>{icon}</span>}
{renderNavItem()}
{subMenuItem && <Arrow style={expand ? 'bottom' : 'right'} />}
</li>
);
}
总结
本文介绍了如何用 React 实现一个导航菜单组件,通过递归组件的方式实现了菜单的嵌套;通过监听TransitionEnd
事件来帮助实现了未知高度容器的过渡动画;通过React.context
分发标识 ID 的办法实现了“单个开关”模式以及菜单项选中样式,最终得到了一个支持无限嵌套的、拥有过渡动画的导航菜单组件。
转载自:https://juejin.cn/post/7257177072678944825