前端文本截斷

誤區

在設計產品時,由於不少產品經理、工程師並沒有「字符不一定等寬」的概念,往往會給出「超過 n 個字符截斷顯示,英文數字算一個字符,漢字算兩個字符」這樣的需求。要知道,這裏面的問題有很多:

爲了顯示效果,前端往往會採用優先西文字體族的 font-family 設置,即西文字符用西文字體,漢字用中文字體,這就很容易使得文本的寬度不好根據字符數來控制。首先,非代碼的內容本身就不一定適合用等寬西文字體顯示。其次即使用了等寬西文字體,漢字也基本不可能正好是其兩倍寬。滿足這個需求的,只能放棄西文字體,讓西文字符也使用中文字體,並且使用中易系列的幾個字體了(比如 SimSun,也就是 Windows 下的「宋體」)。(醜不說,還只能滿足 Windows 下的需求。)

這種需求甚至在很多時候還會和某些字符編碼長度的概念產生混淆,催生「長度限制 n 個字節,其中英文數字算 1 字節、漢字算 2 字節」這樣的奇葩說法。

順便歪個樓,這種「西文等寬、漢字佔兩倍寬度」的需求正常情況下只會存在於程序員的代碼編輯器裏。如果你是這種強迫症晚期,又不想用中易宋體,可以考慮試試 Belleve 製作的 Inziu

思路和原理

對於前端來說,數據庫存儲的限制不應該是我們需要關心的問題。看下前面的「僞需求」,我們實際的需求往往是從視覺角度出發的「超出特定高度截斷顯示」或「超出特定行數階段顯示」兩種。由於實現方式的差異,其實可以分爲「單行截斷」、「多行截斷」、「按高度截斷」幾種。從成本和效果來看,有「實現難度」、「效果精確度」、「對內容是否有限制」、「是否能響應頁面變化」這些需要考慮的細節。本文裏不準備列各種實現的代碼,僅談談一些相關的問題和思路。

要看一些現有的實現方案可以看這幾篇:

text-overflow: ellipsis

我想這個沒有什麼好多說的,自從 Firefox 7 開始支持這個 CSS 屬性以後,這已經成爲了 99% 情況下實現單行文本截斷的不二之選。實現難度幾乎爲零、截斷效果精準、內容中也可以有圖片、鏈接等其他內容,而且在寬度變化時能夠自動響應,兼容性也非常好(當然在低版本 IE 下可能會遇到一些需要額外套一層元素的特殊情況)。要支持 Firefox 7 以下的版本怎麼辦?儘量把需求拍回去吧。實在不行再考慮別的方案。

但是如果附加上其他的需求,純 CSS 的方案可能也有不能滿足的情況。比如有時候我們可能想僅在文字被截斷時纔在鼠標移入後通過浮層顯示全部文本,又有時行末有不能被截掉的但寬度不定的內容。

計算內容寬度

百度以前的 Tangram 庫在 1.x 版本中有一個 textOverflow 方法,會根據給定的寬度對單行文本進行截斷。大致的做法是計算每個字符的寬度,找到加上 ... 正好小於指定寬度的邊界,然後截去後續字符。爲了提高性能,預先計算並緩存了 ASCII 字符(不等寬)的寬度和一個漢字(漢字等寬)的寬度,其他字符再實時去計算雲盤搜索。計算寬度時是在指定元素內添加了一個 div 元素,並繼承了原元素的所有文字排版相關的 CSS 屬性。但事實上如果內容中本來就混雜了各種不同樣式的文本,計算起來可能並不準確(比如有 div:first-child::first-letter 上的樣式)。這個方案當時是兼容所有瀏覽器的,但是處理的內容基本只能是純文本,而且完備性也有一定缺陷。

同樣,如果利用 scrollWidth 來判斷內容是否橫向溢出也是可行的,可以在溢出時不斷截掉尾部的內容,直到剩餘內容加上省略號可以完整顯示。實現起來應該比前一種方案更簡潔一些,也更準確,但前一種方案預先計算完寬度後截取內容時不需要再實時讀取 UI 上的確切寬度,所以性能要比這種高一些。

計算內容行數

在 WebKit 瀏覽器下實現限制顯示行數可以使用非標準實現 -webkit-line-clamp 這個 CSS 屬性,這個也是大家熟知的。在移動端應用的場景可能還多一些,桌面端很難只支持 WebKit 瀏覽器。當 CSS 無法直接解決這個問題時,用 JavaScript 如何解決這個問題呢?

比較容易想到的是用高度除以行高,在不給定行高的情況下,需要通過 getComputedStyle 來獲取實際行高。但當 line-height取默認值時計算值爲 normal,數值並不一定是確定值。所以通過 line-height 進行計算適用於自行指定行高數值的場景。例如在Clamp.js 中,對 normal 值就是假設所有瀏覽器默認值爲 1.2的來處理。更別說可能有超出行高的圖片等內容,使得高度並非行高乘以行數。

除此之外,據我所知可以用來比較精確地判斷內容行數的方法主要有下面兩個。這類方法的特點是行高並不需要是一個固定值,比如中間有內嵌的圖標改變了行高。暫且不討論限定不確定高度的行數本身是否合理(因爲我們顯示內容時高度的限制往往並非來源於行數,而是來源於高度的限制),來看看具體的做法。

利用 Element.getClientRects()

根據測試,在 IE8+ 及其他現代瀏覽器下這個方法對於 display: inline 的元素有一個特性:調用結果返回的 DOMRectList 對象的 length 等於元素渲染後的行數。這樣,我們可以把需要計算行數的內容放在一個 display: inline 的容器內(比如原來是<p> 元素內的文本,現在更改爲 p > span 這種結構),對該 <span> 元素調用 elem.getClientRects().length 即可獲得行數。

可是目前在 WebKit 下,有一個疑似的 bug:當這個 display: inline 的容器內有子元素,getClientRects 的結果會包含這些子元素的輪廓,導致計數錯誤。既然規範並沒有詳細描述這個方法的計算邏輯,爲什麼說是一個疑似 bug 呢?因爲當給容器加上一些特定的樣式,計算結果又會和我們預期的結果相符了。詳情可參考這個 issue 和 demo

利用 Selection.modify()

這是一個非標準的 DOM 接口,但是 WebKit 和 Gecko 都進行了實現(IE/Edge 都不支持)。

大致原理是:當我們把選區定位到某個元素的開頭,然後執行

selection.modify('extend', 'forward', 'lineboundary');

可以把選區擴展到一行的末尾,然後再用

selection.modify('extend', 'forward', 'character');

往後擴展一個字符,如果此時的 selection.focusNode 還在容器內,且 selection.focusOffset 有變化,說明下一行還有內容。循環往復就可得到指定元素的「行數」。

在瀏覽器兼容性上,顯然這個方法也有較大的侷限,僅比 CSS 方法多支持了 Firefox 而已。但比上一個方法的好處在於,由於可以立刻找到折行的字符位置,所以截取時不需要通過截調末尾內容反覆重試行數。

計算內容高度

給容器指定高度以後,通過比較 scrollHeight 和 clientHeight 可以方便地測試元素內容的高度是否溢出容器範圍。如果超出了指定高度,反覆截去尾部內容直到不再溢出。

截取內容

如果內容是純文本,那麼很簡單,依次刪除末尾字符,再檢查內容是否超出寬度/行數/高度限制就行了。文本較長的話可以用二分法優化一下執行效率。同時如果緩存下內容,可以在內容區域寬度變大時,根據情況來重新填入之前截取掉的文本,做到類似 CSS 的自適應效果。

而如果內容中有其他的 HTML 元素,事情就沒這麼好辦了。可行的方法是,始終找到剩餘內容最後的葉子節點,如果是文本節點,刪除末尾字符;否則直接移除該節點。寬度變大時如果要恢復之前的內容就沒這麼簡單了,首先要保留之前所有移除元素的引用(因爲上面可能有事件監聽),然後文本可以重新填入,元素節點也要按之前刪除前的 DOM 結構重新恢復。那麼在之前移除時我們可能就需要記錄每一步的操作,恢復時逆向執行回來。理論上是可行的,實現起來可能會複雜一些。

總結

可以看到,基於 CSS 的方案非常精確,而且在頁面佈局變化、瀏覽器視口大小變化時更容易響應,但只能滿特定的場景。用 JS 的方案在靈活性上有時更勝一籌,但要做的工作就多了很多。而且如果需要處理的內容很多,用 JS 的方法可能會帶來性能瓶頸,畢竟一般讀取 UI 實際顯示樣式的接口調用代價都比較大。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章