开源个 react 缩略组件

背景

入职头条快一年,平常工作中,不止一次听到 UI 反馈,头条 App 内的 h5 上偶尔会出现下面这样的体验 bug。

image-20210228170527346

在缩略符 “…” 前面会小概率出现标点符号,看起来很不雅观。这么长时间这个问题一直没解决,因为自己手头的业务有点多也就没去关注这个他人需求。

最近刚好弄完绩效评估处于需求真空期,刚好想起这个问题,就顺手给解决了,沉淀了个 react 的缩略组件。考虑到这是个很通用的需求,所以整理了一下开源出来。

先直接上干货吧,组件已经开源在 github 上,仓库地址: react-ellipsis (觉得有用可以给个 star)。

可能会有人疑惑,这么常见的需求真的没有可用的轮子吗?我一开始也带着这个疑惑,搜了一下 npm 和 github,发现还真没有能完全符合条件的…

那就自己来造一个吧 -。-

造轮子前

在准备动手解决问题前,我浏览了一下 npm 和 github 上已有的缩略组件,根据 star 数挑几个看看

react-lines-ellipsis

截止发文

  • stars 数 376
  • 未处理 issue 15
  • 上次功能更新 2 年前

问题:

  • 不支持结尾标点符号过滤,只能过滤空白符(可通过 pr 解决,但是作者很长时间未处理 pr 了)

  • 每个 ellipsis 组件都会生成一个隐藏的 div 去计算,性能损耗严重

  • 不支持设置高度缩略

react-truncate

截止发文

  • stars 数 503
  • 未处理 issue 33
  • 上次功能更新 8 月前

问题:

  • 不支持结尾标点符号过滤,只能过滤空白符
  • 不支持按单词或字母切割
  • onResize 需要自行调用
  • 使用 canvas 实现,当元素较多时性能损耗严重

其他组件或多或少都有各自的问题导致无法满足我们的需求,所以动手自己撸吧。

轮子介绍

吭哧吭哧搞了一个下午就写完了,目前迭代到 0.5.2 版本。

先看个简单的示例

提示:容器拉伸是为了方便演示加的,实际组件不包含拉伸功能。

实现方案

核心实现

将组件拆分为 NativeEllipsisJsEllipsis 两个独立的组件。当需求比较简单时,透传为 NativeEllipsis,复杂需求时使用 JsEllipsis 渲染。

NativeEllipsis

当传递属性满足以下条件时:

isSupportNativeEllipsis &&
maxHeight === undefined &&
ellipsisChar === '…' &&
ellipsisNode === undefined &&
endExcludes.length === 0 &&
!onReflow &&
!onEllipsisClick;

基于原生 css -webkit-line-clamp 实现多行缩略,最大程度利用原生功能。

当条件不符合时,采用 js 模拟实现

JsEllipsis

组件基础结构如下:

<div ref={ref} className="ellipsis-js">
  <span ref={textRef} className="ellipsis-js-text"></span>
  <span ref={ellipsisRef} className="ellipsis-js-ellipsis">
    {ellipsisNode || ellipsisChar}
  </span>
</div>

.ellipsis-js 为主容器,.ellipsis-js-text 为内容容器,.ellipsis-js-ellipsis 为缩略符(或节点)容器。

缩略原理

缩略逻辑封装为 reflow 函数,通过属性 maxLine(或 maxHeight) 结合容器 line-heightfont-size 计算出容器文本可显示的最大高度。

  1. 隐藏 ellipsis 节点,将完整内容插入容器(根据dangerouslyUseInnerHTML决定使用 innerText 还是 innerHTML),如果不超出高度说明不需要缩略,直接返回
  2. 如果内容超出限制,则根据是否启用富文本缩略执行对应函数
    1. dangerouslyUseInnerHTML = false 时调用 truncateText 函数进行纯文本缩略
    2. dangerouslyUseInnerHTML = true 时
      1. 处理一下当前容器的子节点,纯文本用 span 包裹起来
      2. 调用 truncateHtml 函数进行富文本缩略

文本缩略 truncateText

文字裁剪封装为 truncateText 函数

循环二分裁剪文本,直到主容器刚好不溢出

ellipsisRef.current.style.display = 'inline';
let currentText = '';
let l = 0;
let r = text.length - 1;
// Binary truncate text until get the max limit fragment of text.
while (l < r) {
  const m = Math.floor((l + r) / 2);
  if (l === m) {
    break;
  }
  const temp = text.slice(l, m);
  textRef.current.innerText = currentText + temp;
  const { height } = ref.current.getBoundingClientRect();
  if (height > max) {
    r = m;
  } else {
    currentText += temp;
    l = m;
  }
}
textRef.current.innerText = currentText;

富文本缩略 truncateHtml

function truncateHTML(
    container: HTMLElement,
    textContainer: HTMLElement,
    max: number,
  ) {
    // 仅在容器溢出时进入
    const children = textContainer.childNodes;
    // 当前缩略的文本节点仅有一个子节点
    if (children.length === 1) {
      const node = children[0] as HTMLElement;
      if (node.nodeType === Node.TEXT_NODE) {
        // 纯文本节点直接调用 truncateText
        truncateText(container, textContainer, max);
      } else {
        const html = node.innerHTML;
        // 先清除节点内容,判断空节点是否可以放置
        node.innerHTML = '';
        const { height } = container.getBoundingClientRect();
        if (height > max) {
          // 放置空节点溢出时直接移除该节点,完成缩略
          textContainer.removeChild(node);
          handleOnReflow(true, textContainer.innerHTML);
          return;
        }
        node.innerHTML = html;
        // 空节点不溢出,则意味着缩略边界在该节点内部,递归调用 truncateHTML
        truncateHTML(container, node, max);
      }
    } else {
      const nodes = [].slice.call(children);
      textContainer.innerHTML = '';
      let i = 0;
      // 多个子节点时清空节点内容,从头往后添加子节点,直到找到边界节点
      while (i < nodes.length) {
        textContainer.appendChild(nodes[i]);
        const { height } = container.getBoundingClientRect();
        if (height > max) {
          break;
        }
        i++;
      }
      if (textContainer.childNodes[i]) {
        // 找到边界节点后递归调用 truncateHTML
        truncateHTML(
          container,
          textContainer.childNodes[i] as HTMLElement,
          max,
        );
      }
    }
  }
自适应原理

reflowOnResize =true 时基于 ResizeObserver 监听 ref.current,当 ref.current 大小变化时重新执行 reflow

替代 window.onResize,减少性能浪费

组件结构设计

Props

属性 类型 默认值 描述
text String <必传> 需要缩略的内容
dangerouslyUseInnerHTML Boolean false 是否使用 innerHTML 插入文本(警告:开启时务必确保 text 安全可靠,否则易导致 XSS 漏洞)
maxLine Number 1 最大的可见行数
maxHeight Number 最大显示高度,单位 px,优先级高于 maxLine
className String 自定义类名
ellipsis Boolean true 是否开启缩略
ellipsisNode ReactNode 自定义缩略节点
collapseNode ReactNode 折叠节点,当 ellipsis = false 时会插入内容尾部(请自行通过 onCollapseNodeClick 控制 ellipsis,组件自身不控制 ellipsis 状态)
endExcludes String[] [] 结尾处希望被过滤掉的字符(在缩略符之前)
reflowOnResize Boolean 是否自适应容器大小,原生缩略支持时默认是 true,不支持时因为有性能损耗,默认是 false,需要自适应时设置为 true
reflowThresholdOnResize Number 自适应重新编排的时间间隔,单位毫秒,默认使用 requestAnimationFrame 实现,可设置该参数降低渲染频率减少性能损耗

Methods

事件名 类型 描述
onReflow (ellipsis: Boolean, text: String) => void 重排完成回调事件。参数ellipsis表示文本是否被截断;参数text为可见文本(不包含缩略符)
onEllipsisNodeClick () => void 缩略节点点击回调事件
onCollapseNodeClick () => void 折叠节点点击回调事件

组件已经开源在 github 上,仓库地址: react-ellipsis (觉得有用可以给个 star)。

Q & A

  • 如何保障性能?

    切割算法上,其他组件大多是通过切割成字符数组后,一个个减少直到容器不再溢出,时间复杂度是 O(n)。react-ellipsis-component 使用二分切割将时间复杂度降到 O(logn)。

    计算时在原容器上计算,其他组件使用隐藏的 div 或者 canvas,当 ellipsis 组件很多时性能损耗严重。

    在自适应上,使用 ResizeObserver 实现,而其他组件使用 window.onresize(性能损耗比较大)。

0%