一个"懂分寸"的文本省略组件是怎样炼成的

大多数前端项目的 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 = falsedisabled = truetooltip 完全不存在,零开销
  • 文本长 → isOverflow = truedisabled = 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 的配合,这些样式通常在父级或全局样式中定义:

css 复制代码
display: -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 行代码不可能完美:

  1. 多行省略的 CSS 依赖外部-webkit-box-orient: vertical 等样式需要在使用侧定义,组件自身没有完整包含
  2. innerText 取 tooltip 内容innerText.trim() 在某些情况下可能和视觉显示的文本有差异(例如包含 ::before 伪元素内容时)
  3. onUpdated 可能触发过于频繁:在频繁更新的场景下(例如动画),每次 VNode 更新都会触发溢出检测,可以加 debounce

但这些都不影响它成为一个设计思路出色 的组件。好的组件不是完美的组件,而是在核心问题上给出了清晰且正确的答案的组件。


九、总结

OverEllipsis 的 120 行代码,核心在做一件事:让 UI 的每一个元素都"说实话"

  • 文本真的溢出了,tooltip 才出现
  • 每一条可能导致溢出的变化,都有一条对应的监听
  • 不需要 tooltip 的时候,它彻底不存在,不是"存在但无内容"

这不是炫技,这是一种对用户注意力的尊重。每一个不必要的 tooltip、多余的滚动条阴影、不该出现的"展开更多",都在微量地消耗用户的认知资源。消除这些"微小的不诚实",是前端工程师能够给予产品的最细腻的关怀。


本组件来自企业数智流程在线项目 src/components/OverEllipsis,源码约 120 行,使用 Vue 3 Composition API + TypeScript + Element Plus。

相关推荐
angerdream1 小时前
手把手编写儿童手机远程监控App之vue3 AI Gent
前端
李明卫杭州1 小时前
CSS BFC 完全指南:从原理到实战,彻底搞懂这个"结界"
前端
裕波1 小时前
AI 正在重写应用开发。Vue 与 Vite,给出新的答案。
javascript·vue.js
Momo__1 小时前
MDN MCP Server——Mozilla 把 Web 文档接进 AI Agent,从此 LLM 不再瞎编 API
前端·ai编程·mcp
妙码生花1 小时前
现代前端的极致性能 icon 加载方案(死磕成功版)
前端·vue.js·typescript
掘金者阿豪2 小时前
把业务数据变成共享仪表盘:Metabase可视化与远程访问实践
前端·后端
kyriewen2 小时前
折腾了半年 AI 编程工作流,最后发现效率瓶颈是桌上那块屏幕
前端·javascript·ai编程
蜗牛前端3 小时前
codex 全流程开发上线的高颜值礼簿小程序
前端·微信小程序
大龄秃头程序员4 小时前
我在图文流 App 里落地双层缓存、弱网降级与 OOM 治理
前端