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 的消息

相关推荐
崔庆才丨静觅9 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606110 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了10 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅10 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅11 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅11 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment11 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅11 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊11 小时前
jwt介绍
前端
爱敲代码的小鱼11 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax