需求背景
给元素溢出添加省略号并设置 tooltip 提示,相比 90% 的同学都遇到过吧,我也不例外。以前也做过同样的功能,但是当年并没有考虑太多。现如今再次遇到这样的需求,我发现这样的功能是普遍又常见的,于是封装了这样一个简单的自定义指令。并让他支持自动检查是否溢出,只有溢出的时候才会显示 tooltip 组件。
技术背景
- Vue2
- element-ui -> el-tooltip
基本需求
- 全局只有一个 el-tooltip 组件
- 支持 el-tooltip 组件所有配置
- el-tooltip 不具备校验内容是否溢出的功能,我们需要
- 封装为 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
);
}
- 使用 createRange 函数创建一个 Range 实例
- 使用 range.setStart 跟 range.setEnt 设置 range 的片段范围,可以理解为添加的内容
- 此时 range 就已经存入了需要检查是否溢出的目标元素的所有节点内容,然后调用 getBoundingClientRect 函数获取内容的实际宽度
- 使用 getComputedStyle 获取目标元素的左右内边距
- 用 rangeWidth + padding > el.offsetWidth 校验元素是否溢出
- 使用 el.scrollWidth > el.offsetWidth 兜底校验
创建 Tooltip 工具类
我这里使用 es6 的 class 来实现,传统的 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];
}
}
}
}
在上述代码中,我将核心的代码都已经加上了注释,大家查看代码时直接看详细注释即可
问题 :上述代码中存在两个弊端
- 由于是单例模式,所以在创建多次 Tooltip 时,最终 Tooltip 的配置会被覆盖,是否应该如此?
- 在使用 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 设置为 false 让 tooltip 不需要校验是否溢出
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;
},
};
实现的代码量是非常的少,具体的逻辑是
- 指令绑定元素时初始化 tooltip 实例
- 添加鼠标事件,在鼠标移入事件中调用 tooltip.show 方法
- 在 componentUpdated 更新后调用 updateInstanceProps 更新 props
- 组件卸载时执行销毁操作即可
还是用刚刚上面的动态切换状态的示例演示
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 的消息