诉求
前端页面的典型场景:有限空间内展示酒店名字+价格,价格更为重要,空间不足时需要省略掉过长的酒店名字,即实现 text ellipsis + suffix 的混排,而且要尽可能多的展示文本。
实现
思路
如果仅仅是多行文字展示省略号,使用css 即可实现:
display: '-webkit-box';
overflow: 'hidden';
webkit-line-clamp: 2;
但是需要在最后一行,优先展示完整的后缀suffix后,在有限空间内再省略展示剩余文字,就不得不结合 js 来计算了。当然有一种 trick 方案, "... ${suffix}" 绝对定位在 textContainer 右下角并设置背景色,但是可能会遮挡部分文字,效果是难以接受的。
Js 方案思路:
-
判断是否需要展示省略号: 使用 maxRow + 1 的空间尝试去渲染内容,看实际高度是否大于 lineHeight * maxRow
- 注意 lineHeight 的获取:如果没有为该节点设置
lineHeight
,则难以直接读取到lineHeight
值,可以再渲染个单行的节点读取其offsetHeight
来得到lineHeight
。曾经尝试使用getComputedStyle(container).lineHeight
来获取,但是上层如果没有明确设置过值,结果会是 normal,而非数值。
- 注意 lineHeight 的获取:如果没有为该节点设置
-
如果需要展示省略号,需要在文字的哪个位置进行截断呢:使用二分法(双指针逼近)不断检测最合适的位置,使得内容正好可以在 maxRow 空间内展示。
- 在找截断位置的这个过程,要不断触发 rerender,以获取内容最新高度再判断
代码
import { useLayoutEffect, useRef, useState } from 'react';
interface IEllipsisContentProps {
text: string;
ellipsisSuffix?: string;
suffix?: string;
maxRows?: number;
}
enum MeasureStatus {
PREPARE = 'PREPARE',
CALCULATING = 'CALCULATING',
DONE = 'DONE',
}
const EllipsisWithSuffix = (props: IEllipsisContentProps) => {
const { text, ellipsisSuffix = '...', suffix = '', maxRows = 1 } = props;
const containerRef = useRef<HTMLDivElement>(null);
// 多渲染一个单行节点,用于获取 lineHeight
const singleLineRef = useRef<HTMLDivElement>(null);
const singleLineHeight = useRef<number>(0);
// 设计 准备 - 计算 - 最终结果这3个阶段,需要展示的内容也会有所差别
const [measureStatus, setMeasureStatus] = useState(MeasureStatus.PREPARE);
// 通过双指针查找找到最终可以完整展示的区间,不断缩小查找范围
const [rangeIndex, setRangeIndex] = useState([0, text.length - 1]);
// 向上取整,最终找到尽可能完整展示的最大文字区间
const midIndex = Math.ceil((rangeIndex[0] + rangeIndex[1]) / 2);
useLayoutEffect(() => {
const node = containerRef.current;
const singleLineNode = singleLineRef.current;
if (!node || !singleLineNode) {
return;
}
const lightHeight = singleLineNode.offsetHeight;
singleLineHeight.current = lightHeight;
if (node.offsetHeight > lightHeight * maxRows) {
setMeasureStatus(MeasureStatus.CALCULATING);
} else {
setMeasureStatus(MeasureStatus.DONE);
}
}, []);
useLayoutEffect(() => {
if (measureStatus !== MeasureStatus.CALCULATING) {
return;
}
const node = containerRef.current;
if (!node) {
return;
}
const lightHeight = singleLineHeight.current;
const currentHeight = node.offsetHeight;
const targetHeight = lightHeight * maxRows;
// 如果还没找到最终的最小区间,那就继续逼近
if (rangeIndex[1] - rangeIndex[0] > 1) {
if (currentHeight > targetHeight) {
setRangeIndex([rangeIndex[0], midIndex]);
} else {
setRangeIndex([midIndex, rangeIndex[1]]);
}
} else {
// 已经到了最小相邻区间,判断最终是取左边还是右边,避免最终二选一时的midIndex计算错误,左右指针此时直接设置为可靠值
if (currentHeight > targetHeight) {
setRangeIndex([rangeIndex[0], rangeIndex[0]]);
} else {
setRangeIndex([rangeIndex[1], rangeIndex[1]]);
}
setMeasureStatus(MeasureStatus.DONE);
}
}, [measureStatus, rangeIndex]);
const renderText = (renderIndex: number) => {
return text.slice(0, renderIndex) + ellipsisSuffix + suffix;
};
return (
<>
<div
ref={containerRef}
style={{
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
// 初始状态下,使用加大一行的空间尝试去完整渲染
WebkitLineClamp: measureStatus === MeasureStatus.DONE ? maxRows : maxRows + 1,
}}>
{measureStatus === MeasureStatus.PREPARE ? text + suffix : renderText(midIndex)}
</div>
/** 只为获取到 lineHeight 值 */
{measureStatus === MeasureStatus.PREPARE && <div ref={singleLineRef}>{text.slice(0, 1)}</div>}
</>
);
};