误区
在设计产品时,由于不少产品经理、工程师并没有「字符不一定等宽」的概念,往往会给出「超过 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>
元素内的文本,现在更改为 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 实际显示样式的接口调用代价都比较大。