如何实现多行内文字自动省略号+suffix?

1,705 阅读3分钟

诉求

前端页面的典型场景:有限空间内展示酒店名字+价格,价格更为重要,空间不足时需要省略掉过长的酒店名字,即实现 text ellipsis + suffix 的混排,而且要尽可能多的展示文本。

实现

思路

如果仅仅是多行文字展示省略号,使用css 即可实现:

 display: '-webkit-box';
 overflow: 'hidden';
 webkit-line-clamp: 2;

但是需要在最后一行,优先展示完整的后缀suffix后,在有限空间内再省略展示剩余文字,就不得不结合 js 来计算了。当然有一种 trick 方案, "... ${suffix}" 绝对定位在 textContainer 右下角并设置背景色,但是可能会遮挡部分文字,效果是难以接受的。

Js 方案思路:

  1. 判断是否需要展示省略号: 使用 maxRow + 1 的空间尝试去渲染内容,看实际高度是否大于 lineHeight * maxRow

    1. 注意 lineHeight 的获取:如果没有为该节点设置 lineHeight,则难以直接读取到 lineHeight 值,可以再渲染个单行的节点读取其 offsetHeight 来得到 lineHeight。曾经尝试使用 getComputedStyle(container).lineHeight 来获取,但是上层如果没有明确设置过值,结果会是 normal,而非数值。
  2. 如果需要展示省略号,需要在文字的哪个位置进行截断呢:使用二分法(双指针逼近)不断检测最合适的位置,使得内容正好可以在 maxRow 空间内展示。

    1. 在找截断位置的这个过程,要不断触发 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>}
    </>
  );
};

完整示例:codesandbox.io/p/sandbox/d…