Skip to content
文章目录

工具提示组件

需要提前掌握的知识

floating-ui 使用

特点

  • tooltip 的 dom 节点是与应用根节点平级的节点
  • tooltip 的 dom 节点仅在显示时创建,在隐藏时立刻销毁,避免渲染过多 dom 节点

示例

说明
CustomTooltip.vue中的tooltip被嵌入到UseCustomTooltip.vue所在的dom了,这不是我们想要的,期望是tooltip节点渲染到body,CustomTooltip2.vue实现了该方式(将tooltip渲染到了body),如果对于简单的tooltip提示,应该让使用者像使用title属性一样使用,因此应该使用自定义指令实现

推荐封装方式

slot 方式封装

CustomTooltip2.vue: 该方案采用 slot 方式封装,适合在 tooltip 内容是复杂内容时使用此种封装方式,且 tooltip 的 dom 会被创建为 body 的直属子节点,且 tooltip 是实时创建和销毁的

vue
<!--

@author: pan
@createDate: 2022-12-09 10:39
-->
<script setup lang="ts">
import { onMounted, ref, toRefs, useSlots, VNode } from 'vue'
import { computePosition, flip, offset, shift } from '@floating-ui/dom'
import factory from './StatefulRenderCompFactory'

export type Side = 'top' | 'right' | 'bottom' | 'left'

const props = withDefaults(
  defineProps<{
    /**
     * 提示内容
     */
    content: string
    /**
     * 提示出现的位置.默认: bottom
     */
    placement?: Side
  }>(),
  { placement: 'bottom' }
)

const { placement } = toRefs(props)
const tooltipDomRef = ref<HTMLDivElement>()
const showTooltip = ref<boolean>(false)

function createOrUpdateTooltip(refrenceDom: HTMLElement) {
  const tooltipDom = tooltipDomRef.value as HTMLDivElement
  if (undefined === refrenceDom || undefined === tooltipDom) {
    return
  }
  computePosition(refrenceDom, tooltipDom, {
    placement: placement.value,
    middleware: [flip(), shift(), offset(6)],
  }).then(({ x, y }) => {
    Object.assign(tooltipDom.style, {
      top: `${y}px`,
      left: `${x}px`,
    })
  })
}

function onMouseEnter(defaultSlotEl: HTMLElement) {
  showTooltip.value = true
  setTimeout(() => {
    createOrUpdateTooltip(defaultSlotEl)
  }, 0)
}
function onMouseLeave() {
  showTooltip.value = false
}
const $slots = useSlots()
const defaultSlotVNode = $slots.default && ($slots.default()[0] as VNode)
function onDefaultSlotVNodeMounted(defaultSlotEl: HTMLElement) {
  const call = () => onMouseEnter(defaultSlotEl)
  defaultSlotEl.addEventListener('mouseenter', call)
  defaultSlotEl.addEventListener('mouseleave', onMouseLeave)
  // @ts-ignore
  defaultSlotEl._removeEventListener = () => {
    defaultSlotEl.removeEventListener('mouseenter', call)
    defaultSlotEl.removeEventListener('mouseleave', onMouseLeave)
  }
}
function onDefaultSlotVNodeUnmounted(defaultSlotEl: HTMLElement) {
  // @ts-ignore
  if (defaultSlotEl._removeEventListener) {
    // @ts-ignore
    defaultSlotEl._removeEventListener()
  }
}
const StatefulCompRender = factory({ mountedCallFun: onDefaultSlotVNodeMounted })
</script>

<template>
  <!-- 下面这段代码用来代替<slot></slot>方式渲染, 为了能够在不添加任何额外节点的情况下,获取到slot对应的真实dom -->
  <StatefulCompRender :vnode="defaultSlotVNode"></StatefulCompRender>
  <!-- 通过Teleport将tooltip节点作为了body的子节点,而非slot组件所在dom节点的子节点,这很好 -->
  <Teleport to="body">
    <transition name="fade">
      <!-- 这里v-if动态控制了tooltip节点的创建与销毁,这里很好 -->
      <div v-if="showTooltip" class="tooltipDom" ref="tooltipDomRef">{{ content }}</div>
    </transition>
  </Teleport>
</template>

<style lang="scss" scoped>
.refrenceDom {
  display: inline-block;
}
.tooltipDom {
  left: 0;
  top: 0;
  position: absolute;
  background-color: #000;
  color: #fff;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s;
}
.fade-enter-to,
.fade-leave-from {
  opacity: 1;
}
</style>

自定义指令方式封装

TooltipDirective.ts: 该方案采用自定义指令封装,使用起来就像用原生的 title 属性一样,且 tooltip 的 dom 会被创建为 body 的直属子节点,且 tooltip 是实时创建和销毁的

ts
import { Directive, createVNode, render, createApp } from 'vue'
//@ts-ignore
import CustomTooltip4 from './CustomTooltip4.vue'

// 创建tooltip的总容器
let tooltipApp
const tooltipContainerClassName = 'comm-tooltipContainer'
let tooltipContainerDom = document.querySelector(`.${tooltipContainerClassName}`)
if (tooltipContainerDom === null) {
  tooltipContainerDom = document.createElement('div')
  tooltipContainerDom.className = tooltipContainerClassName
  document.body.appendChild(tooltipContainerDom)
  tooltipApp = createApp(CustomTooltip4)
  tooltipApp.mount(tooltipContainerDom)
  // @ts-ignore
  tooltipContainerDom._tooltipApp = tooltipApp
}
// @ts-ignore
if (!tooltipApp && tooltipContainerDom._tooltipApp) {
  tooltipApp.mount(tooltipContainerDom)
  // @ts-ignore
  tooltipApp = tooltipContainerDom._tooltipApp
}
/**
 * 初始化或更新自定义指令
 *
 * @param   {[type]}  el       [el description]
 * @param   {[type]}  binding  [binding description]
 *
 * @return  {[type]}           [return description]
 */
function initOrUpdate(el, binding) {
  const placement = binding.arg ? binding.arg : 'bottom'
  const text = binding.value
  const { addTooltip, hideTooltip, showTooltip, updatePlacementAndContent } = tooltipApp._instance.exposed
  if (!el._tooltipIdx) {
    addTooltip(el, text, placement)
    function onMouseenter() {
      showTooltip(el)
    }
    function onMouseLeave() {
      hideTooltip(el)
    }
    el._removeEventListener = function () {
      el.removeEventListener('mouseenter', onMouseenter)
      el.removeEventListener('mouseleave', onMouseLeave)
    }
    el.addEventListener('mouseenter', onMouseenter)
    el.addEventListener('mouseleave', onMouseLeave)
  } else {
    updatePlacementAndContent(el, placement, text)
  }
}

export default {
  mounted(el, binding) {
    initOrUpdate(el, binding)
  },
  updated(el, binding) {
    initOrUpdate(el, binding)
  },
  beforeUnmount(el) {
    const { deleteTooltip } = tooltipApp._instance.exposed
    el._removeEventListener && el._removeEventListener()
    deleteTooltip(el)
  },
} as Directive

注意问题

指令方式:无法在同一个 dom 节点下挂载多个 VNode

无论 createVNode 还是 createApp 方式,如果挂载多个,必然会导致后面一个将前面一个覆盖。而如果每次在自定义指令中都创建一个真实的 dom 来挂载 tooltip 组件,那原本在 tooltip 组件中实时销毁和创建就意义不大了,因为你本身这个父组件无法销毁,那么只能在 Tooltip 组件做文章。至于是采用 createVNode 还是 createApp,那就看你是否需要用到组件的声明周期,以及是否在乎组件能否卸载,如果需要组件声明周期且在意组件是否能卸载,则采用 createApp 方式,否则使用 createVNode 方式即可。 实现方式可参考

vue
<!--

@author: pan
@createDate: 2022-12-09 10:39
-->
<script setup lang="ts">
import { computePosition, flip, offset, shift } from '@floating-ui/dom'
import { onBeforeUnmount, reactive, ref, toRefs } from 'vue'

export type Side = 'top' | 'right' | 'bottom' | 'left'
export interface TooltipInfo {
  idx: number
  showTooltip: boolean
  content: string
  placement: Side
}
export interface TooltipReferenceHTMLElement extends HTMLElement {
  _tooltipIdx: number
}
function validateElIsBindTooltip(el: TooltipReferenceHTMLElement) {
  return el._tooltipIdx ? true : false
}
const toolipInfoArr = reactive<TooltipInfo[]>([])
let tooltipIdx = 1
function addTooltip(el: TooltipReferenceHTMLElement, content: string, placement: Side) {
  if (!el._tooltipIdx) {
    el._tooltipIdx = tooltipIdx++
  }
  toolipInfoArr.push({
    // 给tooltip设置唯一标识
    idx: el._tooltipIdx,
    showTooltip: false,
    content,
    placement,
  })
}
const tooltipContainerRef = ref<HTMLElement>()
function showTooltip(el: TooltipReferenceHTMLElement) {
  if (!validateElIsBindTooltip(el) || !tooltipContainerRef.value) {
    return
  }
  const tooltipInfo = toolipInfoArr.find(item => item.idx === el._tooltipIdx)
  if (!tooltipInfo) {
    return
  }
  tooltipInfo.showTooltip = true
  setTimeout(() => {
    // 等待界面渲染完毕,找到tooltip所在dom节点
    const tooltipDom = tooltipContainerRef.value?.querySelector(
      `[data-tooltip-idx="${tooltipInfo.idx}"]`
    ) as HTMLElement
    if (!tooltipDom) {
      return
    }
    // 使用floating-ui实现tooltip
    computePosition(el, tooltipDom, {
      placement: tooltipInfo.placement,
      middleware: [flip(), shift(), offset(6)],
    }).then(({ x, y }) => {
      Object.assign(tooltipDom.style, {
        top: `${y}px`,
        left: `${x}px`,
      })
    })
  })
}
function hideTooltip(el: TooltipReferenceHTMLElement) {
  if (!validateElIsBindTooltip(el)) {
    return
  }
  const tooltipInfo = toolipInfoArr.find(item => item.idx === el._tooltipIdx)
  if (!tooltipInfo) {
    return
  }
  // dom的销毁交给vue完成
  tooltipInfo.showTooltip = false
}
function updatePlacementAndContent(el: TooltipReferenceHTMLElement, placement: Side, content: string) {
  if (!validateElIsBindTooltip(el)) {
    return
  }
  const tooltipInfo = toolipInfoArr.find(item => item.idx === el._tooltipIdx)
  if (!tooltipInfo) {
    return
  }
  // dom的更新交给vue完成
  tooltipInfo.content = content
  tooltipInfo.placement = placement
}
function deleteTooltip(el: TooltipReferenceHTMLElement) {
  if (!validateElIsBindTooltip(el)) {
    return
  }
  const tooltipInfoIdx = toolipInfoArr.findIndex(item => item.idx === el._tooltipIdx)
  if (tooltipInfoIdx < 0) {
    return
  }
  toolipInfoArr.splice(tooltipInfoIdx, 1)
}
defineExpose({
  addTooltip,
  showTooltip,
  hideTooltip,
  updatePlacementAndContent,
  deleteTooltip,
})
</script>

<template>
  <div ref="tooltipContainerRef">
    <transition name="fade" v-for="toolipInfo in toolipInfoArr" :key="toolipInfo.idx">
      <!-- 这里v-if动态控制了tooltip节点的创建与销毁,这里很好 -->
      <!-- data-tooltip-idx是tooltip的唯一标识,方便后续查找. 直接设置id会不会查找性能更优呢? 不清楚 -->
      <div v-if="toolipInfo.showTooltip && toolipInfo.content" class="tooltipDom" :data-tooltip-idx="toolipInfo.idx">
        {{ toolipInfo.content }}
      </div>
    </transition>
  </div>
</template>

<style lang="scss" scoped>
.tooltipDom {
  left: 0;
  top: 0;
  position: absolute;
  background-color: #000;
  color: #fff;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s;
}
.fade-enter-to,
.fade-leave-from {
  opacity: 1;
}
</style>

参考资料

vue3 < script setup > 引入自定义指令

基于 Vue 实现的 tooltip 工具

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

Vue3 封装可复用组件-Confirm

Vue3 自己封装 confirm 函数

vue3 实现 select 下拉选项