背景
最近项目中需要实现一个自适应宽度并折叠成下拉菜单的面包屑组件,面包屑组件实现已经有很多组件库可以参考,但是宽度自适应折叠需要进行实时计算了。
问题拆解
首先是宽度自适应,需要获取面包屑的显示宽度,以及每一个子元素宽度,进行宽度求和才知道是否超长。其次,需要折叠哪些子元素才能实现不超长,需要尝试进行子元素宽度叠加,最后需要知道哪些子元素进行隐藏,哪个子元素替换成下拉菜单,需要设置hide
和replace
标识。这些都不是难点,比较困难的是如何在子元素已经被折叠的情况下计算子元素的宽度,还有就是如何在子元素折叠的情况下将事件透传给折叠菜单的元素进行响应,最后就是面包屑组件如何进行通信,也就是数据的传递。
实现思路
针对需要解决的问题,采用如下思路:
1、使用一个shadow
组件将子元素完全展开,该元素仅用于计算使用;
2、使用context
用于组件的通信;
3、设置hide
和replace
标志位,并将事件透给标志位,进行子元素的折叠和事件透传;
实现过程
1、实现Breadcrumb
组件
import React, { useLayoutEffect, useRef, useState } from 'react';
import { BreadcrumbContext, IBreadcrumbItemProps, IBreadcrumbItem } from './BreadcrumbContext';
import './Breadcrumb.less';
interface IBreadcrumbProps {
onClick?: (item: IBreadcrumbItemProps) => void;
separator?: React.ReactNode;
}
export const Breadcrumb: React.FC<IBreadcrumbProps> = (props) => {
const breadContainerRef = useRef<HTMLDivElement>(null);
const breadShadowRef = useRef<HTMLDivElement>(null);
const [hideItems, setHideItems] = useState<IBreadcrumbItem[]>([]);
const [replaceItem, setReplaceItem] = useState<IBreadcrumbItem>();
const { children, onClick, separator = '>' } = props;
useLayoutEffect(() => {
if (breadShadowRef.current && breadContainerRef.current) {
let currentWidth = 0;
const { width: maxWidth } = breadContainerRef.current.getBoundingClientRect();
const items: IBreadcrumbItem[] = [];
const breadcrumbShadow = breadShadowRef.current;
const count = breadcrumbShadow.childElementCount;
const breadcrumbChildren = breadcrumbShadow.children;
// children 的个数小于三个直接返回
if (count < 3) {
return;
}
for (let i = 0; i < count; i++) {
const child = breadcrumbChildren[i];
const { width } = child.getBoundingClientRect();
const { id, label } = (child as any).dataset;
currentWidth += width;
items.push({
id,
label,
width,
onClick,
});
}
console.log('items:', items, currentWidth, maxWidth);
if (maxWidth <= currentWidth) {
// 60 是下拉按钮默认操作的宽度
const diff = currentWidth - maxWidth + 60;
let index = 1;
let diffWidth = 0;
const hideItems = [];
// count - 1 最后一个永远保留
for (let i = index; i < count - 2; i++, index++) {
const item = items[i];
diffWidth += item.width;
if (diffWidth >= diff) break;
}
for (let i = 1; i < index; i++) {
hideItems.push(items[i]);
}
setHideItems(hideItems);
setReplaceItem(items[index]);
}
}
// 组件更新进行数据还原
return () => {
setHideItems([]);
setReplaceItem(undefined);
};
}, [children]);
return (
<div className="breadcrumb-container" ref={breadContainerRef}>
<div className="breadcrumb">
<BreadcrumbContext
onItemClick={onClick}
separator={separator}
hideItems={hideItems}
replaceItem={replaceItem}
>
{children}
</BreadcrumbContext>
</div>
<div className="breadcrumb" ref={breadShadowRef} style={{ position: 'absolute', visibility: 'hidden' }}>{children}</div>
</div>
)
}
2、实现BreadcrumbItem
组件
import React, { useMemo } from 'react';
import { Menu, Dropdown } from 'antd';
import { EllipsisOutlined } from '@ant-design/icons';
import { IBreadcrumbItemProps, useBreadcrumbContext } from './BreadcrumbContext';
import './Breadcrumb.less';
export const BreadcrumbItem: React.FC<IBreadcrumbItemProps> = (props) => {
const { id, label } = props;
const { separator, hideItems = [], replaceItem } = useBreadcrumbContext();
const isHide = useMemo(() => {
return !!hideItems.find((item) => (item.id === id || Number(item.id) === id));
}, [hideItems, id]);
const isReplace = useMemo(() => {
return !!(replaceItem && (replaceItem.id === id || Number(replaceItem.id) === id));
}, [replaceItem, id]);
return (
<div className="breadcrumb-item" data-id={id} data-label={label}>
{
isHide ? null : (
isReplace ? (
<Dropdown
overlay={
<Menu>
{
[...hideItems, replaceItem].map(item => {
return (
<Menu.Item key={item?.id} onClick={() => item?.onClick && item?.onClick(item)}>
{item?.label}
</Menu.Item>
)
})
}
</Menu>
}
>
<div style={{ display: 'flex', alignItems: 'center' }}>
<EllipsisOutlined />
<div>
{separator}
</div>
</div>
</Dropdown>
) : (<>
<div>
{label}
</div>
<div>
{separator}
</div>
</>)
)
}
</div>
);
};
3、实现BreadcrumbContext
通信组件
import React, { useContext, useMemo } from 'react';
export interface IBreadcrumbItemProps {
id: number | string;
label: string;
}
export interface IBreadcrumbItem extends IBreadcrumbItemProps {
onClick?: (item: IBreadcrumbItemProps) => void;
width: number;
}
interface IBreadcrumbContextProps {
separator?: React.ReactNode;
hideItems?: IBreadcrumbItem[];
replaceItem?: IBreadcrumbItem;
onItemClick?: (item: IBreadcrumbItemProps) => void;
}
const Context = React.createContext<IBreadcrumbContextProps>({
separator: '>',
hideItems: [],
});
export const BreadcrumbContext: React.FC<IBreadcrumbContextProps> = (props) => {
const { children, separator, onItemClick, hideItems, replaceItem } = props;
const contextValue = useMemo(() => {
return {
separator: separator || '>',
hideItems: hideItems || [],
replaceItem,
onItemClick,
};
}, [separator, onItemClick, hideItems]);
return (
<Context.Provider value={contextValue}>
{
children
}
</Context.Provider>
);
};
export const useBreadcrumbContext = () => {
const contextValue = useContext(Context);
return contextValue;
};
4、Breadcrumb.less
组件样式
.breadcrumb-container {
display: flex;
font-size: 16px;
line-height: 1.5;
width: 100%;
overflow: hidden;
position: relative;
.breadcrumb {
display: flex;
align-items: center;
.breadcrumb-item {
display: flex;
align-items: center;
color: #000;
&:last-child {
& > div:last-child {
display: none;
}
}
}
}
}
测试使用面包屑组件
<div className="wrap">
{
<Breadcrumb onClick={(res) => console.log('res:', res)}>
{
displayItems.map(item => {
return (
<BreadcrumbItem key={item.id} id={item.id} label={item.label} />
)
})
}
</Breadcrumb>
}
</div>