这里给大家分享我在网上总结出来的一些知识,希望对大家有所帮助
前言
日常开发中,我们经常遇到过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>