文本內容超過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倉庫代碼。

相關文章
相關標籤/搜索