Overflow 的溢出折叠属于一种很常见的特性,但如果要求用 JavaScript 去实现却又是一件头痛的事情,所以不如来封装一个通用的组件。
首先要明确的一个点是 JavaScript 实现的「溢出折叠」能力具有较大的性能开销,所以如果要用在循环渲染的地方(表格、列表)需要慎重。
基础骨架
该组件最基础的版本仅需要三个部分:
type OverflowType = {
keys: React.Key[],
itemRender: (key: React.Key) => React.ReactNode
restRender: (keys: React.Key[]) => React.ReactNode
}
// Overflow 组件
<div style={{ display: "flex", flexWrap: "wrap" }} ref={ref}>
{keys.map((i, idx) => (
<OverflowItem key={i} itemKey={i} idx={idx}>{itemRender(i)}</OverflowItem>
))}
<OverflowRest visible={omitKeys.length > 0}>{restRender(omitKeys)}</OverflowRest>
</div>
显隐方案
简单来讲我们需要对不确定宽度的 item 挨个排队,发现排不下的就要把它隐藏掉,同时在末尾插入 rest(当然同时要保证 rest 塞得下)。当 container、item、rest 任意一者变化时,我们需要开始重新计算。
这里就存在现实的问题:我们需要把 item 渲染到 dom 上才能测量它的宽度,然后才可以决定隐藏多少元素,然后在重新计算时又需要用到这一点。所以我们不应当使用 display: none 或者直接在 jsx 中 if-else 掉元素,这会极大的增加开销。
如果我们不从“物理层面”去干掉元素,但我们要实现把 rest 插入到 item 队列的任意位置,也就是会涉及到 dom 元素的删除与插入,对于 React 也是极大的开销。
换个思路,可以用 CSS 排序的方式去代替插队的动作,这里我们可以用到 CSS 属性 flex-order 实现。
type OverflowCommonItemProps = PropsWithChildren<{
visible?: boolean
order: number
}>
// 显示隐藏通过 overflowY 来控制
const visibleStyle: React.CSSProperties = useMemo(
() => ({
height: visible ? undefined : 0,
overflowY: visible ? undefined : 'hidden',
pointerEvents: visible ? undefined : 'none',
}),
[visible],
)
由于组件的外层需要关心到 dom 的物理尺寸(主要指宽度),所以我们需要用 forwardRef 包一层:
const OverflowCommonItem: ForwardRefRenderFunction<
HTMLDivElement,
OverflowCommonItemProps
> = ({ visible, order, children }, ref) => {
return (
<div style={{ order, ...visibleStyle }} ref={ref}>
{children}
</div>
)
}
const OverflowCommonItemRef = React.forwardRef(OverflowCommonItem)
核心算法
确定了显隐的实现逻辑后,计算逻辑就变得水到渠成了。
首先我们需要定义控制显隐的 state 模型:
type Action =
| { type: 'SET_ITEM_WIDTH'; payload: { key: React.Key; width: number } }
| { type: 'SET_REST_WIDTH'; payload: number }
| { type: 'SET_DISPLAY_IDX'; payload: number }
type State = {
itemWidths: Record<React.Key, number>
restWidth: number
displayIdx: number
}
按照上述的思路我们需要在每次相干的 dom 变化时重新计算,因此我们需要借助 useLayoutEffect 这个 hook,同时把我们的 deps 罗列清楚:
useLayoutEffect(() => {
// 计算
}, [itemWidths, containerWidth, keys, restWidth])
简单的写下以下逻辑
// 当可以排下所有 items 时,就全部显示
if (totalWidth <= containerWidth) {
dispatch({ type: 'SET_DISPLAY_IDX', payload: keys.length - 1 })
return
}
// 当确定无法排下时,就先为 rest 预留空间
let displayWith = restWidth
let last = -1
for (let idx = 0; idx < keys.length; idx++) {
const key = keys[idx]
const width = itemWidths[key] || 0
if (displayWith + width > containerWidth) {
break
}
last = idx
displayWith += width
}
dispatch({ type: 'SET_DISPLAY_IDX', payload: last })
然后我们把 item 和 rest 的逻辑抽象出来和 state 连接。主要是实现两个功能:
// Item
const useOverflowItem = (key: React.Key, idx: number) => {
const [ref, { width }] = useSize<HTMLDivElement>()
const dispatch = useContextSelector(OverflowContext, o => o.dispatch)
const displayIdx = useContextSelector(OverflowContext, o => o.displayIdx)
const visible = idx <= displayIdx
const order = visible ? idx : Number.MAX_SAFE_INTEGER
useLayoutEffect(() => {
dispatch({
type: 'SET_ITEM_WIDTH',
payload: { key, width },
})
}, [width, dispatch, key])
return { ref, order, visible }
}
// Rest
const useOverflowRest = (visible: boolean) => {
const [ref, { width }] = useSize<HTMLDivElement>()
const displayIdx = useContextSelector(OverflowContext, o => o.displayIdx)
const dispatch = useContextSelector(OverflowContext, o => o.dispatch)
const order = visible ? displayIdx + 1 : Number.MAX_SAFE_INTEGER
useLayoutEffect(() => {
dispatch({
type: 'SET_REST_WIDTH',
payload: width,
})
}, [dispatch, width])
return { ref, order }
}
做完这些,一个基本的 Overflow 就大功告成了!

额外优化
到目前为止我们做了一些基本的代码,还有许多细节可以优化(bug or feature)。
渲染时闪烁
当存在折叠元素之后,页面初次加载会有短暂的闪烁现象。

出现这个现象的原因其实在本文最开始就提到了,元素显隐的控制计算依赖元素渲染完成之后的 width 测量以及上报,所以元素的渲染必然发生在计算之前,等到计算结束之后才会折叠。
所以就会出现短暂的显示出全部元素的现象。
最简单的办法就是默认隐藏未渲染的元素:
// 由于 dom 元素总是从上至下渲染
// 如果顺序计算时发现某个元素没有上报 width,表示其还没有渲染完成,
// 则隐藏其以及其后的元素
if (width === undefined) {
break
}
关心 rest 动态变化
在上述计算时,其实大多数情况我们只把 rest 当作一个特殊的 item,但当 rest 存在一些特殊渲染逻辑时,可能会存在 BUG。
比如以下这段代码,rest 的宽度受到隐藏 keys 的数量影响,这是很常见的需求。

当显示的元素变少时,rest 会变长;当显示的元素变多时,rest 会变短。
逻辑敏感的同学是不是察觉到里面似乎蕴含着某种死循环,没错!

所以我们需要对 rest 的宽度做一个简单的缓存,并且取最大值:
const useFirst = () => {
const isFirst = useRef(true)
if (isFirst.current) {
isFirst.current = false
return true
}
return isFirst.current
}
const usePreviousDistinct = <T,>(value: T) => {
const prevRef = useRef<T>()
const curRef = useRef<T>(value)
const isFirst = useFirst()
if (!isFirst && curRef.current !== value) {
prevRef.current = curRef.current
curRef.current = value
}
return prevRef.current
}
const prevRestWidth = usePreviousDistinct(restWidth)
const maxRestWidth = Math.max(prevRestWidth || 0, restWidth)
useLayoutEffect(() => {
let totalItemWidth = 0
let last = -1
for (let idx = 0; idx < keys.length; idx++) {
const key = keys[idx]
const width = itemWidths[key]
if (
width === undefined ||
(totalItemWidth + width + maxRestWidth > containerWidth &&
idx !== keys.length - 1) ||
totalItemWidth + width > containerWidth
) {
break
}
last = idx
totalItemWidth += width
}
dispatch({ type: 'SET_DISPLAY_IDX', payload: last })
}, [itemWidths, containerWidth, keys, maxRestWidth, dispatch])
至此已经基本完成了一个粗糙的 Overflow 组件。如果要实现一个兼顾高性能、渲染强大的组件,还有诸多细节需要关心是本文没有讲到的,在业务迭代中逐渐完善吧~