记录--手写一个 v-tooltip 指令
这里给大家分享我在网上总结出来的一些知识,希望对大家有所帮助
前言
日常开发中,我们经常遇到过tooltip这种需求。文字溢出、产品文案、描述说明等等,每次都需要写一大串代码,那么有没有一种简单的方式呢,这回我们用指令来试试。
功能特性
- 支持
tooltip样式自定义 - 支持
tooltip内容自定义 - 动态更新
tooltip内容 - 文字省略自动出提示
 - 支持弹窗位置自定义和偏移
 
功能实现
在vue3中,指令也是拥有着对应的生命周期。

我们这里需要使用的是 mounted、updated和unmounted钩子。
import { DirectiveBinding } from 'vue'
export default {
  mounted(el: HTMLElement, binding: DirectiveBinding) {
  },
  updated(el: HTMLElement, binding: DirectiveBinding) {
  },
  unmounted(el: HTMLElement) {
  }
}
在元素挂载完成之后,我们需要完成上述指令的功能。
什么时候可用?
首先我们需要考虑的是tooltip什么时候可用?
- 元素是省略元素
 - 手动开启时,我们需要启用
tooltip,比如描述或者产品文案等等。 
如果是省略元素,我们需要先判断元素是否存在省略,一般通过这种方式判断:
function isOverflow(el: SpecialHTMLElement) {
  if (el.scrollWidth > el.offsetWidth || el.scrollHeight > el.clientHeight) {
    return true
  }
  return false
}
// element plus 采用如下方式判断,兼容 firefox
function isOverflow(el: SpecialHTMLElement){
  const range = document.createRange()
  range.setStart(el, 0)
  range.setEnd(el, el.childNodes.length)
  const rangeWidth = range.getBoundingClientRect().width
  const padding =
    (Number.parseInt(getComputedStyle(el)['paddingLeft'], 10) || 0) +
    (Number.parseInt(getComputedStyle(el)['paddingRight'], 10) || 0)
  if (
    rangeWidth + padding > el.offsetWidth ||
    el.scrollWidth > el.offsetWidth
  ) {
    return true
  }
  return false
}
我们也需要考虑手动开启这种情况,一般使用一个特殊的CSS属性开启。
const enable = el.getAttribute('enableTooltip')
内容构造和位置计算
tooltip开启之后,我们需要构造它的内容和动态计算tooltip的位置,比如元素发生缩放和滚动。
构造tooltip内容的话,我们采用一个vue组件,然后通过动态组件方式,将其挂载为tooltip的内容。
<template>
<div
ref="tooltipRef"
class="__CUSTOM_TOOLTIP_ITEM_CONTENT__"
:class="arrow"
@mouseover="mouseOver"
@mouseleave="mouseLeave"
v-html="content"
></div>
</template> <script lang="ts" setup>
import type { TimeoutHTMLElement } from './tooltip'
defineProps({
content: {
type: String,
default: '',
},
arrow: {
type: String,
default: '',
},
})
const tooltipRef = ref()
let parent: TimeoutHTMLElement
onMounted(() => {
parent = tooltipRef.value.parentElement
})
function mouseOver() {
clearTimeout(parent.__hide_timeout__)
parent.setAttribute('data-show', 'true')
parent.style.visibility = 'visible'
}
function mouseLeave() {
parent.setAttribute('data-show', 'false')
parent.style.visibility = 'hidden'
}
</script>
<style scoped lang="scss">
$radius: 8px;
@mixin arrow {
position: absolute;
border-style: solid;
border-width: $radius;
width: 0;
height: 0;
content: '';
} .__CUSTOM_TOOLTIP_ITEM_CONTENT__ {
position: absolute;
border-radius: 4px;
padding: 10px;
width: 100%;
max-width: 260px;
font-size: 12px;
color: #fff;
background: rgb(45 46 50 / 80%);
line-height: 18px; &.top::before {
@include arrow; top: $radius * (-2);
left: calc(50% - #{$radius});
border-color: transparent transparent rgb(45 46 50 / 80%) transparent;
} &.top-start::before .top-start::before {
@include arrow; top: $radius * (-2);
left: $radius;
border-color: transparent transparent rgb(45 46 50 / 80%) transparent;
} &.top-end::before &.top-end::before {
@include arrow; top: $radius * (-2);
left: calc(100% - #{$radius * 3});
border-color: transparent transparent rgb(45 46 50 / 80%) transparent;
}
}
</style>
slot方式自定义提示内容。当然也可以通过属性查询[slot='content']节点,取出其中的innerHTML,但是这种在更新时需要特殊处理。function parseSlot(vNode) {
  const content = vNode.children.find(i => {
    return i?.data?.slot === 'content'
  })
  const app = createApp({
    functional: true,
    props: {
      render: Function
    },
    render() {
      return this.render()
    }
  })
	const el = document.createElement('div')
  app.mount(el)
  return el?.innerHTML
}
tooltip位置计算和自动更新,这里我们使用@floating-ui/dom库。const __tooltip_el__ = document.createElement('div')
__tooltip_el__.className = '__CUSTOM_TOOLTIP__'
document.body.appendChild(__tooltip_el__)
function createEle() {
  const tooltip = document.createElement('div')
  tooltip.className = '__CUSTOM_TOOLTIP_ITEM__'
  tooltip.style['zIndex'] = '9999'
  tooltip.style['position'] = 'absolute'
  __tooltip_el__.appendChild(tooltip)
  return tooltip
}
function initTooltip(el: SpecialHTMLElement, binding: DirectiveBinding) {
  const tooltip = createEle()
  el.__float_tooltip__ = tooltip as unknown as TimeoutHTMLElement
  createTooltip(el, binding)
  autoUpdate(el, tooltip, () => updatePosition(el), {
    animationFrame: false,
    ancestorResize: false,
    elementResize: false,
    ancestorScroll: true,
  })
}
function createTooltip(el: SpecialHTMLElement, binding: DirectiveBinding) {
  const tooltip = el.__float_tooltip__ as HTMLElement
  const { width } = el.getBoundingClientRect()
  tooltip.style['minWidth'] = width + 'px'
  const arrow = el.getAttribute('arrow')
  // eslint-disable-next-line vue/one-component-per-file
  const app = createApp(tooltipVue, {
    arrow: arrow,
    content: binding.value !== void 0 ? binding.value : el.oldVNode,
  })
  app.mount(tooltip)
  el.__float_app__ = app
}
function updatePosition(el: SpecialHTMLElement) {
  const tooltip = el.__float_tooltip__
  const middlewares = []
  const visible = tooltip?.style?.visibility
  if (visible !== 'hidden' && visible) {
    const placement = el?.getAttribute('placement') || 'bottom'
    let offsetY =
      el?.getAttribute('offsetY') || el?.getAttribute('offset-y') || 5
    let offsetX = el?.getAttribute('offsetX') || el?.getAttribute('offset-x')
    const offsetXY = el?.getAttribute('offset')
    if (offsetXY !== null) {
      offsetX = offsetXY
      offsetY = offsetXY
    }
    if (offsetX || offsetY) {
      middlewares.push(
        offset({
          mainAxis: Number(offsetY),
          crossAxis: Number(offsetX),
        })
      )
    }
    computePosition(el, tooltip, {
      placement: placement as Placement,
      strategy: 'absolute',
      middleware: middlewares,
    }).then(({ x, y }) => {
      Object.assign(tooltip.style, {
        top: `${y}px`,
        left: `${x}px`,
      })
    })
  }
}
用户交互
在构造好tooltip之后,我们需要添加用户交互行为事件,比如用户移入目标元素,显示tooltip,移除目标元素,隐藏tooltip。这里我们加上hide-delay,即延迟隐藏,在设置offset时特别有用,同时也支持添加show-delay,延迟显示。
function attachEvent(el: HTMLElement) {
  el?.addEventListener?.('mouseover', mouseOver)
  el?.addEventListener?.('mouseleave', mouseLeave)
}
function mouseOver(evt: MouseEvent) {
  const el = evt.currentTarget as SpecialHTMLElement
  const tooltip = el?.__float_tooltip__
  clearTimeout(tooltip?.__hide_timeout__)
  if (tooltip) {
    tooltip.style.visibility = 'visible'
    tooltip.setAttribute('data-show', 'true')
    updatePosition(el)
  }
}
function mouseLeave(evt: MouseEvent) {
  const el = evt.currentTarget as SpecialHTMLElement
  const tooltip = el?.__float_tooltip__
  const isShow = tooltip?.getAttribute?.('data-show')
  const delay = el.getAttribute('hide-delay') || 100
  clearTimeout(tooltip?.__hide_timeout__)
  if (tooltip) {
    if (delay) {
      tooltip.__hide_timeout__ = setTimeout(() => {
        if (isShow === 'true') {
          tooltip.style.visibility = 'hidden'
        }
      }, +delay)
    } else {
      if (isShow === 'true') {
        tooltip.style.visibility = 'hidden'
      }
    }
  }
}
内容更新
我们tooltip的内容并不总是一成不变的,所以我们需要支持内容更新,这个可以在updated钩子中完成内容更新。
既然我们支持了指令传值和slot方式,所以我们需要考虑三点:
- 指令值变化
 slot内容变化- 开启和关闭
 
对于slot内容变化监测,我们可以对比新旧slot内容,内容不同则触发更新。
{
  updated(el: SpecialHTMLElement, binding: DirectiveBinding, vNode: VNode) {
    if (binding.value !== binding.oldValue) {
      updated(el, binding)
    } else {
      const enable = el.getAttribute('enableTooltip')
      if (enable !== el.oldEnable) {
        mounted(el, binding, vNode)
      } else {
        const newVNode = parseSlot(vNode)
        if (el.oldVNode !== newVNode) {
          el.oldVNode = newVNode
          updated(el, binding)
        }
      }
    }
  },
}
function updated(el: SpecialHTMLElement, binding: DirectiveBinding) {
  el?.__float_app__?.unmount?.()
  el.__float_app__ = null
  createTooltip(el, binding)
}
销毁tooltip
最后,在元素销毁或者tooltip关闭的的时候,我们需要把相应的事件等进行销毁。
function unmounted(el: SpecialHTMLElement) {
  removeEvent(el)
  const tooltip = el?.__float_tooltip__
  if (tooltip) {
    __tooltip_el__.removeChild(tooltip)
    el?.__float_app__?.unmount?.()
    el.__float_app__ = null
    el.__float_tooltip__ = null
  }
}
function removeEvent(el: HTMLElement) {
  el?.removeEventListener?.('mouseover', mouseOver)
  el?.removeEventListener?.('mouseleave', mouseLeave)
}
完整代码
import { DirectiveBinding, VNode, App } from 'vue'
import {
  computePosition,
  autoUpdate,
  offset,
  Placement,
} from '@floating-ui/dom'
import tooltipVue from './CustomTooltip.vue'
export type TimeoutHTMLElement = HTMLElement & {
  __hide_timeout__: NodeJS.Timeout
}
export type SpecialHTMLElement =
  | HTMLElement & {
      __float_tooltip__: TimeoutHTMLElement | null
    } & {
      __float_app__: App | null
    } & {
      oldEnable: string | null
    } & {
      oldVNode: string
    }
// tooltip 容器
const __tooltip_el__ = document.createElement('div')
__tooltip_el__.className = '__CUSTOM_TOOLTIP__'
document.body.appendChild(__tooltip_el__)
// 判断是否溢出
function isOverflow(el: SpecialHTMLElement) {
  if (el.scrollWidth > el.offsetWidth || el.scrollHeight > el.clientHeight) {
    return true
  }
  return false
}
// 清除 slot
function emptySlot(el: SpecialHTMLElement) {
  const slot = el.querySelector("[slot='content']")
  if (slot) {
    el.removeChild(slot)
  }
  return slot?.innerHTML
}
// 卸载
function unmounted(el: SpecialHTMLElement) {
  removeEvent(el)
  const tooltip = el?.__float_tooltip__
  if (tooltip) {
    __tooltip_el__.removeChild(tooltip)
    el?.__float_app__?.unmount?.()
    el.__float_app__ = null
    el.__float_tooltip__ = null
  }
}
// 移除事件
function removeEvent(el: SpecialHTMLElement) {
  el?.removeEventListener?.('mouseover', mouseOver)
  el?.removeEventListener?.('mouseleave', mouseLeave)
}
// 添加事件
function attachEvent(el: SpecialHTMLElement) {
  el?.addEventListener?.('mouseover', mouseOver)
  el?.addEventListener?.('mouseleave', mouseLeave)
}
// 鼠标悬浮
function mouseOver(evt: MouseEvent) {
  const el = evt.currentTarget as SpecialHTMLElement
  const tooltip = el?.__float_tooltip__
  clearTimeout(tooltip?.__hide_timeout__)
  if (tooltip) {
    tooltip.style.visibility = 'visible'
    tooltip.setAttribute('data-show', 'true')
    updatePosition(el)
  }
}
// 鼠标移出
function mouseLeave(evt: MouseEvent) {
  const el = evt.currentTarget as SpecialHTMLElement
  const tooltip = el?.__float_tooltip__
  const isShow = tooltip?.getAttribute?.('data-show')
  const delay = el.getAttribute('hide-delay') || 100
  clearTimeout(tooltip?.__hide_timeout__)
  if (tooltip) {
    if (delay) {
      tooltip.__hide_timeout__ = setTimeout(() => {
        if (isShow === 'true') {
          tooltip.style.visibility = 'hidden'
        }
      }, +delay)
    } else {
      if (isShow === 'true') {
        tooltip.style.visibility = 'hidden'
      }
    }
  }
}
// 挂载tooltip
function mounted(
  el: SpecialHTMLElement,
  binding: DirectiveBinding,
  vNode: VNode
) {
  const overflow = isOverflow(el)
	// 手动启用tooltip
  const enable = el.getAttribute('enableTooltip')
  el.oldEnable = enable
  if (binding.value === void 0 && vNode) {
    el.oldVNode = parseSlot(vNode)
  }
  emptySlot(el)
  // 显示延迟
  const delay = el.getAttribute('show-delay') || 100
  if (overflow || enable === 'true') {
    if (delay) {
      setTimeout(() => {
        initTooltip(el, binding)
        attachEvent(el)
      }, +delay)
    } else {
      initTooltip(el, binding)
      attachEvent(el)
    }
  } else {
    unmounted(el)
  }
}
// 更新tooltip 只更新内容
function updated(el: SpecialHTMLElement, binding: DirectiveBinding) {
  el?.__float_app__?.unmount?.()
  el.__float_app__ = null
  createTooltip(el, binding)
}
// 创建元素工厂
function createEle() {
  const tooltip = document.createElement('div')
  tooltip.className = '__CUSTOM_TOOLTIP_ITEM__'
  tooltip.style['zIndex'] = '9999'
  tooltip.style['position'] = 'absolute'
  __tooltip_el__.appendChild(tooltip)
  return tooltip
}
// 初始化tooltip:创建和计算位置
function initTooltip(el: SpecialHTMLElement, binding: DirectiveBinding) {
  const tooltip = createEle()
  el.__float_tooltip__ = tooltip as unknown as TimeoutHTMLElement
  createTooltip(el, binding)
  autoUpdate(el, tooltip, () => updatePosition(el), {
    animationFrame: false,
    ancestorResize: false,
    elementResize: false,
    ancestorScroll: true,
  })
}
// 创建tooltip
function createTooltip(el: SpecialHTMLElement, binding: DirectiveBinding) {
  const tooltip = el.__float_tooltip__ as HTMLElement
  const { width } = el.getBoundingClientRect()
  tooltip.style['minWidth'] = width + 'px'
  const arrow = el.getAttribute('arrow')
  // eslint-disable-next-line vue/one-component-per-file
  const app = createApp(tooltipVue, {
    arrow: arrow,
    content: binding.value !== void 0 ? binding.value : el.oldVNode,
  })
  app.mount(tooltip)
  el.__float_app__ = app
}
// 更新tooltip位置
function updatePosition(el: SpecialHTMLElement) {
  const tooltip = el.__float_tooltip__
  const middlewares = []
  const visible = tooltip?.style?.visibility
  if (visible !== 'hidden' && visible) {
    const placement = el?.getAttribute('placement') || 'bottom'
    let offsetY =
      el?.getAttribute('offsetY') || el?.getAttribute('offset-y') || 5
    let offsetX = el?.getAttribute('offsetX') || el?.getAttribute('offset-x')
    const offsetXY = el?.getAttribute('offset')
    if (offsetXY !== null) {
      offsetX = offsetXY
      offsetY = offsetXY
    }
    if (offsetX || offsetY) {
      middlewares.push(
        offset({
          mainAxis: Number(offsetY),
          crossAxis: Number(offsetX),
        })
      )
    }
    computePosition(el, tooltip, {
      placement: placement as Placement,
      strategy: 'absolute',
      middleware: middlewares,
    }).then(({ x, y }) => {
      Object.assign(tooltip.style, {
        top: `${y}px`,
        left: `${x}px`,
      })
    })
  }
}
// 解析slot
function parseSlot(vNode: VNode) {
  const content = (vNode.children as VNode[]).find?.((i: VNode) => {
    return i?.props?.slot === 'content'
  })
  // eslint-disable-next-line vue/one-component-per-file
  const app = createApp(
    {
      functional: true,
      props: {
        render: Function,
      },
      render() {
        return this.render()
      },
    },
    // eslint-disable-next-line vue/one-component-per-file
    {
      render: () => {
        return content
      },
    }
  )
  const el = document.createElement('div')
  app.mount(el)
  return el?.innerHTML
}
export default {
  mounted(el: SpecialHTMLElement, binding: DirectiveBinding, vNode: VNode) {
    mounted(el, binding, vNode)
  },
  updated(el: SpecialHTMLElement, binding: DirectiveBinding, vNode: VNode) {
    if (binding.value !== binding.oldValue) {
      updated(el, binding)
    } else {
      const enable = el.getAttribute('enableTooltip')
      if (enable !== el.oldEnable) {
        mounted(el, binding, vNode)
      } else {
        const newVNode = parseSlot(vNode)
        if (el.oldVNode !== newVNode) {
          el.oldVNode = newVNode
          updated(el, binding)
        }
      }
    }
  },
  unmounted(el: SpecialHTMLElement) {
    unmounted(el)
  },
}
示例
<div v-tooltip='hello world' enableTooltip='true'>tooltip</div> <div v-tooltip enableTooltip='true'>
tooltip
<div slot='content'>
<div>this is a tooltip</div>
<button>confirm</button>
</div>
</div>
总结
在经过二次封装之后,我们只需要v-tooltip这样简便的操作,即可达到tooltip的作用,简化了传统的书写流程,对于一些特殊tooltip内容,我们可以通过slot方式,定制化更多的提示内容。
本文转载于:
https://juejin.cn/post/7177384845968932901
如果对您有所帮助,欢迎您点个关注,我会定时更新技术文档,大家一起讨论学习,一起进步。

记录--手写一个 v-tooltip 指令的更多相关文章
- 『练手』手写一个独立Json算法 JsonHelper
		
背景: > 一直使用 Newtonsoft.Json.dll 也算挺稳定的. > 但这个框架也挺闹心的: > 1.影响编译失败:https://www.cnblogs.com/zih ...
 - 放弃antd table,基于React手写一个虚拟滚动的表格
		
缘起 标题有点夸张,并不是完全放弃antd-table,毕竟在react的生态圈里,对国人来说,比较好用的PC端组件库,也就antd了.即便经历了2018年圣诞彩蛋事件,antd的使用者也不仅不减,反 ...
 - 搞定redis面试--Redis的过期策略?手写一个LRU?
		
1 面试题 Redis的过期策略都有哪些?内存淘汰机制都有哪些?手写一下LRU代码实现? 2 考点分析 1)我往redis里写的数据怎么没了? 我们生产环境的redis怎么经常会丢掉一些数据?写进去了 ...
 - 剖析手写Vue,你也可以手写一个MVVM框架
		
剖析手写Vue,你也可以手写一个MVVM框架# 邮箱:563995050@qq.com github: https://github.com/xiaoqiuxiong 作者:肖秋雄(eddy) 温馨提 ...
 - 手写一个LRU工具类
		
LRU概述 LRU算法,即最近最少使用算法.其使用场景非常广泛,像我们日常用的手机的后台应用展示,软件的复制粘贴板等. 本文将基于算法思想手写一个具有LRU算法功能的Java工具类. 结构设计 在插入 ...
 - 手写一个线程池,带你学习ThreadPoolExecutor线程池实现原理
		
摘要:从手写线程池开始,逐步的分析这些代码在Java的线程池中是如何实现的. 本文分享自华为云社区<手写线程池,对照学习ThreadPoolExecutor线程池实现原理!>,作者:小傅哥 ...
 - 教你如何使用Java手写一个基于链表的队列
		
在上一篇博客[教你如何使用Java手写一个基于数组的队列]中已经介绍了队列,以及Java语言中对队列的实现,对队列不是很了解的可以我上一篇文章.那么,现在就直接进入主题吧. 这篇博客主要讲解的是如何使 ...
 - 【spring】--  手写一个最简单的IOC框架
		
1.什么是springIOC IOC就是把每一个bean(实体类)与bean(实体了)之间的关系交给第三方容器进行管理. 如果我们手写一个最最简单的IOC,最终效果是怎样呢? xml配置: <b ...
 - 只会用就out了,手写一个符合规范的Promise
		
Promise是什么 所谓Promise,简单说就是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果.从语法上说,Promise 是一个对象,从它可以获取异步操作的消息.Prom ...
 - 利用SpringBoot+Logback手写一个简单的链路追踪
		
目录 一.实现原理 二.代码实战 三.测试 最近线上排查问题时候,发现请求太多导致日志错综复杂,没办法把用户在一次或多次请求的日志关联在一起,所以就利用SpringBoot+Logback手写了一个简 ...
 
随机推荐
- CentOS7上systemctl的使用
			
CentOS 7.x开始,CentOS开始使用systemd服务来代替daemon,原来管理系统启动和管理系统服务的相关命令全部由systemctl命令来代替. 1.原来的 service 命令与 s ...
 - C# 二十年语法变迁之 C# 7参考
			
C# 二十年语法变迁之 C# 7参考 https://benbowen.blog/post/two_decades_of_csharp_iii/ 自从 C# 于 2000 年推出以来,该语言的规模已经 ...
 - Centos中报错apt Command  not Found
			
先说结论: 在centos下用yum install xxxyum和apt-get的区别: 一般来说著名的linux系统基本上分两大类: RedHat系列:Redhat.Centos.Fedora等 ...
 - MYSQL TIMESTAMP自动更新问题
			
某张表格里有2个TIMESTAMP类型,time1.time2;建表时time1默认NOT NULL ,time2默认NULL; 之后出现了问题:当只修改time2字段,不操作time1时:time1 ...
 - lock锁,Semaphore信号量,Event事件,进程队列Queue,生产者消费者模型,JoinableQueue---day31
			
1.lock锁 # ### 锁 lock from multiprocessing import Process,Lock import json,time # (1) lock的基本语法 " ...
 - Kotlin 协程五 —— 在Android 中使用 Kotlin 协程
			
目录 一.Android MVVM 结构 二.添加依赖 三.在后台线程中执行 3.1 协程解决了什么问题 3.2 保证主线程安全 3.3 withContext 的性能 四.结构化并发 4.1 追踪协 ...
 - 被 AI 替代应该就在不远的将来
			
提问:golang 各种图片 转 webp 代码 一秒之后...... package main import ( "fmt" "image" "im ...
 - OpenCV计数应用 c++(QT)
			
一.前言 为了挑战一下OpenCV的学习成果,最经一直在找各类项目进行实践.机缘巧合之下,得到了以下的需求: 要求从以下图片中找出所有的近似矩形的点并计数,重叠点需要拆分单独计数. 二.解题思路 1. ...
 - ABP Suite创建新项目
			
启动Abp Suite ********************************************************************** ** Visual Studio ...
 - 【Azure Function】Azure Function中使用 Java 8 的安全性问题
			
问题描述 使用Azure Function, 环境是Linux的Java8.目前 Oracle Java JDK8,11,17 和 OpenJDK 8/11/17 都在存在漏洞受影响版本的范围内. O ...
 
			
		