前言
之前写过一篇相关的文章,但是没啥流量(没办法,不当标题党都没人点进来看😂),最近因为有了新需求于是在功能和写法上都进行了优化,需要对比的同学可以看一下上篇文章,上篇文章在这里.
大致需求呢就是有一个tag列表,只需要展示2行,当tag数量超出规定行数之后进行省略,鼠标移上去展示省略的内容 还是老规矩,先看一下最终的实现效果:
实现过程
第一步
第一步也是最核心的问题,就是需要计算出在第几个tag处进行隐藏,核心代码就是下面这段,原理就是通过useLayoutEffect拿到dom列表进行遍历,如果某个tag的offsetTop大于 行数*tag高度 了就表示超出了规定行数
js
useLayoutEffect(() => {
if (!ref.current) return;
const { childNodes, offsetWidth } = ref.current! as HTMLDivElement;
const nodeArr = Array.from(childNodes) as HTMLSpanElement[];
let index = nodeArr?.findIndex((item) => {
return item.offsetTop > item.offsetHeight * row;
});
if (
index > 0 &&
offsetWidth -
nodeArr[index - 1].offsetLeft -
nodeArr[index - 1].offsetWidth <
50
) {
index--;
}
setIndex(index);
}, []);
当然光计算出位置是不够的,因为有可能出现下面这种情况,虽然我们算出是在第3个tag换行了,但实际上第一行剩下的宽度不足以放下省略按钮,所以我们还需要计算下当前行的剩余宽度,如果不够需要index-1
计算剩余宽度前:
计算剩余宽度后:
至于省略按钮之前的那一版我是通过计算位置,然后绝对定位来实现的,这种方式不太好,因为需要在外部的box上预留出50的padding-right(防止定位重叠了),所以这次优化了一下,通过计算出的index来截取tag数组,在后面追加一个tag
老版的实现:
响应式
响应式的实现原理和之前那篇差不多,都是通过'resize-observer-polyfill'这个库来监听元素改变,重新计算位置,只不过这一次优化了一下写法
老版本的话省略的dom都是实际存在的,因为每次渲染都需要遍历dom元素来计算位置,如果截取的话,就会导致组件刷新时获取的dom元素数量不对
在这次的版本中为了解决这个问题,我在原本的组件外包裹了一层,通过父组件改变key来重新挂载子组件,在子组件中监听组件第一次挂载就可以计算出位置了,每次需要重新计算index时都重新挂载一次
js
父组件:
const ref = useRef<HTMLDivElement>(null);
const [renderKey, setRenderKey] = useState(Date.now());
useLayoutEffect(() => {
if (!ref.current) return;
const observe = new ResizeObserver(
throttle(() => {
setRenderKey(Date.now());
}, 300),
);
observe.observe(ref.current);
return () => {
observe.disconnect();
};
}, []);
useEffect(() => {
setRenderKey(Date.now());
}, [data, row, className]); // 控制tagList强制刷新,以便于重新计算位置
return (
<StyledWrap ref={ref}>
<TagContent {...{ data, row, className, onClick }} key={renderKey} />
</StyledWrap>
);
子组件:
useLayoutEffect(() => {
if (!ref.current) return;
const { childNodes, offsetWidth } = ref.current! as HTMLDivElement;
const nodeArr = Array.from(childNodes) as HTMLSpanElement[];
let index = nodeArr?.findIndex((item) => {
return item.offsetTop > item.offsetHeight * row;
});
console.log('没有计算剩余宽度的index:', index);
if (
index > 0 &&
offsetWidth -
nodeArr[index - 1].offsetLeft -
nodeArr[index - 1].offsetWidth <
50
) {
index--;
}
console.log('计算了剩余宽度的index:', index);
setIndex(index);
}, []); // 因为父组件控制了强制渲染,所以只需要在第一次渲染后计算位置就可以了
这样做的目的就是为了我们只需要在组件初始化的时候计算位置,每次更新位置时都重新挂载组件,然后截取tag数组,可以看到新写法里面已经根据index做了截取
完整代码在这里
我目前想到的实现方法就是这样,如果各位大佬有更好的实现方式,欢迎讨论👍!
完整代码在下面
js
import { Popover } from 'antd';
import { throttle } from 'lodash-es';
import React, {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import ResizeObserver from 'resize-observer-polyfill';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
interface IOption {
label: string;
value: string | number;
}
interface IProps {
row?: number;
data: IOption[];
className?: string;
onClick?: (item: IOption) => void;
}
const TagContent = ({ row = 1, data, className, onClick }: IProps) => {
const ref = useRef<HTMLDivElement>(null);
const [index, setIndex] = useState(-1);
useLayoutEffect(() => {
if (!ref.current) return;
const { childNodes, offsetWidth } = ref.current! as HTMLDivElement;
const nodeArr = Array.from(childNodes) as HTMLSpanElement[];
let index = nodeArr?.findIndex((item) => {
return item.offsetTop > item.offsetHeight * row;
});
if (
index > 0 &&
offsetWidth -
nodeArr[index - 1].offsetLeft -
nodeArr[index - 1].offsetWidth <
50
) {
index--;
}
setIndex(index);
}, []); // 因为父组件控制了强制渲染,所以只需要在第一次渲染后计算位置就可以了
const onItemClick = (item: IOption) => {
if (!onClick) return undefined;
return () => {
onClick(item);
};
};
const { showList, rest } = useMemo(() => {
if (index > -1) {
return {
showList: data.slice(0, index),
rest: data.slice(index, data.length),
};
}
return {
showList: data,
rest: [],
};
}, [index, data]);
return (
<StyledTagList className={className} ref={ref}>
{showList.map((item) => (
<StyledTag
key={item.value}
onClick={onItemClick(item)}
title={item.label}
>
{item.label}
</StyledTag>
))}
{index > -1 && (
<Popover
overlayClassName="toggle-tag-list"
content={
<>
{rest.map((item) => (
<StyledTag
key={item.value}
onClick={onItemClick(item)}
title={item.label}
>
{item.label}
</StyledTag>
))}
</>
}
>
<StyledTag style={{ cursor: 'pointer' }}>
+{data.length - index}
</StyledTag>
</Popover>
)}
</StyledTagList>
);
};
export const TagList = ({ data, row, className, onClick }: IProps) => {
const ref = useRef<HTMLDivElement>(null);
const [renderKey, setRenderKey] = useState(Date.now());
useLayoutEffect(() => {
if (!ref.current) return;
const observe = new ResizeObserver(
throttle(() => {
setRenderKey(Date.now());
}, 300),
);
observe.observe(ref.current);
return () => {
observe.disconnect();
};
}, []);
useEffect(() => {
setRenderKey(Date.now());
}, [data, row, className]); // 控制tagList强制刷新,以便于重新计算位置
return (
<StyledWrap ref={ref}>
<TagContent {...{ data, row, className, onClick }} key={renderKey} />
</StyledWrap>
);
};
const StyledWrap = styled.div`
position: relative;
overflow: hidden;
height: 100%;
`;
const StyledTagList = styled.div`
position: absolute;
width: 100%;
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
`;
const StyledTag = styled.span<{ onClick?: () => void }>`
min-width: 20px;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
text-align: center;
padding: 0px 6px;
border-radius: 2px;
background: rgba(63, 81, 181, 0.08);
color: #1f2878;
line-height: 20px;
white-space: nowrap;
${({ onClick }) =>
!onClick
? ''
: css`
cursor: pointer;
`}
`;