React 文本截断组件 rc-text-ellipsis
前言
在前端开发中,文本截断(Text Ellipsis)是一个极其常见的需求。无论是新闻列表、商品描述、用户评论,还是各类卡片组件,我们经常需要在有限的空间内展示较长的文本内容。虽然 CSS 的 text-overflow: ellipsis 可以实现单行文本截断,但在面对多行文本、自定义省略位置、展开/收起功能等复杂场景时,纯 CSS 方案就显得力不从心了。
本文将深入剖析 rc-text-ellipsis 组件的设计与实现,这是一个功能强大、高度灵活的 React 文本截断组件,它不仅支持多行文本截断,还提供了三种省略位置(开始、中间、结尾)、展开/收起功能、自定义操作按钮、响应式自动重算等特性。
- github地址:github.com/wulala0102/…
- 效果预览:rc-text-ellipsis.vercel.app
核心特性一览
在深入代码实现之前,让我们先了解一下这个组件提供的核心能力:
- 🎯 精确的多行文本截断 - 支持任意行数的文本截断控制
- 📍 三种省略位置 - 支持在文本开始、中间、结尾位置进行省略
- 🔄 展开/收起功能 - 提供完整的状态管理和交互能力
- 🎨 高度可定制 - 支持自定义操作按钮、省略符号等
- 📱 响应式设计 - 窗口大小变化时自动重新计算
- 🎛️ 命令式 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. 文本截断的本质挑战
实现文本截断看似简单,但实际上面临诸多挑战:
- 如何精确控制行数? - 不同字体、字号、行高下的行数计算
- 如何找到截断点? - 在保证不超过指定行数的前提下,尽可能多地显示文本
- 如何处理操作按钮? - 操作按钮本身也占用空间,需要纳入计算
- 如何优化性能? - 避免大量 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获取所有计算后的样式,确保克隆容器与原容器在渲染上完全一致 - 离屏渲染 - 将克隆容器移出视口,避免影响页面布局和用户体验
-
高度自适应 - 重置
height、minHeight、maxHeight为auto,让内容自然撑开,便于测量真实高度
这种技术让我们可以在不影响实际 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 是一个设计精良、实现优雅的文本截断组件。它的核心价值在于:
- 算法优化 - 采用二分查找而非线性搜索,性能优异
- 功能完整 - 支持多种省略位置、展开/收起、自定义按钮等
- 工程化优秀 - TypeScript 支持、清晰的代码结构、完善的测试
- 用户体验好 - 响应式、流畅的交互
未来可能的改进方向:
- 支持虚拟滚动场景的优化
- 提供服务端渲染(SSR)支持
- 支持动画过渡效果
- 支持更多的自定义钩子(如 onExpand、onCollapse)
参考资源
希望这篇文章能够帮助你深入理解文本截断组件的实现原理,并在自己的项目中灵活运用这些技术。如果你有任何问题或建议,欢迎在 GitHub 上提 issue 讨论。