來源:https://wintc.top/article/58
多行文本超過指定行數隱藏超出部分並顯示“...查看全部”是一個常遇到的需求,網上也有人實現過類似的功能,不過還是想自己寫寫看,於是就寫了一個Vue的組件,本文簡單介紹一下實現思路。
遇到這個需求的同學可以嘗試一下這個組件,支持npm安裝使用:
組件地址:https://github.com/Lushenggang/vue-overflow-ellipsis
在線體驗:https://wintc.top/laboratory/#/ellipsis
一、需求描述
長度不定的一段文字,最多顯示n行(比如3行),不超過n行正常顯示;超過n行則在最后一行尾部顯示“展開”或“查看全部”之類的按鈕,點擊按鈕則展開顯示全部內容,或者跳轉到其它頁面展示所有內容。
預期效果如下:
二、實現原理
純CSS很難完美實現這個功能,所以還得借助JS來實現,實現思路大體相似,都是判斷內容是否超過指定行數,超過則截取字符串的前x個字符,然后然后和“...查看全部”拼接在一起,這里的x即截取長度,需要動態計算。
想通過上述方案實現,有幾個問題需要解決:
-
- 怎樣判斷文字是否超過指定行數
- 如何計算字符串截取長度
- 動態響應,包括響應頁面布局變動、字符串變化、指定行數變化等
下面具體研究一下這些問題。
1. 怎樣判斷一段文字是否超過指定行數?
首先解決一個小問題:如何計算指定行數的高度?我首先想到的是使用textarea的rows屬性,指定行數,然后計算textarea撐起的高度。另一個方法是將行高的計算值與行數相乘,即得到指定行數的高度,這個辦法我沒嘗試過,但是想必可行。
解決了指定行數高度的問題,計算一段文字是否超過指定行數就很容易了。我們可以將指定行數的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來監測(非常好用👍)。
三、代碼實現
完整的代碼實現如下:
<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() } }