文本内容超过N行折叠并显示“...查看所有”

本文发布于个人我的网站: https://wintc.top/article/58,转载请注明。

多行文本超过指定行数隐藏超出部分并显示“...查看所有”是一个常遇到的需求,网上也有人实现过相似的功能,不过仍是想本身写写看,因而就写了一个Vue的组件,本文简单介绍一下实现思路。html

遇到这个需求的同窗能够尝试一下这个组件,支持npm安装使用:vue

组件地址:https://github.com/Lushenggang/vue-overflow-ellipsis
在线体验:https://wintc.top/laboratory/#/ellipsisgit

1、需求描述

        长度不定的一段文字,最多显示n行(好比3行),不超过n行正常显示;超过n行则在最后一行尾部显示“展开”或“查看所有”之类的按钮,点击按钮则展开显示所有内容,或者跳转到其它页面展现全部内容。github

        预期效果以下:npm

多行文本超过指定行数折叠

2、实现原理

        纯CSS很难完美实现这个功能,因此还得借助JS来实现,实现思路大致类似,都是判断内容是否超过指定行数,超过则截取字符串的前x个字符,而后而后和“...查看所有”拼接在一块儿,这里的x即截取长度,须要动态计算。浏览器

        想经过上述方案实现,有几个问题须要解决:异步

    • 怎样判断文字是否超过指定行数
    • 如何计算字符串截取长度
    • 动态响应,包括响应页面布局变更、字符串变化、指定行数变化等

        下面具体研究一下这些问题。函数

1. 怎样判断一段文字是否超过指定行数?

        首先解决一个小问题:如何计算指定行数的高度?我首先想到的是使用textarea的rows属性,指定行数,而后计算textarea撑起的高度。另外一个方法是将行高的计算值与行数相乘,即获得指定行数的高度,这个办法我没尝试过,可是想必可行。oop

        解决了指定行数高度的问题,计算一段文字是否超过指定行数就很容易了。咱们能够将指定行数的textarea使用绝对定位absolute脱离文档流,放到文字的下方,而后经过文本容器的底部与textarea的底部相比较,若是文本容器的底部更靠下,说明超过指定行数。这个判断能够经过getBoundingClientRect接口获取到两个容器的位置、大小信息,而后比较位置信息中的bottom属性便可。布局

        能够这样设计DOM结构:

<div class="ellipsis-container">
    <div class="textarea-container">
      <textarea rows="3" readonly tabindex="-1"></textarea>
    </div>
    {{ showContent }} <-- showContent表示字符串截取部分 --> 
    ... 查看更多
  </div>

        而后使用CSS控制textarea,使其脱离文档流而且不能被看到以及被触发鼠标事件等(textarea标签中的readonly以及tabIndex属性是必要的):

.ellipsis-container
  text-align left
  position relative
  line-height 1.5
  padding 0 !important
  .textarea-container
    position absolute
    left 0
    right 0
    pointer-events none
    opacity 0
    z-index -1
    textarea
      vertical-align middle
      padding 0
      resize none
      overflow hidden
      font-size inherit
      line-height inherit
      outline none
      border none

2.如何计算字符串截取长度x——双边逼近法(二分思想)

        只要能够判断一段文字是否超过指定行数,那咱们就能够动态地尝试截取字符串,直到找到合适的截断长度x。这个长度知足从x的位置截断字符串,前半部分+“...查看所有”等文字恰好不会超出指定行数N,可是多截取一个字,则会超出N行。最直观的想法就是直接遍历,让x从0开始增加到显示文本总长度,对于每一个x值,都计算一次文字是否超过N行,没超过则加继续遍历,超过则得到了合适的长度x - 1,跳出循环。固然也可让x从文本总长度递减遍历。

        不过这里最大的问题在于浏览器的回流和重绘。由于咱们每次截取字符串都须要浏览器从新渲染出来才能获得是否超过N行,这过程当中就触发了浏览器的重绘或回流,每次循环都会触发一次。而对于正常的需求来讲,假设N取值是3,那极可能每次计算会致使50次以上的重绘或回流,这中间消耗的性能仍是很是大的,不当心可能就是几十毫秒甚至上百毫秒。这个计算过程应该在一个任务(即常说的”宏任务“)中完成,不然计算过程当中会出现显示闪动的”异常“状况,因此能够说计算过程是阻塞的,所以计算的总时间必定要控制到很是低,即要减小计算的次数。

        能够考虑使用"双边逼近法"(或称”二分法“)查找合适的截取长度x,大大减小尝试的次数。第一次先以文本长度为截取长度,计算是否超过N行,没超过则中止计算;超过则取1/2长度进行截取,若是此时没超过N行,则在1/2长度到文本长度之间继续二分查找,若是超过则在0到1/2文本长度中继续二分查找。直到查找区间开始值与结束值相差为1,则开始值即为所求。具体实现能够看下文中的完整代码。

3.监听页面变更

        对于Vue项目来讲,传入组件的字符串、行数等可能随时改变,能够watch这些属性变化,而后从新计算一次截取长度。另外一方面,对于页面布局而言,可能会由于其它页面元素的增删或者样式改变,致使页面布局变更,影响到文本容器的宽度,此时也应该从新计算一次截取长度。

        监听文本容器宽度的变化,能够考虑使用ResizeObserver来监听,可是这个接口的兼容性不够好(IE各个版本都不支持),所以选择了一个npm库element-resize-detector来监测(很是好用👍)。

3、代码实现

        完整的代码实现以下:

<template>
  <div class="ellipsis-container">
    <div class="textarea-container" ref="shadow">
      <textarea :rows="rows" readonly tabindex="-1"></textarea>
    </div>
    {{ showContent }}
    <slot name="ellipsis" v-if="(textLength < content.length) || btnShow">
      {{ ellipsisText }}
      <span class="ellipsis-btn" @click="clickBtn">{{ btnText }}</span>
    </slot>
  </div>
</template>

<script> import resizeObserver from 'element-resize-detector'
const observer = resizeObserver()

export default {
  props: {
    content: {
      type: String,
      default: ''
    },
    btnText: {
      type: String,
      default: '展开'
    },
    ellipsisText: {
      type: String,
      default: '...'
    },
    rows: {
      type: Number,
      default: 6
    },
    btnShow: {
      type: Boolean,
      default: false
    },
  },
  data () {
    return {
      textLength: 0,
      beforeRefresh: null
    }
  },
  computed: {
    showContent () {
      const length = this.beforeRefresh ? this.content.length : this.textLength
      return this.content.substr(0, this.textLength)
    },
    watchData () { // 用一个计算属性来统一观察须要关注的属性变化
      return [this.content, this.btnText, this.ellipsisText, this.rows, this.btnShow]
    }
  },
  watch: {
    watchData: {
      immediate: true,
      handler () {
        this.refresh()
      }
    },
  },
  mounted () {
    // 监听尺寸变化
    observer.listenTo(this.$refs.shadow, () => this.refresh())
  },
  beforeDestroy () {
    observer.uninstall(this.$refs.shadow)
  },
  methods: {
    refresh () { // 计算截取长度,存储于textLength中
      this.beforeRefresh && this.beforeRefresh()
      let stopLoop = false
      this.beforeRefresh = () => stopLoop = true
      this.textLength = this.content.length
      const checkLoop = (start, end) => {
        if (stopLoop || start + 1 >= end) return
        const rect = this.$el.getBoundingClientRect()
        const shadowRect = this.$refs.shadow.getBoundingClientRect()
        const overflow = rect.bottom > shadowRect.bottom
        overflow ? (end = this.textLength) : (start = this.textLength)
        this.textLength = Math.floor((start + end) / 2)
        this.$nextTick(() => checkLoop(start, end))
      }
      this.$nextTick(() => checkLoop(0, this.textLength))
    },
    // 展开按钮点击事件向外部emit
    clickBtn (event) {
      this.$emit('click-btn', event)
    },
  }
} </script>

        在代码实现中refresh函数用于计算截取长度,在文本内容、rows属性等发生改变或者文本容器尺寸改变时将被调用。每次refresh调用会异步地递归调用屡次checkLoop,refresh可能从新调用,新的refresh调用将结束以前的checkLoop的调用。

4、其它

1. 支持HTML串的考虑

        如今的实现方案并不支持内容是HTML文本,若是须要支持HTML文本,问题将复杂许多。主要在于HTML字符串的解析和截断,不像文本字字符串那么简单。不过或许能够借助浏览器的Range API 来实现截断位置的定位,Range的insertNode以及setStart接口能够将“...查看所有”插入到指定位置,而若是插入位置恰好符合须要,则能够经过Range.cloneContents()")接口取得截取HTML字符串的相关内容,理论上是可行的,不过具体细节以及处理效率得实践后才知道。

2. 减小浏览器回流的影响

        上述实现方案中,每一次截取都须要浏览器从新渲染DOM,即重绘。重绘的影响还比较小,而若是截取的字符串行数发生改变,还会引起文本容器的高度变化,这时候就会致使浏览器回流,而文本容器在文档流中,回流将会影响整个文档。

        想解决这个问题,可使用一个脱离文档流的元素来进行字符串动态截断后的渲染与判断,布局就相似上述的textarea。由于不在文档流中,回流的影响范围就会减小到该元素自身。得到截断长度后再截断文本,渲染到真正的文本容器便可。本文仅做为一个简单的原理概述的示例,没有作这个处理,对具体细节感兴趣的同窗,能够查看github仓库代码。

相关文章
相关标签/搜索