我来终结多行文本省略号组件的算法吧!

前言

经常在掘金看到很多同学写多行文本省略如何处理,很多都用的css,这些方案或多或少有一些兼容性和局限性。

我今年的计划是实现react pc端和移动端组件库(主要是借(chao)鉴(xi)各个比较成熟的库),当看到ant design和 arco design的 Typography 组件时,其中就包括了多行文本省略的处理,为此我看了源码(还发一下一个bug给arco desgin提了一个pr合了,哈哈),把这个算法跟大家好好讲一下。

核心目标

比如我们的组件是这样使用的

<Text ellipsis={{ rows: 2 }}>
      {mockText}
</Text>
复制代码
  • ellipsis是多行省略的配置项,
    • 其中rows:2表示省略两行
  • mockText 表示你的全量的文本,比如可能有100行

算法核心思想

我们需要解决的问题:

  • 截断两行,所以就要找到这两行到底包括哪些文字,我们需要截断它!
    • 注意:需要考虑resize事件,当容器宽度因为你缩放浏览器导致其宽高变化,那么省略两行的文字也会有变化
  • 如何要计算截断两行的文字的个数

这个计算到底两行多少字符的难点在于,容器的font-Size不一样,字体不一样,字符数就不一样。所以计算多少个文字正好占两行,我们需要转化思路。

我们用一个案例来讲转化的思路:假如我们这么用组件

<Text ellipsis={{ rows: 2 }}>
      {这里是66个字符}
</Text>
复制代码
  • 假设文字有66个,文本的line-height都是22px(暂且忽略padding和margin),我们要超过两行就...打省略。
  • 好,我们可以从高度去判断,两行高度就是22 * 2 = 44px。
  • 我们也知道所有文本的总高度,可以用获得getComputedStyle这个API,获得其dom的scrollHight,也就是文本总高度,获取方法:
window.getComputedStyle(dom元素的ref)
复制代码
  • 在开启我们的算法之前,我们需要判断当前文字的总高度是否已经是小于等于44px,如果是,你这段文字是不是都不满足省略号的情况了,你都不够省略的。

  • 接下来,就是必有省略号的情况了。

  • 我们可以获取65个字符的总高度,看看scrollHight等不等于44px(两行lineHeight的高度),如果等于,说明我们找到了两行文字的最后一个文字,因为66个字符的总高度大于44px,65就等于了,是不是就找到了。

  • 如果65个字符总高度还是大于44px,那么我们就接着所以缩小范围,看看64个字符的总高度是多少,以此类推,我,63,62,61,61...,我总能找到边界字符,然后这些字符的高度是44px了吧。

好了,上面直到截断在哪了,是不是省略号的事情也迎刃而解了。

上面的算法还是有些问题:

  • 我们省略号本身也有宽度,展开收起的文字也有宽度,所以我们事先要把这个宽度算进去,也就是把这些字符加入到dom里面

  • 我们如何计算66,65,64...这些字符的高度呢,不可能直接显示在页面上66个字符,一看不对,不是两行,删掉一个计算65个字符高度,这样用户还不疯了,所以我们需要一个存在页面中,只是为了计算高度,而不显示在页面中dom元素

    • 解决办法很简单,就是创建一个dom,并且style是fixed,top:-99999px,算完以后把这个dom删掉即可
  • 挨个计算真的很低效,假如有1万个字符,那要算1万边啊。

我们换一个算法,二分查找,也就是,刚开始我们计算一半文字的高度,假设这一半文字的高度大于44px,那说明边界的那个挨着省略号的最后一个文字,就在这一半里。

好,我们接着二分,从上面的一半文字里,再获取一半文字,假设这一半文字的高度小于等于44px,说明这一半文字不够啊,真正等于44px的文字在另一半文字里。

依次类推,二分法能帮我们快速找到边界的那个文字,算法时间复杂度是nlogn,性能还是不错的。

我们举个例子,看看这个二分法的流程:

// 假设全部文字如下:
// 通过一定的css样式配置让其显示为3行

<div id="w">
abcd
efgh
ijkl
</div>
复制代码

我们要保留两行那么就是(暂且忽略省略号和展开按钮,方便理解):

<div id="w">
adcd
efgh
</div>
复制代码

算法简易函数如下:

const textNode = document.getElementById('w');
const originStyle = window.getComputedStyle(textNode);
const fullText = textNode.innerText;
// pxToNumber是字符串比如12px,转换为number12的函数
const lineHeight = pxToNumber(originStyle.lineHeight); 
// 假设保留两行
const rows = 2;
const maxHeight = Math.round( lineHeight * rows + pxToNumber(originStyle.paddingTop) + pxToNumber(originStyle.paddingBottom) );
// 判断当前的文字高度是否超过两行的高度
const inRange  = function(){
    return textNode.scrollHeight <= maxHeight;
}
// 二分法查找两行文字中最后一个文字的位置
function measureText(textNode, startLoc = 0, endLoc = fullText.length) {
    const midLoc = Math.floor((startLoc + endLoc) / 2);
    const currentText = fullText.slice(0, midLoc);
    textNode.textContent = currentText;

    if (startLoc >= endLoc - 1) {
      for (let step = endLoc; step >= startLoc; step -= 1) {
        const currentStepText = fullText.slice(0, step);
        textNode.textContent = currentStepText;

        if (inRange() || !currentStepText) {
          return;
        }
      }
    }

    if (inRange()) {
      return measureText(textNode, midLoc, endLoc);
    }
    return measureText(textNode, startLoc, midLoc);
  }
measureTex(textNode, 0, fullText.length);
复制代码

还有,注意一点,我们不需要考虑下面这种形式:

<Text ellipsis={{ rows: 2 }}>
      123<span style="fontSzie: 12px">ab<span style="fontSzie: 22px">34</span>
</Text>
复制代码

因为我们实际的算法处理会提取所有文字,统一fontSize。 接下来,我们看一下框架源码的实际代码,我会把注释写上,这个其实不用看,不容易看明白,只是说为了有些同学想探究其开发环境代码的写法,附上而已。

打个小广告,今年我会有一个大题材,就是手把手教你实现一个可以开源级别的react pc和移动端组件库,包含:

  • 组件库打包,开发,测试,自动化push仓库(包括修改changeLog文件和打tag)的cli工具(已完成100%)
  • 组件库的icon包,也就是所有icon集合在一个服务的npm包里,专属于你们的项目。
  • 组件库网站搭建(自己写,不是用storybook、dumi或者docz)
  • pc端组件库(包括ant所有组件和功能,主要是借鉴其源码,也可以说是源码分析)
  • 移动端组件库(主要借鉴的是zarm,众安的一个react组件库)

组件库可以说是源码分析文章(我会在开源库源码的基础上把代码的质量再提高一个水平)。

import * as React from 'react';
// unmountComponentAtNode是卸载dom元素的react API
import { render, unmountComponentAtNode } from 'react-dom';
// mergedToString是把其参数的所有文字提取出来的方法
import mergedToString from '../_util/mergedToString';

// 这个是合并style的方法,很简单
function styleToString(style: CSSStyleDeclaration, extraStyle: { [key: string]: string } = {}) {
  const styleNames: string[] = Array.prototype.slice.apply(style);
  const styleString = styleNames
    .map((name) => `${name}: ${style.getPropertyValue(name)};`)
    .join('');
  const extraStyleString = Object.entries(extraStyle)
    .map(([key, value]) => `${key}: ${value};`)
    .join('');
  return styleString + extraStyleString;
}

// 这个是px转number的函数
function pxToNumber(value: string | null): number {
  if (!value) return 0;

  const match = value.match(/^\d*(\.\d*)?/);

  return match ? Number(match[0]) : 0;
}

// 镜像dom,也就是我们之前说的用来计算高度的dom
let mirrorElement: HTMLElement;

export function measure(
  // 这个是指容器元素的dom引用,也就是包裹我们文字的容器,我们需要它算总高度srcollHeight
  // 注意这个是已经包裹全部文字的dom引用,所以能计算总高度
  originElement: HTMLElement,
  // 这个是配置化的省略信息的,我们下面遇到了再解释其中的api
  ellipsisConfig,
  // 这个是渲染省略号和展开收起文字的组件,我们上面说了,要把他们也算进去,因为"...展开"本身也占宽度
  operations,
  // 包裹的文字信息
  children,
) {
  //  这个指的是具体省略几行,默认是省略1行 
  const rows = ellipsisConfig.rows || 1;
  // 省略我们默认用“...”,但也可以自定义配置省略的符号
  const ellipsisStr = ellipsisConfig.ellipsisStr !== undefined ? ellipsisConfig.ellipsisStr : '...';

  // 创建我们用于计算高度的dom,并加入到页面中
  if (!mirrorElement) {
    mirrorElement = document.createElement(originElement.tagName);
    document.body.appendChild(mirrorElement);
  }
  // 用来计算包裹容器的最终css属性
  const originStyle = window.getComputedStyle(originElement);

 // 用来隐藏我们创建的包裹容器的css样式,这个是很讲究的,我们注意看注释
  const extraStyle = {
    // 这里不清楚为啥要强制把height设为auto,因为不设不设都不影响scrollHeight的值
    height: 'auto', 
    // min-height和max-height也是不清楚,估计是浏览器兼容性问题
    'min-height': 'auto',
    'max-height': 'auto',
    left: '0',
    top: '-99999999px',
    position: 'fixed',
    'z-index': '-200',
    'white-space': 'normal',
    'text-overflow': 'clip',
    overflow: 'auto',
  };
  // style合并
  const styleString = styleToString(originStyle, extraStyle);
  mirrorElement.setAttribute('style', styleString);
  mirrorElement.setAttribute('aria-hidden', 'true');
  // 假如省略号和展开按钮的dom,方便计算宽高
  render(<span>{operations}</span>, mirrorElement);
    
   // 这个不重要,忽略,因为你要看opration具体生成的代码才能理解
  const operationsChildNodes = Array.prototype.slice.apply(
    mirrorElement.childNodes[0].cloneNode(true).childNodes
  );
 // 剩下的代码就是我们上面举例简易二分法的思路了
  const fullText = mergedToString(React.Children.toArray(children));
  unmountComponentAtNode(mirrorElement);
  mirrorElement.innerHTML = '';

  const ellipsisTextNode = document.createTextNode(`${ellipsisStr}${suffix}`);
  mirrorElement.appendChild(ellipsisTextNode);
  operationsChildNodes.forEach((childNode) => {
    mirrorElement.appendChild(childNode);
  });

  const textNode = document.createTextNode(fullText);
  mirrorElement.insertBefore(textNode, mirrorElement.firstChild);

  const lineHeight = pxToNumber(originStyle.lineHeight);
  const maxHeight = Math.round(
    lineHeight * rows + pxToNumber(originStyle.paddingTop) + pxToNumber(originStyle.paddingBottom)
  );

  function emptyMirrorElem() {
    mirrorElement.setAttribute('style', 'display: none');
    mirrorElement.innerHTML = '';
  }

  function inRange() {
    return mirrorElement.scrollHeight <= maxHeight;
  }

  if (inRange()) {
    unmountComponentAtNode(mirrorElement);
    emptyMirrorElem();
    return { text: fullText, ellipsis: false };
  }

 
  function measureText(textNode: Text, startLoc = 0, endLoc = fullText.length, lastSuccessLoc = 0) {
    const midLoc = Math.floor((startLoc + endLoc) / 2);
    const currentText = fullText.slice(0, midLoc);
    textNode.textContent = currentText;

    if (startLoc >= endLoc - 1) {
      for (let step = endLoc; step >= startLoc; step -= 1) {
        const currentStepText = fullText.slice(0, step);
        textNode.textContent = currentStepText;

        if (inRange() || !currentStepText) {
          return;
        }
      }
    }

    if (inRange()) {
      return measureText(textNode, midLoc, endLoc, midLoc);
    }
    return measureText(textNode, startLoc, midLoc, lastSuccessLoc);
  }

  measureText(textNode);
  const ellipsisText = textNode.textContent;

  emptyMirrorElem();
  return {
    text: ellipsisText,
    ellipsis: true,
  };
}
复制代码

Guess you like

Origin juejin.im/post/7068885729492860965