vue3 使用自定义指令制作简易Tooltip组件

5,680 阅读4分钟

Tooltip 组件通常用于对页面上一些元素进行解释, 个人认为一般用于快速解读一些可以简言易概的实物, 比较轻量级.

已有UI组件库

首先向各前辈表示敬意, 给我的组件封装提供了很多灵感. 🖖🖖🖖(敬礼)

但是一些主流的UI库, 例如ElementUI和Bootstrap等中的 Tooltip需要使用组件包裹, 个人觉得比较麻烦, 所以今天使用 Vue3.x 的自定义指令封装一个简易的 Tooltip 组件.

效果与使用方式

屏幕录制2021-12-20 上午10.gif 如图, 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 主要注册了指令, 这里要注意的是vue3vue2之间组件注册存在的差别.

export default {
    install(app) {
        // 这里的生命周期钩子与 vue2 有所区别
        app.directive('tooltip', {
            mounted(el, binding) {
                ...
            },
            updated(el, binding) {
                ...
            },
            unmounted(el) {
                ...
            }
        }
    }
}

mounted()

mounted中我们主要:

  1. 创建 Tooltip 实例并挂载到页面上.
  2. 接收指令传来的提示内容以及提示方向.
  3. 根据被绑定元素计算 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中我们主要:

  1. 卸载 Tooltip 实例.
  2. 移除各监听事件.
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>

可能会用到的网址

i18n

vue3指令生命周期钩子

可以扩充的地方

v-tooltip.left="Obejct": 本文的 tooltip 组件还很多可以扩充的地方, 例如可以传入Obejct类型的数据用于配置更多项目, 只需要在 directive.js 中判断binding.value的类型是字符串还是对象.

本文所设计的tooltip组件在第一次对目标元素的悬停之后会创建唯一的tooltip元素显示, 并且在下一次悬停只是会更新已存在的tooltip, 最大程度优化性能.

当然修改亿点点代码即可让页面同时只有一个tooltip显示.

您的点赞是我更文的最大动力, 如果您对tooltip组件优化有什么更好的提议欢迎在评论区评论.