最近在项目中需要实现类似Mac系统下的效果:当屏幕宽度足以容纳完整文件名时,全部展示;当用户缩放浏览器导致宽度不足时,则省略中间文字并保留文件后缀。我认为这是最佳方案,因为用户通常更关心文件类型,保留后缀名最符合直觉。
在查阅了大量资料并借鉴已有方案后,我实现了一个满足项目需求的文本省略组件。
一、组件效果预览
-
单行文字溢出,不保留后缀。

-
单行文字溢出,保留后缀。

-
多行文字溢出处理。这是项目中的一个特殊场景。例如,我希望文字最多显示两行。如果两行内没有溢出,则正常显示;如果两行内容依然溢出,则对溢出的文字进行处理。
未处理时的效果:

使用组件后的效果:

(提示:不仅限于两行,三行、四行均可。接下来实现的组件将允许你高度自定义文本溢出的处理场景。)
如果你想先尝试效果,可以通过 npm 快速安装:
npm i auto-ellipsis-text
或
pnpm i auto-ellipsis-text
或
yarn add auto-ellipsis-text
使用起来非常简单,只需用组件包裹你的文本即可:
import { AutoEllipsis } from "auto-ellipsis-text";
<AutoEllipsis suffix="..." :start-ellipsis-line="2">
这是一个简单的测试,看看文字溢出的情况如何处理
这是一个简单的测试,看看文字溢出的情况如何处理
</AutoEllipsis>
言归正传,接下来我将逐步讲解实现这个组件的思路。我写的组件不一定是最优解,但希望你能够知其然并知其所以然,然后完善其不足之处,最终实现你自己的自动省略文本方案,这才是本文的目的。
二、单行溢出的处理
我们首先只考虑单行情况。在展示文件列表等场景中,高度通常是固定的,宽度则不确定(用户可能拖动浏览器改变宽度),但总会给宽度设置最小值和最大值以保证布局统一。
样式方面,我使用了 UnoCSS 并将样式内联在标签中。不过,即使你不了解 UnoCSS 也不影响阅读,样式并非本文重点。
让我们先创建一个简单的溢出场景。代码很简单:容器是一个最大宽度 200px、固定高度 30px 的 div。
<div class="mb-20px text-red"> 下方 div 宽度最大值为 200px,高度为 30 px </div>
<div class="border-solid border-1px border-red max-w-200px h-30px">
<span class="text-20px">这是一个简单的测试,看看文字溢出的情况如何处理</span>
</div>
页面效果如下:

可以清晰看到,由于文字内容超出容器且未对溢出场景做任何处理,导致了当前效果。我们先从基础CSS属性开始探索。
最初查阅 MDN 时,我发现了 text-overflow 这个属性。

text-overflow?我们寻找的不正是文字溢出的处理方式吗?我兴奋地将其添加到组件上。
<div class="border-solid border-1px border-red max-w-200px h-30px text-ellipsis">
<span class="text-20px">这是一个简单的测试,看看文字溢出的情况如何处理</span>
</div>
效果如下:

页面毫无变化。于是我又返回 MDN 查看是否遗漏了什么,发现了这样一段说明:

结论是:text-overflow 属性本身并不会强制创建文本省略的场景。它是在你处理了溢出场景之后,对文字溢出进行的二次特殊处理。如果未对溢出做任何操作,这个属性是无效的。(注意:它仅处理文字溢出场景。)
既然要求我们添加 overflow: hidden 和 white-space 属性,那我们就照做。先只添加 overflow: hidden 看看会发生什么。

我们发现,超出的文字被隐藏了,但省略号呢?原因在于下面这句话:

仔细观察我们的溢出场景:

下面两行文字实际上是向盒子下方溢出的,正好对应了 text-overflow 介绍中 “无法在盒子下方溢出” 的描述。
因此,我们需要制造一个让文字强制不换行的场景。这就用到了另一个关键属性:white-space。

本节我们只关注 nowrap 这个值。
首先需要了解,网页中的换行并非无故发生,而是由一个隐藏的换行符控制,你可以将其浅显地理解为 white-space(空白符)。

理解了这一点,white-space: nowrap 的含义就十分明确了。white-space 对应空白符,no-wrap 代表不换行。连起来就是:遇到空白符不换行。而换行符本身就是一个隐藏的 white-space,添加此属性后,就创建了不换行的场景。
让我们先移除 text-ellipsis 和 overflow-hidden 属性,只添加 white-space: nowrap 看看效果。
<div class="border-solid border-1px border-red max-w-200px h-30px whitespace-nowrap">
<span class="text-20px">这是一个简单的测试,看看文字溢出的情况如何处理</span>
</div>
效果如下:

可以看到,由于忽略了隐藏的换行符,文字不再自动换行,整段内容显示在一行上。此时再加上 overflow: hidden 和 text-ellipsis 属性,神奇的效果就出现了。

我们仅用几个 CSS 属性就完成了单行情况下不保留后缀的文字溢出处理。这为我们后续的 JavaScript 动态计算提供了重要的基础思路。
三、前期准备
首先,创建一个 autoEllipsis.vue 文件,并写下以下基础代码:
<template>
<div id="container">
<span ref="text">{{ primitiveText }}</span>
</div>
</template>
请注意,这个 id 为 container 的 div 元素在接下来的实现中至关重要。
接着,使用 ref 分别获取这两个 DOM 元素的引用。

const container = ref<HTMLDivElement>();
const text = ref<HTMLSpanElement>();
最后,我们需要设计一个函数,在组件挂载后,由它来正确处理文字溢出场景。

function autoEllipsis(container: HTMLElement, textNode: HTMLSpanElement) {...}
接下来的核心就是如何实现这个 autoEllipsis 函数。别急着写代码,让我们先理清思路。
四、理清思路
-
为了实现通用性,container 的宽度不能写死,它应由其外层的父元素决定(即上文提到的有最大/最小宽度限制的元素)。

换句话说,我们的 container 需要动态获取外层父元素的宽度。
-
假设我们已经拿到了父元素宽度,命名为 fatherWidth。然后,通过之前获取的 text 这个 DOM 元素拿到传入的文本内容。通过获取这个 span 元素的 offsetWidth,就能得到文本的总像素宽度。
通过判断文本的 offsetWidth 是否大于 fatherWidth,我们可以计算出溢出的像素宽度。

-
得到溢出宽度后,用其除以字体大小 (overWidth / fontSize),就能算出溢出了多少个字。
-
假设溢出宽度为 200px,字体大小为 20px,那么 200 / 20 得出溢出了 10 个字。
-
假设原文总字数为 30 个。那么此时屏幕上只能完整显示 20 个字,因为有 10 个字因溢出被隐藏。
-
接下来就很简单了:我们从原来 30 个字的中间开始切割,左右各去掉 5 个字,这样容器恰好能容下 20 个字。然后在中间手动加上 “...” 省略号,就完美实现了“保留两端”的省略效果。
-
用大白话讲,就是去掉中间的 10 个字,并用三个点 ... 字符串替换其中的一部分。
五、完成 autoEllipsis 函数
第一步是获取放入文本的总像素宽度。注释已很清晰,不再赘述。

const str = primitiveText; //1.拿到的所有文字信息
textNode.textContent = str; //2.将所有文字放入到我们的 span 标签中
container.style.whiteSpace = "nowrap"; //3.先将文字全部放入到《一行》中,为了计算整体宽度
container.style.width = "fit-content"; //4. 给 container 设置 fit-content 属性,就可以拿到正确的内容宽度
const containerWidth = container.clientWidth; //5. 拿到了 container 的宽度
然后获取外部父元素的宽度。此时出现第一个分支:如果 container 的宽度小于等于父元素宽度,说明文字内容完全可以容纳,无需特殊处理。

const parent = container.parentElement; // 拿到外部父元素的宽度
const parentWidth = parent!.clientWidth || parent!.offsetWidth;
if (containerWidth <= parentWidth) {
// 如果container 的宽度《小于》父元素的宽度,不做任何处理
textNode.textContent = str;
return;
}
第二个分支,当 container 宽度大于父元素宽度时,我们可以通过传递的 props 来区分是否需要保留后缀。如果不需要保留后缀(即单行省略),直接给 container 应用我们在第二部分讲解的CSS属性即可。

else if (cssEntirely.value) {
container.style.width = parentWidth + "px";
container.style.whiteSpace = "nowrap";
container.style.textOverflow = "ellipsis";
container.style.overflow = "hidden";
return;
}
六、保留后缀的实现
如果你对如何实现“保留后缀”还没有清晰思路,建议重新回顾一下第四部分。核心思路是计算出父元素宽度可以容纳多少个字。

const textWidth = textNode.offsetWidth; //1. 拿到文字节点的宽度
const strNumer = str.length; //2. 拿到文字的数量
const avgStrWidth = textWidth / strNumer; //3. 拿到平均每个文字多少宽度
const canFitStrNumber = Math.floor(
(parentWidth * props.startEllipsisLine) / avgStrWidth //4. 根据父元素的宽度来计算出可以容纳多少文字
);
接下来,计算出我们需要删除多少个字。

const shouldDelNumber = strNumer - canFitStrNumber + 1.5; //1. 算出需要删除几个文字(1.5是为了省略号的宽度
const delEachSide = shouldDelNumber / 2; //2. 因为要保留中间,所以我们不能只从开头删除,也需要从两头删除
const endLeft = Math.floor(strNumer / 2 - delEachSide); //3. 因为下面要用到 slice 所以需要计算出 index
const startRight = Math.ceil(strNumer / 2 + delEachSide); //4. 和上面同理
思路很简单,就是使用字符串的 slice 方法,根据上面计算出的索引,从两端切割掉相应数量的文字。

switch(props.suffix){
case true:{
textNode.textContent =
str.slice(0,endLeft) + "..." + str.slice(startRight);
break;
}
case false:{
textNode.textContent = str.slice(0, -shouldDelNumber) + "...";
break;
}
}
最后关键一步:将 container 的 white-space 属性设回 normal。因为我们已经正确处理了文字数量,现在的 container 不会再溢出了。

container.style.wordBreak = "break-all";
container.style.whiteSpace = "normal";
七、核心源码
以下是 autoEllipsis 函数的核心源码:
function autoEllipsis(container: HTMLElement, textNode: HTMLSpanElement) {
const str = primitiveText; //1.拿到的所有文字信息
textNode.textContent = str; //2.将所有文字放入到我们的 span 标签中
container.style.whiteSpace = "nowrap"; //3.先将文字全部放入到《一行》中,为了计算整体宽度
container.style.width = "fit-content"; //4. 给 container 设置 fit-content 属性,就可以拿到正确的内容宽度
const containerWidth = container.clientWidth; //5. 拿到了 container 的宽度
const parent = container.parentElement; // 拿到外部父元素的宽度
const parentWidth = parent!.clientWidth || parent!.offsetWidth;
if (containerWidth <= parentWidth) {
//如果container 的宽度《小于》父元素的宽度,不做任何处理
textNode.textContent = str;
return;
} else if (cssEntirely.value) {
container.style.width = parentWidth + "px";
container.style.whiteSpace = "nowrap";
container.style.textOverflow = "ellipsis";
container.style.overflow = "hidden";
return;
} else {
const textWidth = textNode.offsetWidth; //1. 拿到文字节点的宽度
const strNumer = str.length; //2. 拿到文字的数量
const avgStrWidth = textWidth / strNumer; //3. 拿到平均每个文字多少宽度
const canFitStrNumber = Math.floor(
(parentWidth * props.startEllipsisLine) / avgStrWidth //4. 根据父元素的宽度来计算出可以容纳多少文字
);
const shouldDelNumber = strNumer - canFitStrNumber + 1.5; //1. 算出需要删除几个文字(1.5是为了省略号的宽度
const delEachSide = shouldDelNumber / 2; //2. 因为要保留中间,所以我们不能只从开头删除,也需要从两头删除
const endLeft = Math.floor(strNumer / 2 - delEachSide); //3. 因为下面要用到 slice 所以需要计算出 index
const startRight = Math.ceil(strNumer / 2 + delEachSide); //4. 和上面同理
switch (props.suffix) {
case true: {
textNode.textContent =
str.slice(0, endLeft) + "..." + str.slice(startRight);
break;
}
case false: {
textNode.textContent = str.slice(0, -shouldDelNumber) + "...";
break;
}
}
container.style.wordBreak = "break-all";
container.style.whiteSpace = "normal";
}
}
八、优化点
这个组件目前在计算 ... 省略号所占用的文字宽度时,无法精确地根据字体大小动态调整。即下面代码中的 1.5 这个数字是经验值,并非精确计算得出。

const shouldDelNumber = strNumber - canFitStrNumber + 1.5; //1. 算出需要删除几个文字(1.5 是为了省略号的宽度)
由于我们项目的字体大小是固定的,所以暂未进一步优化。希望各位开发者能提交 PR 一起完善这个 Vue 组件,共同在技术社区中探讨与进步。