这里给大家分享我在网上总结出来的一些知识,希望对大家有所帮助前言
日常开发中,我们经常遇到过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 采用如下方式判断,兼容 firefoxfunction 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
的内容。
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' } $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; } }此外我们也可以通过
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}// 清除 slotfunction 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' } } }}// 挂载tooltipfunction 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, })}// 创建tooltipfunction 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`, }) }) }}// 解析slotfunction 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) },}示例
tooltiptooltipthis is a tooltip总结
在经过二次封装之后,我们只需要
v-tooltip
这样简便的操作,即可达到tooltip
的作用,简化了传统的书写流程,对于一些特殊tooltip
内容,我们可以通过slot
方式,定制化更多的提示内容。本文转载于:https://juejin.cn/post/7177384845968932901如果对您有所帮助,欢迎您点个关注,我会定时更新技术文档,大家一起讨论学习,一起进步。