这里给大家分享我在网上总结出来的一些知识,希望对大家有所帮助前言

日常开发中,我们经常遇到过tooltip这种需求。文字溢出、产品文案、描述说明等等,每次都需要写一大串代码,那么有没有一种简单的方式呢,这回我们用指令来试试。

功能特性

  • 支持tooltip样式自定义
  • 支持tooltip内容自定义
  • 动态更新tooltip内容
  • 文字省略自动出提示
  • 支持弹窗位置自定义和偏移

功能实现

vue3中,指令也是拥有着对应的生命周期。

我们这里需要使用的是mountedupdatedunmounted钩子。

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 采用如下方式判断,兼容 firefoxfunction 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的内容。

  
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' } $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; } }

此外我们也可以通过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}// 清除 slotfunction 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'      }    }  }}// 挂载tooltipfunction 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,  })}// 创建tooltipfunction 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`,      })    })  }}// 解析slotfunction 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)  },}

示例

tooltip
tooltip
this is a tooltip

总结

在经过二次封装之后,我们只需要v-tooltip这样简便的操作,即可达到tooltip的作用,简化了传统的书写流程,对于一些特殊tooltip内容,我们可以通过slot方式,定制化更多的提示内容。

本文转载于:https://juejin.cn/post/7177384845968932901如果对您有所帮助,欢迎您点个关注,我会定时更新技术文档,大家一起讨论学习,一起进步。