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