记录--手写一个 v-tooltip 指令
这里给大家分享我在网上总结出来的一些知识,希望对大家有所帮助
前言
日常开发中,我们经常遇到过tooltip
这种需求。文字溢出、产品文案、描述说明等等,每次都需要写一大串代码,那么有没有一种简单的方式呢,这回我们用指令来试试。
功能特性
- 支持
tooltip
样式自定义 - 支持
tooltip
内容自定义 - 动态更新
tooltip
内容 - 文字省略自动出提示
- 支持弹窗位置自定义和偏移
功能实现
在vue3
中,指令也是拥有着对应的生命周期。
我们这里需要使用的是 mounted
、updated
和unmounted
钩子。
1 2 3 4 5 6 7 8 9 10 11 12 | import { DirectiveBinding } from 'vue' export default { mounted(el: HTMLElement, binding: DirectiveBinding) { }, updated(el: HTMLElement, binding: DirectiveBinding) { }, unmounted(el: HTMLElement) { } } |
在元素挂载完成之后,我们需要完成上述指令的功能。
什么时候可用?
首先我们需要考虑的是tooltip
什么时候可用?
- 元素是省略元素
- 手动开启时,我们需要启用
tooltip
,比如描述或者产品文案等等。
如果是省略元素,我们需要先判断元素是否存在省略,一般通过这种方式判断:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | 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
属性开启。1 | const enable = el.getAttribute( 'enableTooltip' ) |
内容构造和位置计算
tooltip
开启之后,我们需要构造它的内容和动态计算tooltip
的位置,比如元素发生缩放和滚动。
构造tooltip
内容的话,我们采用一个vue
组件,然后通过动态组件方式,将其挂载为tooltip
的内容。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 | <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
,但是这种在更新时需要特殊处理。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | 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
库。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 | 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
,延迟显示。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | 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
内容,内容不同则触发更新。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | { 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
关闭的的时候,我们需要把相应的事件等进行销毁。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | 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) } |
完整代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 | 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) }, } |
示例
1 2 3 4 5 6 7 8 9 | <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> |
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· .NET周刊【3月第1期 2025-03-02】
· [AI/GPT/综述] AI Agent的设计模式综述
2022-11-17 记录--uniapp开发安卓APP视频通话模块初实践