记录–手写一个 v-tooltip 指令

  • 记录–手写一个 v-tooltip 指令已关闭评论
  • 98 次浏览
  • A+
所属分类:Web前端
摘要

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


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

记录--手写一个 v-tooltip 指令

前言

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

功能特性

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

功能实现

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

记录--手写一个 v-tooltip 指令

 我们这里需要使用的是 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 采用如下方式判断,兼容 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 指令