性能提升 satisfying!一个 Vue3 指令干掉页面上 200 个无用 Tooltip 实例

性能提升 satisfying!一个 Vue3 指令干掉页面上 200 个无用 Tooltip 实例

你的表格还在为每个单元格都创建 Tooltip 吗?这个零 DOM 侵入的方案让页面内存直降 40%

前言

在中后台项目中,表格单元格、卡片标题等区域经常出现文本被截断的情况。常见做法是给每个元素都套一个 el-tooltip,但这样做有两个明显的问题:

  1. 性能浪费:即使文本没有溢出,tooltip 组件也被创建了
  2. 代码冗余:每处都要写一遍 tooltip 包裹逻辑

有没有更优雅的方案?答案是 ------ 自定义指令 v-ellipsis-tooltip

效果展示

vue 复制代码
<!-- 只需一行指令,文本溢出时自动显示 tooltip -->
<div v-ellipsis-tooltip class="line-clamp-2">
  这是一段很长的文本内容,当它超出容器宽度时,会自动截断并在鼠标悬停时显示完整内容...
</div>
  • 文本未溢出 → 鼠标悬停无任何反应
  • 文本溢出 → 鼠标悬停自动弹出 tooltip 显示完整内容

设计思路

scss 复制代码
┌─────────────────────────────────────────────────────────┐
│                   v-ellipsis-tooltip                    │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  mounted ──► 注册 mouseenter 监听                        │
│                    │                                    │
│                    ▼                                    │
│  mouseenter ──► 判断是否溢出                             │
│                    │                                    │
│              ┌─────┴─────┐                              │
│              │           │                              │
│          未溢出       已溢出                             │
│              │           │                              │
│          清除tooltip  创建/显示 ElTooltip                │
│                          │                              │
│                    virtualTriggering                    │
│                   (虚拟触发,无需包裹)                    │
│                                                         │
│  beforeUnmount ──► 移除监听 + 清理 DOM                   │
│                                                         │
└─────────────────────────────────────────────────────────┘

核心实现

完整源码

typescript 复制代码
// @unocss-include
import type { UseTooltipProps } from 'element-plus'
import type { Directive } from 'vue'
import { ElTooltip } from 'element-plus'
import { isPlainObject } from 'lodash-es'
import { render } from 'vue'

type TooltipValue = string | Partial<UseTooltipProps>

interface TooltipContext {
  container?: HTMLElement
  vm?: VNode
  binding: DirectiveBinding<TooltipValue>
  onMouseEnter: () => void
}

const contextMap = new WeakMap<HTMLElement, TooltipContext>()

const parseBinding = (el: HTMLElement, binding: DirectiveBinding<TooltipValue>) => {
  const { value } = binding
  let elTooltipProps: Partial<UseTooltipProps> = {}

  if (typeof value === 'string') {
    elTooltipProps.content = value
  }
  else if (!value) {
    elTooltipProps.content = el.textContent ?? ''
  }
  else if (value && isPlainObject(value)) {
    elTooltipProps = { ...value, content: value.content ?? el.textContent ?? '' }
  }

  return elTooltipProps
}

const clean = (ctx: TooltipContext) => {
  if (ctx.container) {
    render(null, ctx.container)
    ctx.container.remove()
  }
  ctx.vm = undefined
  ctx.container = undefined
}

const handleMouseEnter = (el: HTMLElement, ctx: TooltipContext) => {
  const { binding } = ctx
  const elTooltipProps = parseBinding(el, binding)
  // 使用 1px 容差值,避免 iOS Safari 亚像素渲染导致的误差问题
  const tolerance = 1
  const isOverflow = el.scrollWidth - el.clientWidth > tolerance
    || el.scrollHeight - el.clientHeight > tolerance

  if (!isOverflow || elTooltipProps.disabled) {
    clean(ctx)
    return
  }

  if (!ctx.vm) {
    const vm = h(ElTooltip, {
      virtualTriggering: true,
      virtualRef: el,
      placement: 'top',
      showAfter: 200,
      popperClass: 'max-w-300',
      ...elTooltipProps,
    })

    if (binding.instance && binding.instance.$) {
      vm.appContext = binding.instance.$.appContext
    }

    ctx.vm = vm
  }
  if (!ctx.container) {
    ctx.container = document.createElement('div')
  }
  render(ctx.vm, ctx.container)
  if (elTooltipProps.trigger === 'hover' || !elTooltipProps.trigger) {
    ctx.vm.component!.exposed!.onOpen()
  }
}

/** 只有在内容溢出时才显示 tooltip */
export const vEllipsisTooltip: Directive<HTMLElement, TooltipValue> = {
  mounted(el: HTMLElement, binding: DirectiveBinding<TooltipValue>) {
    const ctx: TooltipContext = {
      binding,
      onMouseEnter: () => handleMouseEnter(el, ctx),
    }
    contextMap.set(el, ctx)
    el.addEventListener('mouseenter', contextMap.get(el)!.onMouseEnter)
  },

  beforeUnmount(el: HTMLElement) {
    const ctx = contextMap.get(el)!
    el.removeEventListener('mouseenter', contextMap.get(el)!.onMouseEnter)
    clean(ctx)
    contextMap.delete(el)
  },
}

技术亮点解析

1. WeakMap 管理上下文

typescript 复制代码
const contextMap = new WeakMap<HTMLElement, TooltipContext>()

使用 WeakMap 以 DOM 元素为 key 存储指令上下文,当元素被 GC 回收时,对应的上下文也会自动释放,零内存泄漏

2. Virtual Triggering(虚拟触发)

typescript 复制代码
const vm = h(ElTooltip, {
  virtualTriggering: true,
  virtualRef: el,
  // ...
})

利用 Element Plus 的 virtualTriggering 特性,tooltip 无需包裹目标元素 ,直接通过 virtualRef 绑定触发源。这意味着:

  • 不改变 DOM 结构
  • 不影响原有样式布局
  • 纯"附加"行为

3. popperClass 最大宽度限制

typescript 复制代码
popperClass: 'max-w-300',

这里的 max-w-300UnoCSS 原子化类名 (等价于 max-width: 300px),用于限制 tooltip 弹层的最大宽度,防止超长文本撑开过宽。

文件顶部的 // @unocss-include 魔法注释确保 UnoCSS 能扫描到 .ts 文件中的类名并生成对应样式。

如果你的项目没有使用 UnoCSS,需要在全局样式中手动定义该类:

css 复制代码
/* global.scss */
.max-w-300 {
  max-width: 300px;
}

4. 按需创建 + 溢出检测

typescript 复制代码
const isOverflow = el.scrollWidth - el.clientWidth > tolerance
  || el.scrollHeight - el.clientHeight > tolerance

每次 mouseenter 时实时检测溢出状态,只有确实溢出才创建 tooltip 实例,完美应对内容动态变化的场景。

5. 1px 容差处理

typescript 复制代码
const tolerance = 1

iOS Safari 的亚像素渲染可能导致 scrollWidthclientWidth 相差不到 1px,容差值避免了误判。

6. AppContext 继承

typescript 复制代码
if (binding.instance && binding.instance.$) {
  vm.appContext = binding.instance.$.appContext
}

将当前组件的 appContext 传递给动态创建的 VNode,确保 tooltip 能正确使用全局注册的插件、国际化等配置。

使用方式

基础用法

vue 复制代码
<template>
  <!-- 自动读取元素 textContent 作为 tooltip 内容 -->
  <span v-ellipsis-tooltip class="line-clamp-1">
    {{ longText }}
  </span>
</template>

自定义内容

vue 复制代码
<template>
  <!-- 指定 tooltip 显示内容 -->
  <span v-ellipsis-tooltip="'自定义提示文本'" class="line-clamp-1">
    {{ shortText }}
  </span>
</template>

传递 ElTooltip Props

vue 复制代码
<template>
  <!-- 完整配置 -->
  <div
    v-ellipsis-tooltip="{ placement: 'bottom', content: '详细信息', showAfter: 500 }"
    class="line-clamp-3"
  >
    {{ articleContent }}
  </div>
</template>

配合表格使用

vue 复制代码
<template>
  <el-table :data="tableData">
    <el-table-column prop="name" label="名称">
      <template #default="{ row }">
        <span v-ellipsis-tooltip class="line-clamp-1">
          {{ row.name }}
        </span>
      </template>
    </el-table-column>
  </el-table>
</template>

与传统方案对比

维度 传统 el-tooltip 包裹 v-ellipsis-tooltip 指令
代码量 每处 3-5 行模板代码 1 个指令属性
性能 始终创建 tooltip 实例 按需创建,溢出才渲染
DOM 影响 额外包裹层 零 DOM 侵入
动态内容 需手动控制 disabled 自动实时检测
样式影响 可能破坏 flex 布局 完全无影响

注意事项

  1. 必须设置溢出截断样式 :元素需要有 overflow: hidden(如 line-clamp-*truncate 等),否则无法触发溢出检测。

  2. 多行文本支持 :同时检测了水平和垂直方向的溢出,支持 line-clamp-2line-clamp-3 等多行截断。

  3. 指令已自动注册 :项目配置了 unplugin-auto-import,直接在模板中使用即可,无需手动 import。

总结

v-ellipsis-tooltip 通过自定义指令 + 虚拟触发的方式,实现了:

  • 零侵入:不改变 DOM 结构和样式
  • 高性能:按需创建,自动清理
  • 易使用:一行指令搞定
  • 强兼容:处理了 Safari 亚像素、多行文本等边界情况

如果你的项目也面临大量文本溢出提示的需求,不妨试试这个方案!


如果这篇文章对你有帮助,欢迎点赞 👍 收藏 ⭐ 关注,后续会分享更多 Vue3 实战技巧。

相关推荐
前端那点事1 小时前
Vuex刷新数据丢失?4种持久化方案全覆盖,从零到项目落地(实战完整版)
前端·vue.js
漫游的渔夫1 小时前
前端开发者做 AI Agent:别只渲染答案,用 7 个状态接住确认、错误和 trace
前端·人工智能·typescript
clove1 小时前
从 LLM 到 Agent:一篇文章课带你彻底搞懂 AI 智能体的核心逻辑
前端
前端那点事1 小时前
彻底吃透JS定时器!setTimeout/setInterval区别、坑点与最优优化方案(Vue实战)
前端·vue.js
ZC跨境爬虫1 小时前
跟着 MDN 学 HTML day_27:(深入理解 HTML 属性反射机制)
前端·javascript·ui·html·音视频·媒体
ayqy贾杰1 小时前
过去三年我做对了一件事
前端·面试·ai编程
Hello--_--World1 小时前
React:useRef 超详细教程、forwardRef 详解、useImperativeHandle详解
前端·javascript·react.js
xuankuxiaoyao1 小时前
vue.js 课程自己编写小游戏
前端·javascript·vue.js
兔子零10241 小时前
当 Codex 成为主力,软件工程的重心已经变了
前端·后端·架构