Tooltip
组件通常用于对页面上一些元素进行解释, 个人认为一般用于快速解读一些可以简言易概的实物, 比较轻量级.
已有UI组件库
首先向各前辈表示敬意, 给我的组件封装提供了很多灵感. 🖖🖖🖖(敬礼)
但是一些主流的UI库, 例如ElementUI和Bootstrap等中的 Tooltip
均需要使用组件包裹, 个人觉得比较麻烦, 所以今天使用 Vue3.x 的自定义指令封装一个简易的 Tooltip
组件.
效果与使用方式
如图, Tooltip
是支持类似 i18n
这种可能会动态更新参数的, 并且使用起来更加方便快捷:
<template>
<!-- 使用自定义指令 v-tooltip 快速创建 tooltip 提示框 -->
<button v-tooltip.left="t('like')">点赞<button>
</template>
<script>
import { useI18n } from 'vue-i18n'
export default {
setup(){
const { t } = useI18n();
return {
t
}
}
}
</script>
<!-- 这里使用的 i18n 单文件模式, 这里不做解释, 如有问题可以在评论区讨论 -->
<i18n>
{
"zh_CN": {
"like": "一键三连"
},
"en_US": {
"like": "Like me"
}
}
</i18n>
其他姿势
<button v-tooltip.left="'显示在左边'">点赞<button>
<button v-tooltip.right="'显示在右边'">点赞<button>
<button v-tooltip.top="'显示在上部'">点赞<button>
<button v-tooltip.bottom="'显示在下部'">点赞<button>
目录格式
tooltip
|
| -- directive.js // 在这里注册自定义指令 & 定位 tooltip
|
| -- tooltip.vue // 在这里写 tooltip 的模版
tooltip.vue
这里没什么好说的, 主要定义好模版, 将可以用于控制位置属性暴露出去.
<template>
<!-- 指示 -->
<div class="zc-tooltip" v-show="tooltipShow" :style="tooltipStyle">
<!-- 指示内容 -->
<span class="zc-tooltip-text" v-html="text"></span>
<!-- 小箭头 -->
<div class="zc-tooltip-arrow" :class="[{'left':placements=='left'},
{'bottom':placements=='bottom'},
{'right':placements=='right'},
{'top':placements=='top'}]"></div>
</div>
</template>
<script>
import {ref, computed} from 'vue'
export default {
setup(){
// 显示弹框
const tooltipShow = ref(false);
// 提示内容
const text = ref()
// 显示方向
const placements = ref('left')
// 显示
function showTip(){
tooltipShow.value = true
}
// 隐藏
function hiddenTip(){
tooltipShow.value = false
}
// 位置
const tooltipPostiton = ref({
x: 0,
y: 0
})
const tooltipStyle = computed(()=>{
return {
transform: `translate3d(${tooltipPostiton.value.x}px,${tooltipPostiton.value.y}px,0)`
}
})
return {
tooltipShow,
showTip,
hiddenTip,
tooltipPostiton,
tooltipStyle,
text,
placements,
}
}
}
</script>
🌟🌟🌟 解决鼠标在 Tooltip 边缘时可能会造成 “闪动” 🌟🌟🌟
造成闪动的原因很简单:
graph TB
A[当鼠标悬停在按钮上] --> B[显示Tooltip] --> C{鼠标是否在按钮边缘}
C==是==>D[因为Tooltip显示, 鼠标悬停在了Tooltip] -->F[按钮失去焦点] --> G[Tooltip消失]==无限循环==>A
C==否==>E[正常显示]
防抖动最常用的方式就是给 150ms 左右的延时, 但是我今天更 简单粗暴
. css 中有一个属性 pointer-events
: 指定在什么情况下 (如果有) 某个特定的元素可以成为鼠标事件的, 如果参数设置为none
, 那么浏览器会忽略当前元素的鼠标事件.
/* 使得元素忽略鼠标事件 */
.zc-tooltip{
...
pointer-events: none;
}
directive.js
directive.js
主要注册了指令, 这里要注意的是vue3与vue2之间组件注册存在的差别.
export default {
install(app) {
// 这里的生命周期钩子与 vue2 有所区别
app.directive('tooltip', {
mounted(el, binding) {
...
},
updated(el, binding) {
...
},
unmounted(el) {
...
}
}
}
}
mounted()
mounted中我们主要:
- 创建
Tooltip
实例并挂载到页面上. - 接收指令传来的提示内容以及提示方向.
- 根据被绑定元素计算
Tooltip
在页面中的位置.
// 控制方向
const allPlacements = ['left', 'bottom', 'right', 'top']
...
mounted(el, binding) {
// 获取提示内容
el._tipOptions = binding.value
// 当鼠标移入目标元素
el._tipHandler = () => {
// 从指令modifiers中获取正确的显示方向
const limitPlacementQueue = allPlacements.filter(placement => binding.modifiers[placement])
const placements = limitPlacementQueue.length ? limitPlacementQueue : allPlacements
// 如果当前没有实例存在
if (!el._tipInstance) {
// 创建tooltip实例
el._synopsis = createApp(tooltip)
// 创建根元素
el._root = document.createElement('div')
// 挂载到页面
document.body.appendChild(el._root)
el._tipInstance = el._synopsis.mount(el._root)
}
// 设置 tooltip 显示方向
el._tipInstance.placements = placements[0]
// 使 tooltip 显示
el._tipInstance.showTip()
// 设置 tooltip 显内容
el._tipInstance.text = el._tipOptions
nextTick(() => {
// 计算 tooltip 在页面中的位置
calculationLocation(el._tipInstance, el, placements[0])
})
// 当发送滚动事件时
el._scrollHandler = () => {
if (el._tipInstance.tooltipShow)
// 重新定位位置
calculationLocation(el._tipInstance, el, placements[0])
}
// 添加页面滚动监听
window.addEventListener('scroll', el._scrollHandler)
}
// 档鼠标移出目标元素
el._tipMouseleaveHandler = () => {
if (el._tipInstance) {
// 使 tooltip 隐藏
el._tipInstance.hiddenTip()
}
}
// 对目标元素添加鼠标监听
el.addEventListener('mouseenter', el._tipHandler)
el.addEventListener('mouseleave', el._tipMouseleaveHandler)
},
updated()
updated中我们主要更新提示内容以应对可能存在的参数变化.
updated(el, binding) {
// 更新提示内容
el._tipOptions = binding.value
},
unmounted()
unmounted中我们主要:
- 卸载
Tooltip
实例. - 移除各监听事件.
unmounted(el) {
if (el._tipInstance) {
// 卸载 Tooltip 实例.
el._synopsis.unmount()
document.body.removeChild(el._root)
}
// 移除各监听事件
window.removeEventListener('scroll', el._scrollHandler)
}
源码展示
tooltip.vue
<template>
<!-- 指示 -->
<transition name="tooltip">
<div class="zc-tooltip" v-show="tooltipShow" :style="tooltipStyle"
>
<span class="zc-tooltip-text" v-html="text"></span>
<div class="zc-tooltip-arrow" :class="[{'left':placements=='left'},
{'bottom':placements=='bottom'},
{'right':placements=='right'},
{'top':placements=='top'}]"></div>
</div>
</transition>
</template>
<script>
import {ref, computed} from 'vue'
export default {
setup(){
// 显示弹框
const tooltipShow = ref(false);
// 提示内容
const text = ref()
// 方向
const placements = ref('left')
// 显示
function showTip(){
tooltipShow.value = true
}
function hiddenTip(){
tooltipShow.value = false
}
// 位置
const tooltipPostiton = ref({
x: 0,
y: 0
})
const tooltipStyle = computed(()=>{
return {
transform: `translate3d(${tooltipPostiton.value.x}px,${tooltipPostiton.value.y}px,0)`
}
})
return {
tooltipShow,
showTip,
hiddenTip,
tooltipPostiton,
tooltipStyle,
text,
placements,
}
}
}
</script>
<style lang="scss" scoped>
// tooltip
.zc-tooltip{
padding: 10px;
font-size: 12px;
line-height: 1.2;
min-width: 10px;
word-wrap: break-word;
position: fixed;
left: 0;
top: 0;
background: #303133;
color: #fff;
z-index: 1000;
display: inline-block;
border-radius: 8px;
font-weight: 500;
pointer-events: none;
}
// 小箭头
.zc-tooltip-arrow{
position: absolute;
width: 0;
height: 0;
border-width: 8px;
border-style: solid;
}
// 如果在左侧
.zc-tooltip-arrow.left{
border-color: transparent transparent transparent #303133;
right: -15px;
top: 50%;
transform: translate3d(0,-50%,0);
}
// 如果在下侧
.zc-tooltip-arrow.bottom{
top: -15px;
border-color: transparent transparent #303133 transparent;
left: 50%;
transform: translate3d(-50%,0,0);
}
// 如果在右侧
.zc-tooltip-arrow.right{
left: -15px;
top: 50%;
transform: translate3d(0,-50%,0);
border-color: transparent #303133 transparent transparent;
}
// 如果在上侧
.zc-tooltip-arrow.top{
bottom: -15px;
border-color: #303133 transparent transparent transparent;
left: 50%;
transform: translate3d(-50%,0,0);
}
/* 动画 */
.tooltip-enter-from,
.tooltip-leave-to{
opacity: 0;
transition: opacity .3s ease;
}
.tooltip-leave-from,
.tooltip-enter-to{
transition: opacity .1s ease;
}
</style>
directive.js
// 引入组件
import { nextTick, createApp } from "vue";
import tooltip from './tooltip.vue'
import { tokenFun } from '../../utils/token'
// 清除监听
function clearEvent(el) {
if (el._tipHandler) {
el.removeEventListener('mouseenter', el._tipHandler)
}
if (el._tipMouseleaveHandler) {
el.removeEventListener('mouseleave', el._tipMouseleaveHandler)
}
delete el._tipHandler
delete el._tipMouseleaveHandler
delete el._tipOptions
delete el._tipInstance
}
// 位置定位
function calculationLocation(el, target, placements) {
if (!el || !target) return;
el.tooltipPostiton.y = 0;
el.tooltipPostiton.x = 0;
let el_dom = el.$el.nextElementSibling.getBoundingClientRect()
let target_dom = target.getBoundingClientRect()
if (placements === "left") {
el.tooltipPostiton.x = target_dom.x - el_dom.width - 10
el.tooltipPostiton.y = target_dom.y - el_dom.height / 2 + target_dom.height / 2
} else if (placements === "bottom") {
el.tooltipPostiton.x = target_dom.x + target_dom.width / 2 - el_dom.width / 2
el.tooltipPostiton.y = target_dom.y + el_dom.height + 10
} else if (placements === "right") {
el.tooltipPostiton.x = target_dom.x + target_dom.width + 10
el.tooltipPostiton.y = target_dom.y - el_dom.height / 2 + target_dom.height / 2
} else if (placements === "top") {
el.tooltipPostiton.x = target_dom.x + target_dom.width / 2 - el_dom.width / 2
el.tooltipPostiton.y = target_dom.y - el_dom.height - 10
}
}
// 方向
const allPlacements = ['left', 'bottom', 'right', 'top']
export default {
install(app) {
app.directive('tooltip', {
mounted(el, binding) {
clearEvent(el)
el._tipOptions = binding.value
el._tipHandler = () => {
const limitPlacementQueue = allPlacements.filter(placement => binding.modifiers[placement])
const placements = limitPlacementQueue.length ? limitPlacementQueue : allPlacements
if (!el._tipInstance) {
el._synopsis = createApp(tooltip)
el._root = document.createElement('div')
document.body.appendChild(el._root)
el._root.id = `tooltip_${tokenFun()}`
el._tipInstance = el._synopsis.mount(el._root)
}
el._tipInstance.placements = placements[0]
el._tipInstance.showTip()
el._tipInstance.text = el._tipOptions
nextTick(() => {
calculationLocation(el._tipInstance, el, placements[0])
})
el._scrollHandler = () => {
if (el._tipInstance.tooltipShow)
calculationLocation(el._tipInstance, el, placements[0])
}
window.addEventListener('scroll', el._scrollHandler)
}
el._tipMouseleaveHandler = () => {
if (el._tipInstance) {
el._tipInstance.hiddenTip()
}
}
el.addEventListener('mouseenter', el._tipHandler)
el.addEventListener('mouseleave', el._tipMouseleaveHandler)
},
updated(el, binding) {
el._tipOptions = binding.value
},
unmounted(el) {
if (el._tipInstance) {
el._synopsis.unmount()
document.body.removeChild(el._root)
}
window.removeEventListener('scroll', el._scrollHandler)
}
})
}
}
token.js
用于给每个 tooltip
分配唯一ID, 可以忽略
function node() {
return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
}
export function tokenFun() {
return (node() + node() + node());
}
main.js
import { createApp } from 'vue'
import App from './App.vue'
// 安装tooltip
import tooltip from './components/tooltip/directive'
const app = createApp(App)
app.use(tooltip).mount('#app')
快捷使用
<button v-tooltip.left="'显示在左边'">求赞<button>
<button v-tooltip.right="'显示在右边'">求赞<button>
<button v-tooltip.top="'显示在上部'">求赞<button>
<button v-tooltip.bottom="'显示在下部'">求赞<button>
可能会用到的网址
可以扩充的地方
v-tooltip.left="Obejct"
: 本文的 tooltip 组件还很多可以扩充的地方, 例如可以传入Obejct类型的数据用于配置更多项目, 只需要在 directive.js
中判断binding.value的类型是字符串还是对象.
本文所设计的tooltip
组件在第一次对目标元素的悬停之后会创建唯一的tooltip
元素显示, 并且在下一次悬停只是会更新已存在的tooltip
, 最大程度优化性能.
当然修改亿点点代码即可让页面同时只有一个tooltip
显示.
您的点赞是我更文的最大动力, 如果您对
tooltip
组件优化有什么更好的提议欢迎在评论区评论.