阅读视图

发现新文章,点击刷新页面。

React 文本截断组件 rc-text-ellipsis

前言

在前端开发中,文本截断(Text Ellipsis)是一个极其常见的需求。无论是新闻列表、商品描述、用户评论,还是各类卡片组件,我们经常需要在有限的空间内展示较长的文本内容。虽然 CSS 的 text-overflow: ellipsis 可以实现单行文本截断,但在面对多行文本、自定义省略位置、展开/收起功能等复杂场景时,纯 CSS 方案就显得力不从心了。

本文将深入剖析 rc-text-ellipsis 组件的设计与实现,这是一个功能强大、高度灵活的 React 文本截断组件,它不仅支持多行文本截断,还提供了三种省略位置(开始、中间、结尾)、展开/收起功能、自定义操作按钮、响应式自动重算等特性。

核心特性一览

在深入代码实现之前,让我们先了解一下这个组件提供的核心能力:

  • 🎯 精确的多行文本截断 - 支持任意行数的文本截断控制
  • 📍 三种省略位置 - 支持在文本开始、中间、结尾位置进行省略
  • 🔄 展开/收起功能 - 提供完整的状态管理和交互能力
  • 🎨 高度可定制 - 支持自定义操作按钮、省略符号等
  • 📱 响应式设计 - 窗口大小变化时自动重新计算
  • 🎛️ 命令式 API - 通过 ref 提供外部控制能力
  • 💪 TypeScript 支持 - 完整的类型定义
  • 高效算法 - 采用二分查找算法优化性能

技术架构设计

1. 组件结构

rc-text-ellipsis 采用了清晰的分层架构:

src/
├── TextEllipsis.tsx          # 主组件,负责渲染和事件处理
├── hooks/
│   └── useTextEllipsis.ts    # 核心 Hook,封装文本截断逻辑
└── utils.ts                   # 工具函数,实现算法和 DOM 操作

这种架构设计遵循了单一职责原则,将渲染逻辑、状态管理和计算逻辑清晰分离。

2. 主组件设计

主组件 TextEllipsis.tsx 采用了 React.forwardRef 来暴露命令式 API:

export interface TextEllipsisRef {
  toggle: (expanded?: boolean) => void;
}

const TextEllipsis = React.forwardRef<TextEllipsisRef, TextEllipsisProps>(
  (props, ref) => {
    // 组件实现
  }
);

这种设计让组件既可以作为受控组件使用,也可以通过 ref 进行外部控制,提供了极大的灵活性。

核心算法深度解析

1. 文本截断的本质挑战

实现文本截断看似简单,但实际上面临诸多挑战:

  1. 如何精确控制行数? - 不同字体、字号、行高下的行数计算
  2. 如何找到截断点? - 在保证不超过指定行数的前提下,尽可能多地显示文本
  3. 如何处理操作按钮? - 操作按钮本身也占用空间,需要纳入计算
  4. 如何优化性能? - 避免大量 DOM 操作和重排

2. 克隆容器技术

rc-text-ellipsis 的第一个巧妙设计是使用"克隆容器"技术:

export const cloneContainer = (
  rootElement: HTMLElement | null,
  content: string,
): HTMLDivElement | null => {
  const originStyle = window.getComputedStyle(rootElement);
  const container = document.createElement('div');

  // 复制所有样式
  const styleNames: string[] = Array.prototype.slice.call(originStyle);
  styleNames.forEach((name) => {
    container.style.setProperty(name, originStyle.getPropertyValue(name));
  });

  // 设置为离屏元素
  container.style.position = 'fixed';
  container.style.zIndex = '-9999';
  container.style.top = '-9999px';
  container.style.height = 'auto';

  container.innerText = content;
  document.body.appendChild(container);

  return container;
};

设计亮点:

  • 完整样式继承 - 通过 getComputedStyle 获取所有计算后的样式,确保克隆容器与原容器在渲染上完全一致
  • 离屏渲染 - 将克隆容器移出视口,避免影响页面布局和用户体验
  • 高度自适应 - 重置 heightminHeightmaxHeightauto,让内容自然撑开,便于测量真实高度

这种技术让我们可以在不影响实际 DOM 的情况下,进行各种测试和计算。

3. 二分查找算法

找到最佳截断点是文本截断的核心难题。rc-text-ellipsis 采用了二分查找算法,大大提升了查找效率:

const tail = (left: number, right: number): string => {
  // 递归终止条件
  if (right - left <= 1) {
    if (position === 'end') {
      return content.slice(0, left) + dots;
    }
    return dots + content.slice(right, end);
  }

  // 取中点
  const midPoint = Math.round((left + right) / 2);

  // 构造测试文本
  container.innerText = position === 'end'
    ? content.slice(0, midPoint) + dots
    : dots + content.slice(midPoint, end);
  container.innerHTML += actionHTML;

  // 判断是否超高
  if (container.offsetHeight > maxHeight) {
    // 超高了,需要缩短文本
    if (position === 'end') {
      return tail(left, midPoint);
    }
    return tail(midPoint, right);
  }

  // 还有空间,可以尝试显示更多文本
  if (position === 'end') {
    return tail(midPoint, right);
  }
  return tail(left, midPoint);
};

算法分析:

  • 时间复杂度:O(log n),其中 n 是文本长度
  • 空间复杂度:O(log n),递归调用栈
  • 对比暴力搜索:如果从头逐字符测试,时间复杂度是 O(n),在长文本场景下性能差距显著

直观理解:

假设文本有 1000 个字符:

  • 暴力搜索:最坏情况需要测试 1000 次
  • 二分查找:最多只需要测试 10 次(log₂1000 ≈ 10)

4. 中间位置省略的双指针算法

对于中间位置省略(middle position),问题变得更加复杂,因为需要同时确定左右两个截断点。rc-text-ellipsis 采用了双指针同步二分的策略:

const middleTail = (
  leftPart: [number, number],
  rightPart: [number, number],
): string => {
  // 递归终止条件
  if (leftPart[1] - leftPart[0] <= 1 && rightPart[1] - rightPart[0] <= 1) {
    return (
      content.slice(0, leftPart[0]) +
      dots +
      content.slice(rightPart[1], end)
    );
  }

  // 同时缩小左右两个搜索区间
  const leftMiddle = Math.floor((leftPart[0] + leftPart[1]) / 2);
  const rightMiddle = Math.ceil((rightPart[0] + rightPart[1]) / 2);

  container.innerText =
    content.slice(0, leftMiddle) +
    dots +
    content.slice(rightMiddle, end);
  container.innerHTML += actionHTML;

  if (container.offsetHeight >= maxHeight) {
    // 超高了,两边同时向中心收缩
    return middleTail(
      [leftPart[0], leftMiddle],
      [rightMiddle, rightPart[1]],
    );
  }

  // 还有空间,两边同时向外扩展
  return middleTail(
    [leftMiddle, leftPart[1]],
    [rightPart[0], rightMiddle],
  );
};

算法亮点:

  • 对称性设计 - 左右两边同步进行二分,保证文本均匀分布
  • 统一的时间复杂度 - 仍然是 O(log n)
  • 适用场景 - 特别适合文件路径、URL 等中间部分不重要的场景

例如:/very/long/path/to/some/deep/directory/important-file.txt 可以显示为:/very/long/.../important-file.txt

5. 精确的高度计算

准确计算最大允许高度是算法的基础:

const { paddingBottom, paddingTop, lineHeight } = container.style;
const maxHeight = Math.ceil(
  (Number(rows) + 0.5) * pxToNum(lineHeight) +
    pxToNum(paddingTop) +
    pxToNum(paddingBottom),
);

设计细节:

  • +0.5 行的容差 - 考虑到字体渲染的亚像素偏差,增加半行容差,避免边界情况下的截断错误
  • Math.ceil 向上取整 - 保守策略,确保不会超出限制
  • 包含 padding - 完整考虑盒模型,避免遗漏边距影响

状态管理与生命周期

1. 核心 Hook 设计

useTextEllipsis 是整个组件的核心大脑:

export const useTextEllipsis = (options: UseTextEllipsisOptions) => {
  const [text, setText] = React.useState(content);
  const [expanded, setExpanded] = React.useState(false);
  const [hasAction, setHasAction] = React.useState(false);
  const needRecalculateRef = React.useRef(false);

  // 核心计算逻辑
  const calcEllipsised = React.useCallback(() => {
    // ... 计算截断文本
  }, [content, rows, position, dots, expandText, action, rootRef, actionRef]);

  return { text, expanded, hasAction, toggle };
};

状态设计:

  • text - 当前显示的文本(可能是截断后的)
  • expanded - 展开/收起状态
  • hasAction - 是否需要显示操作按钮(文本够短时不需要)
  • needRecalculateRef - 标记是否需要重新计算(延迟计算优化)

2. 响应式更新

组件通过监听 window resize 事件实现响应式:

React.useEffect(() => {
  const handleResize = () => {
    calcEllipsised();
  };

  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, [calcEllipsised]);

这确保了在窗口大小变化时,文本截断能够实时调整,提供良好的用户体验。

3. 自定义操作按钮的延迟计算

针对自定义操作按钮,组件采用了巧妙的延迟策略:

React.useEffect(() => {
  calcEllipsised();

  if (action) {
    const timer = setTimeout(calcEllipsised, 0);
    return () => clearTimeout(timer);
  }
}, [calcEllipsised, action]);

为什么需要延迟?

当使用自定义 action 渲染函数时,第一次渲染时 actionRef.current 可能还未挂载,无法获取其 outerHTML。通过 setTimeout(..., 0) 将计算推迟到下一个事件循环,确保 DOM 已经完全渲染。

使用场景与最佳实践

1. 基础用法

最简单的使用方式:

import TextEllipsis from 'rc-text-ellipsis';
import 'rc-text-ellipsis/assets/index.css';

<TextEllipsis
  rows={3}
  content="这是一段很长的文本..."
  expandText="展开"
  collapseText="收起"
/>

2. 文件路径显示

利用 middle position 优雅地显示长路径:

<TextEllipsis
  position="middle"
  rows={1}
  content="/Users/username/Documents/Projects/MyApp/src/components/TextEllipsis.tsx"
/>

显示效果:/Users/username/.../TextEllipsis.tsx

3. 自定义操作按钮

实现更丰富的交互:

<TextEllipsis
  rows={2}
  content={longText}
  action={(expanded) => (
    <span style={{ color: '#1890ff', cursor: 'pointer' }}>
      {expanded ? '▲ 收起' : '▼ 查看更多'}
    </span>
  )}
/>

4. 外部控制

通过 ref 实现编程式控制:

const ref = useRef<TextEllipsisRef>(null);

// 在需要的时候展开
ref.current?.toggle(true);

// 切换状态
ref.current?.toggle();

性能优化策略

1. 使用 React.memo

对于列表渲染场景,建议配合 React.memo 使用:

const TextItem = React.memo(({ content }: { content: string }) => (
  <TextEllipsis rows={2} content={content} />
));

2. 避免频繁的 prop 变化

由于每次 content 变化都会触发重新计算,应该避免不必要的更新:

// ❌ 不好的做法
<TextEllipsis content={data.map(item => item.text).join(' ')} />

// ✅ 好的做法
const memoizedContent = useMemo(
  () => data.map(item => item.text).join(' '),
  [data]
);
<TextEllipsis content={memoizedContent} />

3. 合理设置 rows

rows 值越大,二分查找的迭代次数可能会增加。如果不需要很多行,尽量使用较小的值。

浏览器兼容性

组件使用的核心 API:

  • window.getComputedStyle - IE 9+
  • element.offsetHeight - 所有现代浏览器
  • Array.prototype.slice.call - ES5

兼容性总结:现代浏览器及 IE11+

与其他方案的对比

1. 纯 CSS 方案

.text-ellipsis {
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

优势:性能最好,无需 JavaScript 局限

  • 仅支持 end position
  • 无法添加展开/收起功能
  • 无法自定义省略符号位置
  • 兼容性较差(需要 -webkit- 前缀)

2. React-Text-Truncate

优势:较为成熟的社区方案 对比 rc-text-ellipsis

  • ✅ rc-text-ellipsis 支持三种省略位置
  • ✅ rc-text-ellipsis 提供了 ref API
  • ✅ rc-text-ellipsis 使用二分查找,性能更优

总结与展望

rc-text-ellipsis 是一个设计精良、实现优雅的文本截断组件。它的核心价值在于:

  1. 算法优化 - 采用二分查找而非线性搜索,性能优异
  2. 功能完整 - 支持多种省略位置、展开/收起、自定义按钮等
  3. 工程化优秀 - TypeScript 支持、清晰的代码结构、完善的测试
  4. 用户体验好 - 响应式、流畅的交互

未来可能的改进方向:

  • 支持虚拟滚动场景的优化
  • 提供服务端渲染(SSR)支持
  • 支持动画过渡效果
  • 支持更多的自定义钩子(如 onExpand、onCollapse)

参考资源

希望这篇文章能够帮助你深入理解文本截断组件的实现原理,并在自己的项目中灵活运用这些技术。如果你有任何问题或建议,欢迎在 GitHub 上提 issue 讨论。

❌