工具提示组件
需要提前掌握的知识
特点
- 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>