本文發佈於個人我的網站: https://wintc.top/article/58,轉載請註明。
多行文本超過指定行數隱藏超出部分並顯示「...查看所有」是一個常遇到的需求,網上也有人實現過相似的功能,不過仍是想本身寫寫看,因而就寫了一個Vue的組件,本文簡單介紹一下實現思路。html
遇到這個需求的同窗能夠嘗試一下這個組件,支持npm安裝使用:vue
組件地址:https://github.com/Lushenggang/vue-overflow-ellipsis
在線體驗:https://wintc.top/laboratory/#/ellipsisgit
長度不定的一段文字,最多顯示n行(好比3行),不超過n行正常顯示;超過n行則在最後一行尾部顯示「展開」或「查看所有」之類的按鈕,點擊按鈕則展開顯示所有內容,或者跳轉到其它頁面展現全部內容。github
預期效果以下:npm
純CSS很難完美實現這個功能,因此還得藉助JS來實現,實現思路大致類似,都是判斷內容是否超過指定行數,超過則截取字符串的前x個字符,而後而後和「...查看所有」拼接在一塊兒,這裏的x即截取長度,須要動態計算。瀏覽器
想經過上述方案實現,有幾個問題須要解決:異步
下面具體研究一下這些問題。函數
首先解決一個小問題:如何計算指定行數的高度?我首先想到的是使用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
只要能夠判斷一段文字是否超過指定行數,那咱們就能夠動態地嘗試截取字符串,直到找到合適的截斷長度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,則開始值即爲所求。具體實現能夠看下文中的完整代碼。
對於Vue項目來講,傳入組件的字符串、行數等可能隨時改變,能夠watch這些屬性變化,而後從新計算一次截取長度。另外一方面,對於頁面佈局而言,可能會由於其它頁面元素的增刪或者樣式改變,致使頁面佈局變更,影響到文本容器的寬度,此時也應該從新計算一次截取長度。
監聽文本容器寬度的變化,能夠考慮使用ResizeObserver來監聽,可是這個接口的兼容性不夠好(IE各個版本都不支持),所以選擇了一個npm庫element-resize-detector來監測(很是好用👍)。
完整的代碼實現以下:
<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的調用。
如今的實現方案並不支持內容是HTML文本,若是須要支持HTML文本,問題將複雜許多。主要在於HTML字符串的解析和截斷,不像文本字字符串那麼簡單。不過或許能夠藉助瀏覽器的Range API 來實現截斷位置的定位,Range的insertNode以及setStart接口能夠將「...查看所有」插入到指定位置,而若是插入位置恰好符合須要,則能夠經過Range.cloneContents()
")接口取得截取HTML字符串的相關內容,理論上是可行的,不過具體細節以及處理效率得實踐後才知道。
上述實現方案中,每一次截取都須要瀏覽器從新渲染DOM,即重繪。重繪的影響還比較小,而若是截取的字符串行數發生改變,還會引起文本容器的高度變化,這時候就會致使瀏覽器迴流,而文本容器在文檔流中,迴流將會影響整個文檔。
想解決這個問題,可使用一個脫離文檔流的元素來進行字符串動態截斷後的渲染與判斷,佈局就相似上述的textarea。由於不在文檔流中,迴流的影響範圍就會減小到該元素自身。得到截斷長度後再截斷文本,渲染到真正的文本容器便可。本文僅做爲一個簡單的原理概述的示例,沒有作這個處理,對具體細節感興趣的同窗,能夠查看github倉庫代碼。