大多数前端项目的 tooltip 都在撒谎------它们对着一行根本没溢出的短文本,也煞有介事地弹出"完整内容"。本文拆解一个仅 120 行的组件,看它如何用两个 DOM API 和一个 Observer,让 tooltip 学会"闭嘴"。
一、问题:tooltip 的"狼来了"困境
先看一个随处可见的写法:
vue
<!-- 常见但粗暴的做法 -->
<el-tooltip :content="text">
<div class="ellipsis">{{ text }}</div>
</el-tooltip>
这行代码有一个隐蔽的问题:不管文本是否溢出,tooltip 都会出现。用户在"项目管理"四个字上 hover,弹出一个写着"项目管理"的 tooltip------这不仅是废话,更是在消耗用户的注意力。当一个页面上有几十个这样的 tooltip 时,每一次鼠标划过都是一次微小的"狼来了"。
更好笑的是,这个问题在大多数项目中从未被修正,因为:
- 产品不会提"去掉不必要的 tooltip"这种需求
- 设计师的 mockup 里看不出 hover 态
- 测试用例里没有"验证短文本不弹 tooltip"
- 用起来好像也没什么大问题
于是它就一直在那里,成为页面上一万个微小摩擦中的一个。
一个真正好的省略组件,应该在文本不需要省略时保持沉默。
二、核心判断:浏览器已经算好了,我们只需要读出来
溢出检测的本质不是算法问题,而是一个读值问题。浏览器在渲染文本时已经精确计算了每个元素的尺寸,我们只需要比较两个值:
溢出条件:scrollWidth > clientWidth 或 scrollHeight > clientHeight
scrollWidth:元素内容实际占据的宽度(包括被 overflow hidden 裁掉的部分)clientWidth:元素可视区的宽度(CSS 盒模型去掉滚动条后的内容宽度)
当内容没有溢出时,scrollWidth === clientWidth,差值恰好为零。
当内容溢出时,scrollWidth 会超出 clientWidth,浏览器裁掉了超出的部分------同时也很诚实地保留了真实宽度的数值。
所以检测溢出不需要任何计算,只需要诚实地问浏览器一句:你的内容有多宽?
typescript
const checkOverflow = () => {
const el = overEllipsisRef.value
if (!el) return
isOverflow.value = el.scrollWidth > el.clientWidth
|| el.scrollHeight > el.clientHeight
}
多行省略同理,只不过比较维度从宽度变成了高度:
arduino
单行省略 → 比较 scrollWidth vs clientWidth
多行省略 → 比较 scrollHeight vs clientHeight(因为 -webkit-line-clamp 裁掉的是高度)
三、不仅是"判断一次":什么时候需要重新判断?
溢出状态不是一个静态值。以下任何情况发生,都可能导致溢出状态改变:
| 触发场景 | 例子 |
|---|---|
| 内容变化 | 数据从接口返回,文本从空变成了一段话 |
| 容器尺寸变化 | 用户拖拽侧边栏,表格列宽变了 |
| 窗口缩放 | 浏览器从全屏变成半屏 |
| 字体/主题切换 | 字体大小变了,或者换了个语言包导致文字变长 |
| 行数限制变化 | 从单行省略切换到三行省略 |
组件对这些场景的覆盖非常细致:
typescript
// 场景 1:内容变化 → watch content
watch(() => props.content, () => {
refreshEllipsisState()
})
// 场景 2:行数限制变化 → watch line
watch(() => props.line, () => {
refreshEllipsisState()
})
// 场景 3:容器尺寸变化 → ResizeObserver
const initObserver = () => {
resizeObserver = new ResizeObserver(() => {
refreshEllipsisState()
})
resizeObserver.observe(overEllipsisRef.value)
}
// 场景 4:slot 内容更新 → onUpdated
onUpdated(() => {
refreshEllipsisState()
})
每一种可能导致溢出状态变化的场景,都有一条对应的监听路径。不遗漏触发源,是这个组件可靠性的基础。
四、ResizeObserver 的正确用法
ResizeObserver 是这里最容易被用错的 API。两点值得展开:
1. 不是监听 window,而是监听元素本身
很多开发者习惯用 window.addEventListener('resize', ...) 做响应式,但这有两个问题:
- 粒度太粗:窗口 resize 不一定影响当前元素(弹窗里的元素、固定宽度的侧边栏不受影响)
- 漏掉场景:元素尺寸变化不一定伴随窗口 resize------CSS flex/grid 布局中,兄弟元素的增删会导致当前元素尺寸变化,但窗口大小没变
ResizeObserver 直接监听目标元素的尺寸变化,颗粒度精确到像素。
2. 有兜底
代码里有一个值得注意的判断:
typescript
if (!window.ResizeObserver || !overEllipsisRef.value) return
对于不支持 ResizeObserver 的古董浏览器,组件降级为只在 mount 和 content/watch 变化时检测,不报错、不崩溃。这是一个成本极低的渐进增强------支持了就是更好的体验,不支持也不影响核心功能。
五、把判断结果驱动到 tooltip 的 disabled 属性
这是整个组件最妙的一句模板代码:
vue
<ElTooltip :content="tooltipContent" :disabled="!isOverflow">
<div class="over-ellipsis" ref="overEllipsisRef">
<slot>{{ props.content }}</slot>
</div>
</ElTooltip>
disabled 属性在 Element Plus 中控制 tooltip 是否生效。传入 true 时,tooltip 完全不渲染弹出层,不会有 hover 事件监听,不会有 DOM 节点创建。
这意味着:
- 文本短 →
isOverflow = false→disabled = true→ tooltip 完全不存在,零开销 - 文本长 →
isOverflow = true→disabled = false→ tooltip 正常工作
不是"有 tooltip 但内容为空",而是 tooltip 根本不存在。 这比用 v-if 更优雅------v-if 会销毁重建 DOM,而 disabled 只影响 tooltip 的行为逻辑。Tooltip 的 DOM 结构和事件绑定保持不变,切换开销更小。
六、多行省略的 CSS 配合
单行省略是 CSS 基本功:
css
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
多行省略需要 -webkit-line-clamp,但注意它要求配合 display: -webkit-box 和 -webkit-box-orient: vertical,这三者缺一不可。组件在这里做了一个干净的属性分离:
typescript
const isMultiLine = computed(() => Number(props.line) > 1)
const ellipsisStyle = computed(() =>
isMultiLine.value
? { WebkitLineClamp: String(props.line) }
: {}
)
vue
<div
:class="[
{ 'over-ellipsis--multi-line': isMultiLine,
'over-ellipsis--single-line': !isMultiLine }
]"
:style="ellipsisStyle"
>
css
.over-ellipsis--single-line {
display: block;
white-space: nowrap;
}
.over-ellipsis--multi-line {
word-break: break-all;
}
单行用 white-space: nowrap,多行用 -webkit-line-clamp(配合外部的 display: -webkit-box + -webkit-box-orient: vertical),两种省略模式通过 isMultiLine 干净切换。line prop 默认值设为 1,这是一个合理的默认------大多数场景确实是单行省略。
注意:完整的多行省略需要以下 CSS 的配合,这些样式通常在父级或全局样式中定义:
cssdisplay: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis;
七、完整的心智模型
把这个组件的设计哲学抽象出来,是一条可以迁移的通用原则:
scss
┌─────────────────────────────────────────────────┐
│ │
│ 状态 X 是否需要展示? │
│ ↓ │
│ 能否在运行时检测? │
│ ↓ │
│ 用浏览器原生 API 读值(不自己算) │
│ ↓ │
│ 识别所有会导致值变化的触发源,逐一监听 │
│ ↓ │
│ 用 disabled / v-if 控制功能的启用,而非事后补救 │
│ │
└─────────────────────────────────────────────────┘
映射到 OverEllipsis:
| 步骤 | 实现 |
|---|---|
| 状态 X | tooltip 是否需要出现 |
| 运行时检测 | scrollWidth > clientWidth |
| 原生 API 读值 | 直接用 DOM 属性,不自己算字符宽度 |
| 触发源覆盖 | watch content, watch line, ResizeObserver, onUpdated |
| 控制功能启用 | :disabled="!isOverflow" |
这个模式可以复用到很多场景:
- 截断标记:"查看更多"按钮只在真正截断时才显示
- 滚动阴影:只在可滚动时在边缘显示渐变阴影
- 虚拟列表:只在数据量超过阈值时才启用虚拟化
- 分页器:只在超过一页时才显示分页组件
- 拖拽手柄:只在列表项 > 1 时才可拖拽排序
八、这个组件也有不完美的地方
实事求是地说,120 行代码不可能完美:
- 多行省略的 CSS 依赖外部 :
-webkit-box-orient: vertical等样式需要在使用侧定义,组件自身没有完整包含 innerText取 tooltip 内容 :innerText.trim()在某些情况下可能和视觉显示的文本有差异(例如包含::before伪元素内容时)onUpdated可能触发过于频繁:在频繁更新的场景下(例如动画),每次 VNode 更新都会触发溢出检测,可以加 debounce
但这些都不影响它成为一个设计思路出色 的组件。好的组件不是完美的组件,而是在核心问题上给出了清晰且正确的答案的组件。
九、总结
OverEllipsis 的 120 行代码,核心在做一件事:让 UI 的每一个元素都"说实话"。
- 文本真的溢出了,tooltip 才出现
- 每一条可能导致溢出的变化,都有一条对应的监听
- 不需要 tooltip 的时候,它彻底不存在,不是"存在但无内容"
这不是炫技,这是一种对用户注意力的尊重。每一个不必要的 tooltip、多余的滚动条阴影、不该出现的"展开更多",都在微量地消耗用户的认知资源。消除这些"微小的不诚实",是前端工程师能够给予产品的最细腻的关怀。
本组件来自企业数智流程在线项目 src/components/OverEllipsis,源码约 120 行,使用 Vue 3 Composition API + TypeScript + Element Plus。