怒肝96小时,为你讲透更多文本组件的主流实现方式

835 阅读7分钟

今天讲一个比较常见的需求,更多文本组件。大致效果长这样:

在未超出设定的指定长度前,不显示展开按钮。如果超出,把超出的部分截断并,并提供展开/收起的功能。目前主流实现主要有三种思路:js不计算截断值法、js计算截断值法、纯CSS实现法。

一、js不计算截断值法

首先讲讲实现超出省略的几大问题,

问题1.如何计算出我们是否显示【展示/收起】这两个按钮

方案1.行高计算法

如果内容只有几个字,肯定是不需要【展示/收起】按钮。

我看了很多人写的方案是传字符长度来判断,这肯定不行,因为英文字符和中文字符的长度压根就不一样,这样计算肯定有误差。

正确的方法是用window.getComputedStyle这个API。它可以拿到元素的总高和行高。

具体代码如下:

judgeExpand() { 
  // 判断是否需要折叠
  this.$nextTick(() => {
    const {expand} = this;
    const textExpandStyle = window.getComputedStyle(this.$refs.textExpand)
	//获取总高度,下面的方式有bug,有可能为auto或者100%
    // const textExpandHeight = parseFloat(textExpandStyle.height)
   const textExpandHeight = this.$refs.textExpand.offsetHeight //获取总高度
   const textExpandLineHeight = parseFloat(textExpandStyle.lineHeight) //获取行高
    // 计算行高
    const rects = Math.ceil(textExpandHeight / textExpandLineHeight)
    if (rects <= expand) { // 不需要折叠展示
      this.showBtnJudge = false
    } else {
      this.showBtnJudge = true
    }
  })
}

这个方法还有个前提,你ref元素,必须设置line-height的具体数据。

line-height必填

<span class="text-expand-content" :style="expandStyle" ref="textExpand">
{{text}}
</span>
.text-expand-content {
    line-height: 20px;
}

这里的行高是必填,可以考虑拓展为props,因为这里不填,那么下面这句代码就出问题。

const textExpandStyle = window.getComputedStyle(this.$refs.textExpand)
const textExpandLineHeight = parseFloat(textExpandStyle.lineHeight) //获取行高,normal

因为这里获取的lineHeight,可能为默认值normal,这样的话我们就没法计算当前行数了。

 const rects = Math.ceil(textExpandHeight / textExpandLineHeight)

方案2.滚动高和实际高比较计算法

通常 js 的解决方式很容易,比较一下元素的 滚动高(scrollHeight) 和 实际高**(clientHeight)** 即可,然后添加相对应的类名。下面是伪代码

if (el.scrollHeight > el.clientHeight) { 
	// 文本超出了
	el.classList.add('trunk') 
} 

问题2.如何省略多行内容并加上省略号

当文本超出时,如何实现下面这张图的效果,让文本省略在几行,并且加上省略号。

方案1.css控制省略

实现这个功能比较简单,核心功能是四行代码:

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

这里说明一下几个重要参数。

-webkit-box-orient: vertical

这个属性的意思是:从顶部向底部垂直布置子元素。

值得一提的是,CSS中-webkit-box-orient: vertical属性,在编译后丢失问题。f12看了下,原来是-webkit-box-orient: vertical;这个属性丢失,导致了不生效,于是断定是编译过程导致这个属性丢失。

解决办法

1.在Styles里把这个属性加上就好了。

2.把autoprefixer关掉,网上可行的解决方案如下

/* autoprefixer: ignore next */
-webkit-box-orient: vertical;

-webkit-line-clamp: 3;

这个属性的意思就是,超过多少行就开始省略,这个属性很重要。

我们准备加一个props来控制-webkit-line-clamp的省略函数。

this.style=`display: -webkit-box;word-break: break-all;-webkit-line-clamp: ${this.expand};-webkit-box-orient: vertical;text-overflow: ellipsis;overflow: hidden;`

实现代码

VUE组件全部代码如下:

<template>
  <div class="text-expand">
    <div v-if="!(showPopover && showPopoverJudge)">
      <span class="text-expand-content" :style="expandStyle" ref="textExpand">
        {{ (text === null || text === undefined || text === '') ? '--' : text }}
      </span>
      <div class="expander">
        <span
          v-if="showBtn && showBtnJudge"
        >
          <span
            v-if="!showFull"
            class="action action-expand"
            @click.stop="showFullFn(true)"
          >
            展开
            <i v-if="showBtnIcon" class="iconfont iconxiajiantou"/>
          </span>
          <span
            v-else
            class="action action-pack"
            @click.stop="showFullFn(false)"
          >
            收起
            <i v-if="showBtnIcon" class="iconfont iconshangjiantou"/>
          </span>
        </span>
      </div>
    </div>
    <el-popover
      v-else
      :placement="popoverPlace"
      trigger="hover">
      <div class="popover-content">
        {{ text }}
      </div>
      <span class="text-expand-content" :style="expandStyle" slot="reference">{{ text }}</span>
    </el-popover>
  </div>
</template>
<script>
  export default {
    name: "TextExpand",
    props: {
      text: { // 文本内容
        type: String,
        default: () => ''
      },
      expand: { // 折叠显示行数
        type: Number,
        default: () => 3
      },
      showBtn: { // 展开、折叠按钮
        type: Boolean,
        default: true
      },
      showBtnIcon: { // 展开、折叠icon
        type: Boolean,
        default: true
      },
      showPopover: { // popover显示全文本
        type: Boolean,
        default: false
      },
      popoverPlace: { // popover位置
        type: String,
        default: 'bottom'
      }
    },
    data() {
      return {
        showFull: false, // 是否展示全文本
        expandStyle: '',
        showBtnJudge: false, // 判断是否需要折叠展示按钮
        showPopoverJudge: false // 判断是否需要折叠展示popover
      }
    },
    watch: {
      text: function (val) {
        this.judgeExpand()
      }
    },
    mounted() {
      this.judgeExpand()
    },
    methods: {
      showFullFn(value) {
        this.expandStyle = value ? '' : `display: -webkit-box;word-break: break-all;-webkit-line-clamp: ${this.expand};-webkit-box-orient: vertical;text-overflow: ellipsis;overflow: hidden;`
        this.showFull = value
      },
      judgeExpand() { // 判断是否需要折叠
        this.$nextTick(() => {
          const {expand} = this;
          const textExpandStyle = window.getComputedStyle(this.$refs.textExpand)
          //获取总高度,此方式有bug,有可能为auto或者100%
          // const textExpandHeight = parseFloat(textExpandStyle.height)
          const textExpandHeight = this.$refs.textExpand.offsetHeight //获取总高度
          const textExpandLineHeight = parseFloat(textExpandStyle.lineHeight) //获取行高
          // 计算行高
          const rects = Math.ceil(textExpandHeight / textExpandLineHeight)
          if (rects <= expand) { // 不需要折叠展示
            this.showBtnJudge = false
            this.showPopoverJudge = false
          } else {
            this.showBtnJudge = true
            this.showPopoverJudge = true
            this.expandStyle = `display: -webkit-box;word-break: break-all;-webkit-line-clamp: ${this.expand};-webkit-box-orient: vertical;text-overflow: ellipsis;overflow: hidden;`
          }
        })
      }

    }
  }
</script>
<style lang="less" scoped>
  .text-expand {
    &-content {
      line-height: 20px;
      word-break: break-all;
      white-space: pre-wrap;
    }

    .expander {
      text-align: left;
      margin-top: 6px;

      .action {
        display: inline-block;
        font-size: 14px;
        color: #0281F0;
        cursor: pointer;

        i {
          display: inline;
          font-size: 12px;
        }
      }

      .action.action-pack {
        margin-left: 0;
      }
    }
  }

  .popover-content {
    max-width: 40vw;
    max-height: 30vh;
    overflow: hidden;
    word-break: break-all;
    overflow-y: auto;
  }
</style>

代码如上所述,这里聊一些注意事项。

窗口变化时需要监听

而且你还需要监听resize API(当你的容器宽高变化时,重新计算),还需要在插槽内容变化时重新执行上面的算法。

实现效果:

上面这个方案有缺陷,那就更多跟收齐按钮的位置是在文字下面的,UI肯定希望你把更多按钮放在省略号的右边,而不是下边。

这里就涉及到截断值的问题。也就是方案2:利用JS计算截断值,把文字一直删减,删到dom的高度在最大高度内为止。我见到有人实现是一个个字符减的,这样太消耗性能了,网上看到一个组件库的源码,是用二分法来计算截断长度的,这算是目前JS处理的最佳方案了,下面详细讲讲:

二、js二分法计算截断值

默认使用 js二分法 不断进行截断计算从而得到省略临界值,同时 resize 时还会多次触发重新计算。所以在大量使用对性能影响较大,但此方法不会在排版组件下插入额外样式dom。

实现的主要算法是这样的,首先把你的全部文本放在一个fix定位的dom里,这个dom主要是为了计算你比如想保留两行,那么这两行的高度和总高度是多少,用的computedstyle这个api。在此之前还要把你的省略号和展开收起文字放进去一起算高度,当然这个计算的dom只是为了计算,是不让你看见的,所以top是-99999。

然后用二分法,去找截断文字到底有多少,每次拿一半的文字的高度去对比你想要的保留文字比如两行的高度,如果你两行的高度比一半文字高度低,说明我要找的截断文字在0到一半文字之间,就继续二分,这样算法复杂度是o的logn。

而且你还需要监听resize api,当你的容器宽高变化时,继续执行上面的算法。

有兴趣可以看看arco design的typography组件的源码,贴在下面了:

export default (
  originElement: HTMLElement,
  ellipsisConfig: EllipsisInternalConfig,
  operations: VNodeTypes | VNodeTypes[],
  fullText: string
) => {
  if (!ellipsisContainer) {
    ellipsisContainer = document.createElement('div');
    document.body.appendChild(ellipsisContainer);
  }

  const { rows, suffix, ellipsisStr } = ellipsisConfig;

  const originStyle = window.getComputedStyle(originElement);
  const styleString = styleToString(originStyle);
  const lineHeight = pxToNumber(originStyle.lineHeight);
  const maxHeight = Math.round(
    lineHeight * rows +
      pxToNumber(originStyle.paddingTop) +
      pxToNumber(originStyle.paddingBottom)
  );
    
  //创建一个你看不见的div,用于二分法计算截断值

  ellipsisContainer.setAttribute('style', styleString);
  ellipsisContainer.setAttribute('aria-hidden', 'true');

  ellipsisContainer.style.height = 'auto';
  ellipsisContainer.style.minHeight = 'auto';
  ellipsisContainer.style.maxHeight = 'auto';
  ellipsisContainer.style.position = 'fixed';
  ellipsisContainer.style.left = '0';
  ellipsisContainer.style.top = '-99999999px';
  ellipsisContainer.style.zIndex = '-200';

  
  const vm = createApp({
    render() {
      return <span>{operations}</span>;
    },
  });

  vm.mount(ellipsisContainer);

  const operationsChildNodes = Array.prototype.slice.apply(
    ellipsisContainer.childNodes[0].cloneNode(true).childNodes
  );

  vm.unmount();
  ellipsisContainer.innerHTML = '';

  // 省略号和后缀
  const ellipsisTextNode = document.createTextNode(`${ellipsisStr}${suffix}`);
  ellipsisContainer.appendChild(ellipsisTextNode);

  // 操作按钮
  operationsChildNodes.forEach((operationNode) => {
    ellipsisContainer.appendChild(operationNode);
  });

  // 内容
  const textNode = document.createTextNode(fullText);
  ellipsisContainer.insertBefore(textNode, ellipsisTextNode);

  //判断当前div的高度是否超出最大值
  function inRange() {
    return ellipsisContainer.offsetHeight <= maxHeight;
  }

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

  // 二分法:寻找最多的文字
  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()) {
      measureText(textNode, midLoc, endLoc, midLoc);
    } else {
      measureText(textNode, startLoc, midLoc, lastSuccessLoc);
    }
  }

  measureText(textNode);

  //返回满足条件的截断后文本。
  return {
    text: textNode.textContent,
    ellipsis: true,
  };
};

最后,文中实现思路来自于arco design的typography组件。链接:arco.design/vue/compone…

三、CSS样式方案

我知道,把展开放在文本后面才是大家想要的效果。

通过 CSS样式 进行省略展示,对于大量使用场景下会有显著性能提高。主要实现思路详见文章:juejin.cn/post/696390…

下面给出我根据文章代码封装的组件代码,大家可以直接拿去用:

<template>
  <div class="wrapper">
    <input id="exp" class="exp" type="checkbox">
    <div class="text">
      <label class="btn" for="exp"></label>
      <slot></slot>
    </div>
  </div>
</template>

<script>
  export default {
    name: 'CssMoreText',
    props: {
      //省略的行数
      rows: {
        type: Number,
        default: 2
      },
    },
  }
</script>

<style lang="less" scoped>
  .wrapper {
    display: flex;
    margin: 50px auto;
    width: 800px;
    overflow: hidden;
    border-radius: 8px;
    padding: 15px;
    box-shadow: 20px 20px 60px #bebebe,
      -20px -20px 60px #ffffff;
  }

  .text {
    font-size: 20px;
    overflow: hidden;
    text-overflow: ellipsis;
    text-align: justify;
    /* display: flex; */
    display: -webkit-box;
    -webkit-line-clamp: 3;
    -webkit-box-orient: vertical;
    position: relative;
  }

  .text::before {
    content: '';
    height: calc(100% - 24px);
    float: right;
  }

  .text::after {
    content: '';
    width: 999vw;
    height: 999vw;
    position: absolute;
    box-shadow: inset calc(100px - 999vw) calc(30px - 999vw) 0 0 #fff;
    margin-left: -100px;
  }

  .btn {
    float: right;
    clear: both;
    margin-left: 10px;
    font-size: 16px;
    padding: 0 8px;
    background: #3F51B5;
    line-height: 24px;
    border-radius: 4px;
    color: #fff;
    cursor: pointer;
    /* margin-top: -30px; */
  }

  .btn::before {
    content: '展开'
  }

  .exp {
    display: none;
  }

  .exp:checked + .text {
    -webkit-line-clamp: 999;
  }

  .exp:checked + .text::after {
    visibility: hidden;
  }

  .exp:checked + .text .btn::before {
    content: '收起'
  }
</style>

使用:

<css-more-text :rows="3">
  浮动元素是如何定位的
  正如我们前面提到的那样,当一个元素浮动之后,它会被移出正常的文档流,然后向左或者向右平移,一直平移直到碰到了所处的容器的边框,或者碰到另外一个浮动的元素。
  在下面的图片中,有三个红色的正方形。其中有两个向左浮动,一个向右浮动。要注意到第二个向左浮动的正方形被放在第一个向左浮动的正方形的右边。如果还有更多的正方形这样浮动,它们会继续向右堆放,直到填满容器一整行,之后换行至下一行。
</css-more-text>

效果:

四、超出省略实现方案总结

超出省略目前通过两种方式实现分别是 js二分法计算截断值js不计算临界值法CSS超出省略 三种优缺点如下:

指标js计算截断值(二分法)js不计算截断值法(另起一行)CSS省略
性能差(二分法会多次操作dom,影响性能)
功能一般(样式有缺陷,展开/收起按钮必须另起一行)差(只支持字符串)