vue2 封装一个自动校验是否溢出的 tooltip 自定义指令

需求背景

给元素溢出添加省略号并设置 tooltip 提示,相比 90% 的同学都遇到过吧,我也不例外。以前也做过同样的功能,但是当年并没有考虑太多。现如今再次遇到这样的需求,我发现这样的功能是普遍又常见的,于是封装了这样一个简单的自定义指令。并让他支持自动检查是否溢出,只有溢出的时候才会显示 tooltip 组件。

技术背景

  • Vue2
  • element-ui -> el-tooltip

基本需求

  1. 全局只有一个 el-tooltip 组件
  2. 支持 el-tooltip 组件所有配置
  3. el-tooltip 不具备校验内容是否溢出的功能,我们需要
  4. 封装为 vue 自定义指令,方便使用

校验是否溢出

需要完成这个功能之前,需要先了解一下如何校验元素内容是否溢出,这里我也是翻阅了 el-table 的源码查看了 show-overflow-tooltip 功能的校验元素是否溢出的实现学会的。

这里是我单独抽离封装的检查是否溢出源码👇

javascript 复制代码
/**
 * 检查元素是否溢出
 * @param {HTMLElement} el 需要检查的元素
 * @returns
 */
export function isOverflow(el) {
  const range = document.createRange();
  range.setStart(el, 0);
  range.setEnd(el, el.childNodes.length);
  const rangeRect = range.getBoundingClientRect();
  const rangeWidth = Math.round(rangeRect.width);
  const computedStyle = getComputedStyle(el);
  const padding =
    parseInt(computedStyle.paddingLeft.replace("px", "")) +
    parseInt(computedStyle.paddingRight.replace("px", ""));

  return (
    rangeWidth + padding > el.offsetWidth || el.scrollWidth > el.offsetWidth
  );
}
  1. 使用 createRange 函数创建一个 Range 实例
  2. 使用 range.setStartrange.setEnt 设置 range 的片段范围,可以理解为添加的内容
  3. 此时 range 就已经存入了需要检查是否溢出的目标元素的所有节点内容,然后调用 getBoundingClientRect 函数获取内容的实际宽度
  4. 使用 getComputedStyle 获取目标元素的左右内边距
  5. rangeWidth + padding > el.offsetWidth 校验元素是否溢出
  6. 使用 el.scrollWidth > el.offsetWidth 兜底校验

创建 Tooltip 工具类

我这里使用 es6class 来实现,传统的 function 方式当然也是可以的

javascript 复制代码
import Vue from "vue";
import { Tooltip as ElTooltip } from "element-ui";
import { debounce } from "lodash";

import { isOverflow } from "@/utils/is";

// 使用 Vue.extend 创建一个 Tooltip 构造器
const TooltipConstructor = Vue.extend(ElTooltip);

// 创建一个显示 Popper 的防抖函数,节省性能
const activateTooltip = debounce((tooltip) => tooltip.handleShowPopper(), 50);

// 默认的 props
const defaultProps = {
  effect: "dark",
  placement: "top-start",
  isOverflow: true, // 这个属性用于配置是否需要使用自动校验溢出,因为有些场景可能是需要一直显示 tooltip
};

export default class Tooltip {
  props = {};
  instance = null;

  constructor(props = {}) {
    this.props = { ...defaultProps, ...props };

    /**
     * 单例模式:使用 tooltip 时有些地方需要大量的创建多次 tooltip
     * 但是很多时候tootip 的配置样式都是固定不变的
     * 所以我这里直接使用单例模式来实现,并且提供了 updateInstanceProps 函数来修改 props
     */
    if (!Tooltip.instance) {
      this.initInstance(this.props);
      Tooltip.instance = this;
    } else {
      // 多次创建后续传入的 props 直接覆盖前面的 props
      Tooltip.instance.updateInstanceProps(this.props);
      return Tooltip.instance;
    }
  }
  
  // 提供 create 静态函数,支持两种创建方式
  static create(props) {
      return new Tooltip(props);
  }

  initInstance(props) {
    this.instance = new TooltipConstructor({
      propsData: { ...props },
    });
    this.instance.$mount();
  }
  
  /**
   * 
   * @param {HTMLElement} childElement 指定挂载的元素(用于确定提示的位置,跟校验溢出的元素)
   * @param {string | VNode} content 提示内容
   * @param {Object} props el-tooltip 的所有支持的 props 
   * @returns 
   */
  show(childElement, content, props) {
    // 可以在显示 tooltip 时动态修改 props 参数
    props && this.updateInstanceProps(props);
    // 校验是否溢出
    if (this.props.isOverflow && !isOverflow(childElement)) {
      return;
    }

    const instance = this.instance;
    if (!instance) return;

    content && this.setContent(content);
    // 引用的元素,相当于确认将 tooltip 挂载在哪个元素位置显示
    instance.referenceElm = childElement;

    // 确保元素可见
    if (instance.$refs.popper) {
      instance.$refs.popper.style.display = "none";
    }

    // 下面这三行代码都是为了打开 popper 组件,具体细节可以查看 el-tooltip 的源码实现,大致就是修改状态
    instance.doDestroy();
    instance.setExpectedState(true);
    activateTooltip(instance);
  }

  hide() {
    if (!this.instance) return;

    this.instance.doDestroy();
    this.instance.setExpectedState(false);
    this.instance.handleClosePopper();
  }

  destroy() {
    if (this.instance) {
      this.instance.$destroy();
      this.instance = null;
      Tooltip.instance = null;
    }
  }

  setContent(content) {
    // 更新 tooltip 的内容,因为 el-tooltip 可以是 VNode 所以这里直接更新组件的插槽内容即可
    this.instance.$slots.content = content;
  }
  
  /** 更新 props */
  updateInstanceProps(props) {
    this.props = { ...this.props, ...props };
    
    // 更新 tooltip 组件实例 props
    for (const key in props) {
      if (key in this.instance) {
        this.instance[key] = props[key];
      }
    }
  }
}

在上述代码中,我将核心的代码都已经加上了注释,大家查看代码时直接看详细注释即可
问题 :上述代码中存在两个弊端

  1. 由于是单例模式,所以在创建多次 Tooltip 时,最终 Tooltip 的配置会被覆盖,是否应该如此?
  2. 在使用 updateInstanceProps 更新 props 时,也会对所有的 tooltip 实例造成影响,是否应该如此呢?

实践一下

接下来我先创建几个基本示例,试验一下功能是否正常

基本使用

html 复制代码
<template>
  <div class="container">
    <p
      class="text"
      @mouseenter="handleTextMouseenter"
      @mouseleave="handleMouseleave"
    >
      Lorem ipsum dolor sit amet consectetur adipisicing elit. Nostrum quas
      iusto, sunt blanditiis accusantium excepturi deserunt, id enim quos,
      quaerat dolores aliquam consequatur. Fugit saepe dolorum facilis in facere
      aut.
    </p>
  </div>
</template>

<script>
import Tooltip from "@/utils/Tooltip";

export default {
  created() {
    this.tooltip = new Tooltip({
      placement: "top",
    });
  },
  beforeDestroy() {
    this.tooltip.destroy();
  },
  methods: {
    handleTextMouseenter(event) {
      const content = event.target.innerText || event.target.textContent;
      this.tooltip.show(event.target, content);
    },
    handleTextMouseleave() {
      this.tooltip.hide();
    },
  },
};
</script>

<style lang="scss" scoped>
.container {
  display: flex;
  flex-direction: column;
  align-items: center;
}
.text {
  width: 300px;
  padding: 0 10px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
</style>

没有问题,能够正常显示出提示。修改 p 标签中的内容为 测试不溢出 来测试不溢出的情况。

html 复制代码
 // 省略......
  <p
      class="text"
      @mouseenter="handleTextMouseenter"
      @mouseleave="handleMouseleave"
    >
     测试不溢出
    </p>
 // 省略......

同样也是没有问题的,不溢出就不显示 tooltip 了。

动态展示

有时候可能会有一个 "按钮" 需要动态判断是否需要出现提示的情况,需要将 isOverflow 设置为 falsetooltip 不需要校验是否溢出

html 复制代码
<template>
  <div class="container">
    <el-button
      :disabled="disabled"
      @mouseenter.native="handleSubmitMouseenter"
      @mouseleave.native="handleMouseleave"
    >
      提交
    </el-button>
    <el-button @click="disabled = !disabled"> 切换提交禁用状态 </el-button>
  </div>
</template>
<script>
import Tooltip from "@/utils/Tooltip";

export default {
  data() {
    return {
      disabled: true,
    };
  },
  created() {
    this.tooltip = new Tooltip({
      placement: "top",
      isOverflow: false
    });
  },
  beforeDestroy() {
    this.tooltip.destroy();
  },
  methods: {
    handleSubmitMouseenter(event) {
      this.tooltip.show(event.target, "当前未登录,不允许提交",{
        // 核心代码,动态禁用 tooltip
        disabled: !this.disabled,
      });
    },
    handleMouseleave() {
      this.tooltip.hide();
    },
  },
};
</script>
<style lang="scss" scoped>
.container {
  display: flex;
  flex-direction: column;
  align-items: center;
}
</style>

当我切换按钮的禁用状态时,就不会显示 tooltip 的提示信息了👇

vue 自定义指令

有了前面的 Tooltip 工具类的基础,实现自定义指令就非常简单了

javascript 复制代码
import Tooltip from "@/utils/Tooltip";

export default {
  bind(el, binding) {
    el._tooltip = new Tooltip(binding.value);
    
    el._handleMouseEnter = () => {
      const content = binding.value?.content || el.innerText || el.textContent;
      el._tooltip.show(el, content);
    };
    el._handleMouseLeave = () => {
      el._tooltip.hide();
    };

    el.addEventListener("mouseenter", el._handleMouseEnter);
    el.addEventListener("mouseleave", el._handleMouseLeave);
  },  
  componentUpdated(el, binding) {
    el._tooltip?.updateInstanceProps(binding.value);
  },
  unbind(el) {
    el._tooltip?.destroy();
    el.removeEventListener("mouseenter", el._handleMouseEnter);
    el.removeEventListener("mouseleave", el._handleMouseLeave);

    delete el._tooltip;
    delete el._handleMouseEnter;
    delete el._handleMouseLeave;
  },
};

实现的代码量是非常的少,具体的逻辑是

  1. 指令绑定元素时初始化 tooltip 实例
  2. 添加鼠标事件,在鼠标移入事件中调用 tooltip.show 方法
  3. componentUpdated 更新后调用 updateInstanceProps 更新 props
  4. 组件卸载时执行销毁操作即可

还是用刚刚上面的动态切换状态的示例演示

html 复制代码
<template>
  <div class="container">
    <el-button
      :disabled="disabled"
      v-tooltip="{
        isOverflow: false,
        disabled: !disabled,
        content: '当前未登录,不允许提交',
      }"
    >
      提交
    </el-button>

    <el-button @click="disabled = !disabled"> 切换提交禁用状态 </el-button>
  </div>
</template>
<script>
export default {
  data() {
    return {
      disabled: true,
    };
  },
};
</script>
<style lang="scss" scoped>
.container {
  display: flex;
  flex-direction: column;
  align-items: center;
}
</style>

可以看到当我动态切换提交按钮的禁用状态时,也是可以正常动态控制是否显示 tooltip 的消息

相关推荐
掘金安东尼2 小时前
⏰前端周刊第444期(2025年12月8日–12月14日)
前端
BD_Marathon2 小时前
Vue3_响应式数据和setup语法糖
javascript
李广山Samuel2 小时前
Node-OPCUA 入门(2)-创建一个简单的opcua客户端
javascript
长安牧笛2 小时前
开发课堂学生专注度分析程序,捕捉学生面部表情和动作,分析专注程度,帮助老师调整教学。
javascript
weixin_448119942 小时前
Datawhale Hello-Agents入门篇202512第2次作业
java·前端·javascript
BD_Marathon2 小时前
Vue3_事件渲染命令
开发语言·javascript·ecmascript
程序员爱钓鱼2 小时前
Node.js 编程实战:路由与中间件
前端·后端·node.js
程序员爱钓鱼2 小时前
Node.js 编程实战:Express 基础
前端·后端·node.js
周万宁.FoBJ2 小时前
在vite+Vue3项目中使用 自定义svg 图标,借助vite-plugin-svg-icons 封装SvgIcon组件
vue.js