仿 ElementPlus 组件库(四)—— Tooltip 组件实现

我们在模仿 ElementPlus 组件库的道路上迈出了坚实的步伐,成功探索了 Icon 组件的实现。今天,我们将继续深入,聚焦于另一个在提升用户体验方面发挥关键作用的组件 ------Tooltip 组件,即提示框组件,以其简洁高效的信息传达方式,在各类用户界面中广泛应用,是构建易用且交互性强的应用不可或缺的部分。

一、什么是 Tooltip 组件

Tooltip 组件是一种交互元素,常用于展示鼠标悬停时的提示信息。这些信息可以是对元素功能的解释、操作说明或者补充细节,帮助用户更好地理解和使用界面元素,减少误操作的可能性。

二、实现 Tooltip 组件

(一)安装Popper.js

Popper.js 是一款用于处理元素定位和显示的 JavaScript 库,在实现 Tooltip 组件等需要精确位置定位和交互的场景中发挥着重要作用.

  • 自动计算位置:根据宿主元素(触发按钮)和视口边界,动态调整 Tooltip 的显示位置(如顶部、底部、左侧、右侧);
  • 处理边界溢出:避免 Tooltip 超出屏幕,自动翻转方向(如底部无空间时切换到顶部);
  • 支持动画与交互:配合 Vue 的过渡动画,实现流畅的显示 / 隐藏效果;
  • 轻量且灵活:体积仅~30KB(压缩后),支持 Vue、React 等框架,与 ElementPlus 的设计理念高度契合。
css 复制代码
npm i @popperjs/core --save

Vue.js中引入

Vue.js 复制代码
import { createPopper } from '@popperjs/core'

const overlayNode = ref<HTMLElement>
const triggerNode = ref<HTMLElement>

(二)组件目录

目录 复制代码
components
├── Tooltip
    ├── Tooltip.vue
    ├── types.ts
    ├── style.css    

(三)初步实现Tooltip组件基本功能

实现基本的提示框功能,用户可以通过点击触发元素来显示或隐藏提示框,并且可以通过 props 自定义提示框的内容、触发方式和显示位置。

types.ts 复制代码
import type { Placement } from '@popperjs/core'
export interface TooltipProps {
  content?: string
  trigger?: 'hover' | 'click'
  placement?: Placement
}

export interface TooltipEmits {
  (e: 'visible-change', value: boolean): void
}
Tooltip.vue 复制代码
<template>
  <div class="yl-tooltip">
    <div class="yl-tooltip__trigger" ref="triggerNode" @click="togglePopper">
      <slot></slot>
    </div>
    <div v-if="isOpen" class="yl-tooltip__popper" ref="popperNode">
      <slot name="content">
        {{ content }}
      </slot>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, watch } from 'vue'
import { createPopper } from '@popperjs/core'
import type { Instance } from '@popperjs/core'
import type { TooltipProps, TooltipEmits } from './types'
const props = withDefaults(defineProps<TooltipProps>(), {
  placement: 'bottom',
})
const emits = defineEmits<TooltipEmits>()
const isOpen = ref(false)
const popperNode = ref<HTMLElement>()
const triggerNode = ref<HTMLElement>()
let popperInstance: null | Instance = null
const togglePopper = () => {
  isOpen.value = !isOpen.value
  emits('visible-change', isOpen.value)
}
watch(
  isOpen,
  (newValue) => {
    if (newValue) {
      if (triggerNode.value && popperNode.value) {
        popperInstance = createPopper(triggerNode.value, popperNode.value,{placement:props.placement})
      } else {
        popperInstance?.destroy()
      }
    }
  },
  { flush: 'post' },
)
</script>
App.vue 复制代码
import Tooltip from './components/Tooltip/Tooltip.vue'

(三)对Tooltip组件添加动态事件支持

  • 通过动态绑定事件,组件可以根据 props.trigger 的值灵活地改变提示框的触发方式。当 trigger 属性为 'hover' 时,用户鼠标悬停在触发元素上会打开提示框,鼠标离开外部元素时会关闭提示框;当 trigger 属性为 'click' 时,用户点击触发元素可以切换提示框的显示状态。
  • 同时,使用 watch 函数监听 trigger 属性的变化,当该属性值改变时,会自动更新事件绑定,确保组件能根据新的触发方式正常工作。
Tooltip.vue 复制代码
<template>
  <div class="yl-tooltip" v-on="outerEvents">
    <div class="yl-tooltip__trigger" ref="triggerNode" v-on="events">
      <slot></slot>
    </div>
    //...
</template>
//...
const props = withDefaults(defineProps<TooltipProps>(), {
  placement: 'bottom',
  trigger: 'hover',
})
let outerEvents: Record<string, any> = reactive({})
const togglePopper = () => {
  isOpen.value = !isOpen.value
  emits('visible-change', isOpen.value)
}
const open = () => {
  isOpen.value = true
  emits('visible-change', true)
}
const close = () => {
  isOpen.value = false
  emits('visible-change', false)
}
const attachEvents = () => {
  if (props.trigger === 'hover') {
    events['mouseenter'] = open
    outerEvents['mouseleave'] = close
  } else if (props.trigger === 'click') {
    events['click'] = togglePopper
  }
}
attachEvents()
watch(
  () => props.trigger,
  (newTrigger, oldTrigger) => {
    if (newTrigger != oldTrigger) {
      //清空事件
      events = {}
      outerEvents = {}
      attachEvents()
    }
  },
)

(四)对Tooltip组件添加外侧点击关闭功能

  • 通过封装 useClickOutside 自定义钩子函数,实现对指定元素外部点击事件的监听。
  • Tooltip 组件中,使用该钩子监听 popperContainerNode 所引用的元素外部的点击事件。
  • 当用户点击该元素外部,并且 Tooltip 组件的触发方式为 'click' 且提示框处于打开状态时,会自动调用 close 方法关闭提示框。
目录 复制代码
src
├── hooks
    ├── useClickOutside.ts  
useClickOutside.ts 复制代码
import { onMounted, onUnmounted } from 'vue'
import type { Ref } from 'vue'
const useClickOutside = (elementRef: Ref<undefined | HTMLElement>, callback: (e: MouseEvent) => void) => {
  const handler = (e: MouseEvent) => {
    if (elementRef.value && e.target) {
      if (!elementRef.value.contains(e.target as HTMLElement)) {
        callback(e)
      }
    }
  }
  onMounted(() => {
    document.addEventListener('click', handler)
  })
  onUnmounted(() => {
    document.removeEventListener('click', handler)
  })
}

export default useClickOutside
 
Tooltip.vue 复制代码
<template>
  <div class="yl-tooltip" ref="popperContainerNode" v-on="outerEvents">
    //...
import useClickOutside from '@/hooks/useClickOutside'

const popperContainerNode = ref<HTMLElement>()

useClickOutside(popperContainerNode, () => {
  if (props.trigger === 'click' && isOpen.value) {
    close()
  }
})

(五)对Tooltip组件添加手动打开关闭功能

  • TooltipProps 中新增 manual 属性,用户可以控制提示框是否进入手动模式。
  • 在手动模式下(manualtrue),提示框不会响应默认的触发事件(如鼠标悬停或点击),并且点击提示框外部也不会关闭提示框,用户需要通过调用组件暴露的 showhide 方法来手动控制提示框的显示与隐藏。
  • 在非手动模式下(manualfalse),提示框会按照之前的逻辑,根据 trigger 属性绑定相应的事件来自动显示和隐藏,同时点击提示框外部也会关闭提示框。
types.ts 复制代码
export interface TooltipProps {
  //...
  manual?: boolean;
}

export interface TooltipInstance {
  show: () => void;
  hide: () => void;
}
Tooltip.vue 复制代码
import type { TooltipProps, TooltipEmits,TooltipInstance } from './types'
import { ref, watch, reactive,onUnmounted } from 'vue'

useClickOutside(popperContainerNode, () => {
  if (props.trigger === 'click' && isOpen.value && !props.manual) {
    close()
  }
})

if (!props.manual) {
  attachEvents()
}

watch(
  () => props.manual,
  (isManual) => {
    if (isManual) {
      events = {}
      outerEvents = {}
    } else {
      attachEvents()
    }
  },
)

onUnmounted(() => {
  popperInstance?.destroy()
})

defineExpose<TooltipInstance>({
  show: open,
  hide: close,
})
App.vue 复制代码
import type { TooltipInstance } from './components/Tooltip/types'
const tooltipRef= ref<TooltipInstance | null>(null)

(六)对Tooltip组件添加Popper参数

  • 原有的 Tooltip 组件基础上添加了对 Popper 参数的支持。
  • 通过在 TooltipProps 中新增 popperOptions 属性,用户可以传入自定义的 Popper 配置选项,以更精细地控制提示框的行为和样式。
  • Tooltip.vue 组件内部,使用计算属性 popperOptions 将用户传入的 popperOptions 与默认的 placements 属性合并,生成最终的 Popper 配置选项。
  • 当提示框显示时,使用合并后的配置选项创建 Popper 实例,从而实现对提示框定位和其他行为的自定义配置。
types.ts 复制代码
import type { Placement, Options } from '@popperjs/core'
export interface TooltipProps {
  //...
  popperOptions?: Partial<Options>;

}
Tooltip.vue 复制代码
import { ref, watch, reactive, onUnmounted,computed } from 'vue'

const popperOptions = computed(() => {
  return {
    placements: props.placement,
    ...props.popperOptions
  }
})

watch(
  isOpen,
  (newValue) => {
    if (newValue) {
      if (triggerNode.value && popperNode.value) {
        popperInstance = createPopper(triggerNode.value, popperNode.value, popperOptions.value)
      } else {
        popperInstance?.destroy()
      }
    }
  },
  { flush: 'post' },
)
App.vue 复制代码
import type { Options } from '@popperjs/core'

(七)对Tooltip组件添加动画效果

  • 在原有的 Tooltip 组件基础上添加了动画效果。
  • 通过在 TooltipProps 中新增 transition 属性,用户可以自定义提示框显示和隐藏时的过渡动画名称。
  • Tooltip.vue 组件中,使用 <Transition> 组件包裹提示框元素,并根据 transition 属性值动态应用过渡动画。
  • 同时,为 transition 属性设置了默认值 'fade',并在 style.css 文件中定义了 fade 过渡动画的样式,实现了提示框淡入淡出的效果,使 Tooltip 组件在显示和隐藏时更加平滑和美观。
types.ts 复制代码
export interface TooltipProps {
  //...
  transition?: string;
}
Tooltip.vue 复制代码
    <Transition :name="transition">
      <div v-if="isOpen" class="yl-tooltip__popper" ref="popperNode">
        <slot name="content">
          {{ content }}
        </slot>
      </div>
    </Transition>
    
const props = withDefaults(defineProps<TooltipProps>(), {
  //...
  transition:'fade'
})
style.css 复制代码
.yl-tooltip {
  .fade-enter-active,
  .fade-leave-active {
    transition: opacity var(--yl-transition-duration);
  }

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
}
style/index.css 复制代码
@import '../components/Tooltip/style.css';

(八)对Tooltip组件实现延时显示隐藏功能

  • 通过在 TooltipProps 中新增 openDelaycloseDelay 属性,用户可以分别设置提示框显示和隐藏的延迟时间。
  • 在组件内部,使用 debounce 函数对提示框的打开和关闭操作进行了防抖处理。
  • 当用户触发打开或关闭提示框的事件(如鼠标悬停、点击等)时,并不会立即执行打开或关闭操作,而是等待指定的延迟时间。
  • 如果在延迟时间内再次触发相同操作,会重新计算延迟时间,只有在延迟时间结束且没有再次触发时,才会真正执行打开或关闭提示框的操作。
  • 这种设计可以避免用户频繁触发操作时,提示框不必要的显示和隐藏,提升了用户体验,并且用户可以根据实际需求灵活调整延迟时间。
css 复制代码
npm install lodash-es --save
types.ts 复制代码
export interface TooltipProps {
//...
  openDelay?: number
  closeDelay?: number
}
Tooltip.vue 复制代码
import { debounce } from 'lodash-es'
let OpenTimes = 0
let CloseTimes = 0
const props = withDefaults(defineProps<TooltipProps>(), {
//...
  openDelay: 0,
  closeDelay: 0,
})

const open = () => {
  OpenTimes++
  console.log('open times', OpenTimes)
  isOpen.value = true
  emits('visible-change', true)
}
const close = () => {
  CloseTimes++
  console.log('close times', CloseTimes)
  isOpen.value = false
  emits('visible-change', false)
}
const openDebounce = debounce(open, props.openDelay)
const closeDebounce = debounce(close, props.closeDelay)

const openFinal = () => {
  closeDebounce.cancel()
  openDebounce()
}
const closeFinal = () => {
  openDebounce.cancel()
  closeDebounce()
}
const togglePopper = () => {
  if (isOpen.value) {
    closeFinal()
  } else {
    openFinal()
  }
}
useClickOutside(popperContainerNode, () => {
  if (props.trigger === 'click' && isOpen.value && !props.manual) {
    closeFinal()
  }
})
const attachEvents = () => {
  if (props.trigger === 'hover') {
    events['mouseenter'] = openFinal
    outerEvents['mouseleave'] = closeFinal
  } else if (props.trigger === 'click') {
    events['click'] = togglePopper
  }
}
defineExpose<TooltipInstance>({
  show: openFinal,
  hide: closeFinal,
})
style.css 复制代码
.yl-tooltip {
  --yl-popover-bg-color: var(--yl-bg-color-overlay);
  --yl-popover-font-size: var(--yl-font-size-base);
  --yl-popover-border-color: var(--yl-border-color);
  --yl-popover-padding: 12px;
  --yl-popover-border-radius: 4px;
  display: inline-block;
}

.yl-tooltip {
  .yl-tooltip__popper {
    background: var(--yl-popover-bg-color);
    border-radius: var(--yl-popover-border-radius);
    border: 1px solid var(--yl-popover-border-color);
    padding: var(--yl-popover-padding);
    color: var(--yl-text-color-regular);
    line-height: 1.4;
    text-align: justify;
    font-size: var(--yl-popover-font-size);
    box-shadow: var(--yl-box-shadow-light);
    word-break: break-all;
    box-sizing: border-box;
    z-index: 1000;
    #arrow,
    #arrow::before {
      position: absolute;
      width: 8px;
      height: 8px;
      box-sizing: border-box;
      background: var(--yl-popover-bg-color);
    }
    #arrow {
      visibility: hidden;
    }
    #arrow::before {
      visibility: visible;
      content: '';
      transform: rotate(45deg);
    }
    &[data-popper-placement^='top'] > #arrow {
      bottom: -5px;
    }

    &[data-popper-placement^='bottom'] > #arrow {
      top: -5px;
    }

    &[data-popper-placement^='left'] > #arrow {
      right: -5px;
    }

    &[data-popper-placement^='right'] > #arrow {
      left: -5px;
    }
    &[data-popper-placement^='top'] > #arrow::before {
      border-right: 1px solid var(--yl-popover-border-color);
      border-bottom: 1px solid var(--yl-popover-border-color);
    }
    &[data-popper-placement^='bottom'] > #arrow::before {
      border-left: 1px solid var(--yl-popover-border-color);
      border-top: 1px solid var(--yl-popover-border-color);
    }
    &[data-popper-placement^='left'] > #arrow::before {
      border-right: 1px solid var(--yl-popover-border-color);
      border-top: 1px solid var(--yl-popover-border-color);
    }
    &[data-popper-placement^='right'] > #arrow::before {
      border-left: 1px solid var(--yl-popover-border-color);
      border-bottom: 1px solid var(--yl-popover-border-color);
    }
  }

  .fade-enter-active,
  .fade-leave-active {
    transition: opacity var(--yl-transition-duration);
  }

  .fade-enter-from,
  .fade-leave-to {
    opacity: 0;
  }
}
Tooltip.vue 复制代码
<template>
//...
        <slot name="content">
          {{ content }}
        </slot>
        <div id="arrow" data-popper-arrow></div>
//...
</template>

const popperOptions = computed(() => {
  return {
    placements: props.placement,
    modifiers: [
      {
        name: 'offset',
        options: {
          offset: [0, 8],
        },
      },
    ],
    ...props.popperOptions,
  }
})
相关推荐
崔庆才丨静觅4 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60615 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了5 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅5 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅6 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅6 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment6 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅6 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊7 小时前
jwt介绍
前端
爱敲代码的小鱼7 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax