一、需求分析
主要分为横向和纵向两种菜单,每一个单独的菜单项还可以设置展开,比如React官方文档的导航菜单:
代码样例分析
最外层由一个Menu包裹,里面不同的子项为多个Menu.Item,Menu和Menu.Item分别有不同的属性
基本属性分析:
interface MenuProps: {
defaultIndex: number; // 指示高亮
mode: string; // 横向还是竖向
onSelect: (selectedIndex: number) => void; // 点击时的回掉
className: string; // 用户自定义类名
}
interface MenuItemProps: {
index: number; // 指示为某一项
disabled: boolean; // 是否为 disabled
className: string; // 用户自定义类名
}
在 Menu 中通过 context 设置 active 的 MenuItem,和执行 onSelect() 回调函数
const [currentActive, setActive] = useState(defaultIndex); // 设置 active 状态的 Menu.Item
传递 context:
// 回调函数
const handleClick = (index: number) => {
setActive(index);
if(onSelect) {
onSelect(index);
}
}
const passedContext: IMenuContext = { // 传递给 Menu.Item 的 context
index: currentActive ? currentActive : 0, //useState 设置的值可能为 undefined,但这里的 index 只能为 number类型,这里需要设置一下
onSelect: handleClick,
}
<ul className={classes} style={style} data-testid={"test-menu"}>
<MenuContext.Provider value={passedContext}>
{children}
</MenuContext.Provider>
</ul>
在 MenuItem 中接收 context,绑定事件,执行回调函数
const context = useContext(MenuContext);
const handleClick = () => {
if(context.onSelect && !disabled) {
context.onSelect(index)
}
}
return (
<li className={classes} style={style} onClick={handleClick}>
{children}
</li>
)
注意: children 是一个不透明的数据结构,从本质上来讲, props.children 可以是任意一种类型,如果他是函数,则在函数上调用 map 方法就会报错;
在 React 中可以使用 React.Children.map(children, function[(thisArg)]) 和 React.Children.forEach(children, function[(thisArg)]) 来解决上述问题,如果遇上不和规则的类型,则会被跳过;
给 MenuItem 添加一个 dispalyName,这是 React 内置的一个静态属性,可以帮助我们判断类型,从而让 Menu 中只包含 MenuItem,而不能包含其他元素或组件
在MenuItem中声明 displayName:
MenuItem.displayName = 'MenuItem';
在 Menu 中声明一个函数来渲染子组件(MenuItem):
const renderChildren = () => { // 添加一个函数来循环渲染子组件
return React.Children.map(children, (child, index) => {
// 现在的 child 是一个 React.Node 类型, 在 child.type 上并没有 displayName 属性
// 通过类型断言,把它转换成一个 Functional Component 实来获得 dispalyname
const childElement = child as React.FunctionComponentElement<MenuItemProps>;
const { displayName } = childElement.type;
if(displayName === 'MenuItem') {
return child;
} else {
console.error('Warning: Menu has a child which is not a MenuItem component');
}
})
}
使用:
<ul className={classes} style={style} data-testid={"test-menu"}>
<MenuContext.Provider value={passedContext}>
{renderChildren()}
</MenuContext.Provider>
</ul>
现在我们不想每次使用的时候手动给多个 MenuItem 添加 index,可以通过
React.cloneElement(
element,
[props],
[...children]
)
克隆替换原来的元素并添加上新的属性
const renderChildren = () => { // 添加一个函数来循环渲染子组件
return React.Children.map(children, (child, index) => {
// 现在的 child 是一个 React.Node 类型, 在 child.type 上并没有 displayName 属性
// 通过类型断言,把它转换成一个 Functional Component 实来获得 dispalyname
const childElement = child as React.FunctionComponentElement<MenuItemProps>;
const { displayName } = childElement.type;
if(displayName === 'MenuItem') {
return React.cloneElement(childElement, { //通过克隆来添加 index 属性
index
});
} else {
console.error('Warning: Menu has a child which is not a MenuItem component');
}
})
}
Menu组件代码:
import React, { createContext, useState } from 'react';
import classNames from 'classnames';
import { MenuItemProps } from './MenuItem';
export type MenuMode = 'horizontal' | 'vertical';
export type selectCallBack = (selectedIndex: number) => void;
export interface MenuProps {
defaultIndex?: number;
mode?: MenuMode;
className?: string;
style?: React.CSSProperties;
children?: React.ReactNode;
onSelect?: selectCallBack;
}
export interface IMenuContext {
index: number;
onSelect?: selectCallBack;
}
export const MenuContext = createContext<IMenuContext>({index: 0}); // 创建 context,初始值为 0
const Menu = (props: MenuProps) => {
const {
defaultIndex,
mode,
className,
style,
children,
onSelect, // 用户自定义的回掉事件
} = props;
const [currentActive, setActive] = useState(defaultIndex); // 设置 active 状态的 Menu.Item
const handleClick = (index: number) => {
setActive(index);
if(onSelect) {
onSelect(index);
}
}
const passedContext: IMenuContext = { // 传递给 Menu.Item 的 context
index: currentActive ? currentActive : 0, //useState 设置的值可能为 undefined,但这里的 index 只能为 number类型,这里需要设置一下
onSelect: handleClick,
}
const classes = classNames('fun-menu', className, {
'fun-menu-vertical': mode === 'vertical'
})
const renderChildren = () => { // 添加一个函数来循环渲染子组件
return React.Children.map(children, (child, index) => {
// 现在的 child 是一个 React.Node 类型, 在 child.type 上并没有 displayName 属性
// 通过类型断言,把它转换成一个 Functional Component 实来获得 dispalyname
const childElement = child as React.FunctionComponentElement<MenuItemProps>;
const { displayName } = childElement.type;
if(displayName === 'MenuItem') {
return React.cloneElement(childElement, { //通过克隆来添加 index 属性
index
});
} else {
console.error('Warning: Menu has a child which is not a MenuItem component');
}
})
}
return (
<ul className={classes} style={style} data-testid={"test-menu"}>
<MenuContext.Provider value={passedContext}>
{renderChildren()}
</MenuContext.Provider>
</ul>
)
}
Menu.defaultProps = {
defaultIndex: 0,
mode: 'horizontal'
}
export default Menu;
MenuItem组件代码
import React, { useContext } from 'react'
import classNames from 'classnames'
import { MenuContext } from './Menu';
export interface MenuItemProps {
index?: number;
disabled?: boolean;
className?: string;
style?: React.CSSProperties;
}
const MenuItem: React.FC<MenuItemProps> = (props) => {
const { index, disabled, className, style, children } = props
const context = useContext(MenuContext)
const classes = classNames('fun-menu-item', className, {
'is-disabled': disabled,
'is-active': context.index === index,
})
const handleClick = () => {
if (context.onSelect && !disabled && (typeof index === 'number')) {
context.onSelect(index)
}
}
return (
<li className={classes} style={style} onClick={handleClick}>
{children}
</li>
)
}
MenuItem.displayName = 'MenuItem'
export default MenuItem
单元测试代码:
import React from 'react'
import { render, RenderResult, fireEvent, cleanup } from '@testing-library/react'
import Menu, { MenuProps } from './Menu'
import MenuItem from './MenuItem'
const testProps: MenuProps = {
defaultIndex: 0,
onSelect: jest.fn(),
className: 'test'
}
const testVerProps: MenuProps = {
defaultIndex: 0,
mode: 'vertical',
}
const generateMenu = (props: MenuProps) => {
return (
<Menu {...props}>
<MenuItem>
active
</MenuItem>
<MenuItem disabled>
disabled
</MenuItem>
<MenuItem>
xyz
</MenuItem>
</Menu>
)
}
let wrapper: RenderResult, menuElement: HTMLElement, activeElement: HTMLElement, disabledElement: HTMLElement;
describe('test Menu and MenuItem component in default(horizontal) mode', () => {
beforeEach(() => {
wrapper = render(generateMenu(testProps));
menuElement= wrapper.getByTestId('test-menu');
activeElement = wrapper.getByText('active');
disabledElement = wrapper.getByText('disabled');
})
it('should render correct Menu and MenuItem based on default props' , () => {
expect(menuElement).toBeInTheDocument();
expect(menuElement).toHaveClass('fun-menu test');
expect(menuElement.getElementsByTagName('li').length).toEqual(3);
expect(activeElement).toHaveClass('fun-menu-item is-active');
expect(disabledElement).toHaveClass('fun-menu-item is-disabled');
})
it('click items should change active and call the right callback', () => {
const thirdItem = wrapper.getByText('xyz');
fireEvent.click(thirdItem);
expect(thirdItem).toHaveClass('is-active');
expect(activeElement).not.toHaveClass('is-active');
expect(testProps.onSelect).toHaveBeenCalledWith(2);
fireEvent.click(disabledElement);
expect(disabledElement).not.toHaveClass('is-active');
expect(testProps.onSelect).not.toHaveBeenCalledWith(1);
})
it('test Menu and MenuItem component in vertical mode', () => {
cleanup();
const wrapper = render(generateMenu(testVerProps));
const menuElement = wrapper.getByTestId('test-menu');
expect(menuElement).toHaveClass('fun-menu-vertical');
})
})
网友评论