誤區
在設計產品時,由於不少產品經理、工程師並沒有「字元不一定等寬」的概念,往往會給出「超過 n 個字元截斷顯示,英文數字算一個字元,漢字算兩個字元」這樣的需求。要知道,這裡面的問題有很多:
為了顯示效果,前端往往會採用優先西文字型族的 font-family
設定,即西文字元用西文字型,漢字用中文字型,這就很容易使得文字的寬度不好根據字元數來控制。首先,非程式碼的內容本身就不一定適合用等寬西文字型顯示。其次即使用了等寬西文字型,漢字也基本不可能正好是其兩倍寬。滿足這個需求的,只能放棄西文字型,讓西文字元也使用中文字型,並且使用中易系列的幾個字型了(比如 SimSun,也就是 Windows 下的「宋體」)。(醜不說,還只能滿足 Windows 下的需求。)
這種需求甚至在很多時候還會和某些字元編碼長度的概念產生混淆,催生「長度限制 n 個位元組,其中英文數字算 1 位元組、漢字算 2 位元組」這樣的奇葩說法。
順便歪個樓,這種「西文等寬、漢字佔兩倍寬度」的需求正常情況下只會存在於程式設計師的程式碼編輯器裡。如果你是這種強迫症晚期,又不想用中易宋體,可以考慮試試 Belleve 製作的 Inziu。
思路和原理
對於前端來說,資料庫儲存的限制不應該是我們需要關心的問題。看下前面的「偽需求」,我們實際的需求往往是從視覺角度出發的「超出特定高度截斷顯示」或「超出特定行數階段顯示」兩種。由於實現方式的差異,其實可以分為「單行截斷」、「多行截斷」、「按高度截斷」幾種。從成本和效果來看,有「實現難度」、「效果精確度」、「對內容是否有限制」、「是否能響應頁面變化」這些需要考慮的細節。本文裡不準備列各種實現的程式碼,僅談談一些相關的問題和思路。
要看一些現有的實現方案可以看這幾篇:
- ELLIPSE MY TEXT…
- Line Clampin’ (Truncating Multiple Line Text)
- CSS Ellipsis: How to Manage Multi-Line Ellipsis in Pure CSS
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 > span
這種結構),對該 元素呼叫
elem.getClientRects().length
即可獲得行數。
可是目前在 WebKit 下,有一個疑似的 bug:當這個 display: inline
的容器內有子元素,getClientRects
的結果會包含這些子元素的輪廓,導致計數錯誤。既然規範並沒有詳細描述這個方法的計算邏輯,為什麼說是一個疑似 bug 呢?因為當給容器加上一些特定的樣式,計算結果又會和我們預期的結果相符了。詳情可參考這個 issue 和 demo。
利用 Selection.modify()
這是一個非標準的 DOM 介面,但是 WebKit 和 Gecko 都進行了實現(IE/Edge 都不支援)。
大致原理是:當我們把選區定位到某個元素的開頭,然後執行
|
可以把選區擴充套件到一行的末尾,然後再用
|
往後擴充套件一個字元,如果此時的 selection.focusNode
還在容器內,且 selection.focusOffset
有變化,說明下一行還有內容。迴圈往復就可得到指定元素的「行數」。
在瀏覽器相容性上,顯然這個方法也有較大的侷限,僅比 CSS 方法多支援了 Firefox 而已。但比上一個方法的好處在於,由於可以立刻找到折行的字元位置,所以擷取時不需要通過截調末尾內容反覆重試行數。
計算內容高度
給容器指定高度以後,通過比較 scrollHeight
和 clientHeight
可以方便地測試元素內容的高度是否溢位容器範圍。如果超出了指定高度,反覆截去尾部內容直到不再溢位。
擷取內容
如果內容是純文字,那麼很簡單,依次刪除末尾字元,再檢查內容是否超出寬度/行數/高度限制就行了。文字較長的話可以用二分法優化一下執行效率。同時如果快取下內容,可以在內容區域寬度變大時,根據情況來重新填入之前擷取掉的文字,做到類似 CSS 的自適應效果。
而如果內容中有其他的 HTML 元素,事情就沒這麼好辦了。可行的方法是,始終找到剩餘內容最後的葉子節點,如果是文字節點,刪除末尾字元;否則直接移除該節點。寬度變大時如果要恢復之前的內容就沒這麼簡單了,首先要保留之前所有移除元素的引用(因為上面可能有事件監聽),然後文字可以重新填入,元素節點也要按之前刪除前的 DOM 結構重新恢復。那麼在之前移除時我們可能就需要記錄每一步的操作,恢復時逆向執行回來。理論上是可行的,實現起來可能會複雜一些。
總結
可以看到,基於 CSS 的方案非常精確,而且在頁面佈局變化、瀏覽器視口大小變化時更容易響應,但只能滿特定的場景。用 JS 的方案在靈活性上有時更勝一籌,但要做的工作就多了很多。而且如果需要處理的內容很多,用 JS 的方法可能會帶來效能瓶頸,畢竟一般讀取 UI 實際顯示樣式的介面呼叫代價都比較大。