Tooltip组件

需求分析

Tooltip组件要实现的需求就是当我们点击或者鼠标移入的时候显示对应的文字提示。同时文字的内容、位置以及显示、隐藏的延迟时间还有是否使用过渡效果等。这里要注意的是我们使用popperjs来实现文字位置的展示。它的原理也很简单:通过计算和定位,将弹出元素放在与目标元素相关的位置上。另外popperjs还会监听窗口的变化,以便在窗口大小或滚动位置发生变化时,重新计算和定位弹出元素。那么我们先看一下Tooltip组件有哪些配置项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// types.ts
import { Placement, Options } from '@popperjs/core'
export interface TooltipProps {
content?: string
trigger?: 'hover' | 'click'
placement?: Placement
popperOptions?: Partial<Options>
transition?: string
openDelay?: number
closeDelay?: number
manual?: boolean
}

export interface TooltipEmits {
(e: 'visible-change', value: boolean): void
(e: 'click-outside', value: boolean): void
}

export interface TooltipInstance {
show: () => void
hide: () => void
}

TooltipProps定义了我们这个Tooltip组件可以接受哪些属性; TooltipEmits是我们定义的Tooltip组件的事件类型接口,有两个事件,一个是点击了元素以外区域,另一个是当显示、隐藏发生变化的时候;TooltipInstance是我们向外暴露一个实例,它的类型接口有两个方法分别是showhide用来控制显示和隐藏。

组件基本结构

Tooltip组件的结构还是非常简单的,根据需求分析阶段,我们知道它有一个内容,我们使用 vue 中的<slot></slot>插槽。还有一个过渡效果,那么我们就用 vue 中的transition来做。基本结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
<template>
<div class="tm-tooltip" ref="popperContainerNode" v-on="outerEvents">
<div class="tm-tooltip__trigger" ref="triggerNode" v-on="events">
<slot />
</div>
<Transition name="transition">
<div v-if="isOpen" class="tm-tooltip__popper" ref="popperNode">
<slot name="content">{{ content }}</slot>
<div id="arrow" data-popper-arrow></div>
</div>
</Transition>
</div>
</template>

组件事件分析

Tooltip组件有哪些行为(事件),最容易想到的就是当我们鼠标移入的时候显示,移出的时候隐藏;还有一个就是点击元素的时候显示,再次点击元素隐藏。一句话里面包含了三个事件,分别是鼠标移入mouseover、鼠标移出mouseout、鼠标点击click。根据这一点儿我们可以写出以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { reactive } from 'vue'
let events: Record<string, any> = reactive({})
let outerEvents:Record<string, any> = reacitve({})
const attchEvents = () => {
if (props.trigger === 'trigger') {
// 鼠标移入的时候显示
outerEvents['mouseover'] = openFinal
// 鼠标移出的时候隐藏
outerEvents['mouseout'] = closeFinal
} else if (props.trigger === 'click') {
// 点击事件
events['mouseout'] = togglePopper
}
}

// 页面初始化完毕以后就绑定事件
if (!props.manual) {
attchEvents
}

绑定好事件以后,我们来写具体的事件回调函数。我们应该声明一个响应式变量来控制元素的隐藏和显示,当发生改变的时候通过emits将变量的值暴露出去,可以让外部组件也就是父组件在有需要的时候访问到。在这里我们还应该考虑一个频率的问题,所以使用lodash-es封装好的工具函数debounce来做防抖效果。

1
npm install lodash-es --save
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
import { ref } from 'vue'
import { debounce } from 'lodash-es'
let isOpen = ref(false)
let openTimes = 0
let closeTimes = 0
const open = () => {
openTimes++
isOpen.value = true
emits('visible-change', true)
}

const close = () => {
closeTimes++
isOpen.value = false
emits('visible-change', false)
}

const openDebounce = debounce(open, props.overDelay)
const closeDebounce = debounce(close, props.closeDelay)

const openFinal = () => {
closeDebounce.cancel()
openDebounce()
}

const closeFinal = () => {
openDebounce.cancel()
closeDebounce()
}

const togglePopper = () => {
if (isOpen.value) {
closeFinal()
} else {
openFinal()
}
}

上面三个事件的回调函数写完以后我们还要考虑一个问题就是,当我们点击了元素外面的区域以后,也应该将Tooltip组件的内容隐藏起来。判断是否点击了元素外部区域不光在这一个组件中可能用到,其他组件也有可能使用。所以我们写一个组合式函数,后面其他组件有需要的时候可以直接使用。在vue2当中的时候是用mixin混入来实现的,vue3提供了组合式函数这个概念。下面我们来编写实现一下这个组合式函数:

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
// 按照惯例 组合式函数以“use”开头
import { onMounted, onUnmounted, type Ref } from 'vue'
const useClickOutside = (
elementRef: Ref<HTMLElement | undefined>,
callback: (e: MouseEvent) => void
) => {
// 事件回调函数
const handler = (e: MouseEvent) => {
if (elementRef.value && e.target) {
// 如果我们点击的区域不在我们tooltip组件的内部
if (!elementRef.value.contains(e.target as HTMLElement)) {
// 调用外部传递进来的回调函数
callback(e)
}
}
}

// 挂载
onMounted(() => {
document.addEventListener('click', handler)
})

// 卸载
onUnmounted(() => {
document.removeEventListener('click', handler)
})
}

// 向外导出,供外部使用
export default useClickOutside

使用组合式函数useClickOutside
当我们编写好组合式函数useClickOutside以后,我们就要在Tooltip组件中使用它:

1
2
3
4
5
6
7
8
9
10
11
12
import { TooltipEmits } from './types'
import useClickOutside from '../../hooks/useClickOutside.ts'
const popperContainerNode = ref<HTMLElement>()
const emits = defineEmits<TooltipEmits>()
useClickOutside(popperContainerNode, () => {
if (props.trigger === 'click' && isOpen.value && !props.manual) {
closeFinal()
}
if (isOpen.value) {
emits('click-outside', true)
}
})

创建 popperjs 实例

在实现了一系列事件以后,我们该说到如何将文字提示准确的定位在触发元素的某一位置上。前面我们分析过了,使用的是popperjs做到的。还应该考虑一个问题就是显示和隐藏的时机,前面我们声明了一个响应式变量isOpen,那么我们就用侦听器来监听它的变化,从而做出对应的变化。接下来就让我们编写代码实现它:

安装 popperjs

1
npm install @popperjs/core --save
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { watch, onUnmounted } from 'vue'
import { Instance } from '@popperjs/core'
import { createPopper } from '@popperjs/core/lib/createPopper'
const triggerNode = ref<HTMLElement>()
const popperNode = ref<HTMLElement>()
let popperInstance: null | Instance = null
watch(isOpen, (newValue) => {
if (newValue) {
if (triggerNode.value && popperNode.value) {
popperInstance = createPopper(
triggerNode.value,
popperNode.value,
popperOptions.value
)
} else {
popperInstance.destroy()
}
}
}, { flush: 'post'})

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

在上面创建popperjs实例的时候有用到了一个叫popperOptions的响应式对象,这个响应式对象是我们通过计算属性返回的,用来做一些默认的配置项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { computed } from 'vue'
const popperOptions = computed(() => {
return {
placement: props.placement,
modifiers: [
{
name: 'offset',
options: {
offset: [0, 9]
}
}
],
...props.popperOptions
}
})

监听触发方式的变化
我们的Tooltip组件支持两种触发方式,所以我们要监听外部传进来的trigger,当它发生变化的时候我们应该清空之前的响应式事件对象,重新绑定事件。

1
2
3
4
5
6
7
8
9
10
11
import { watch } from 'vue'
watch(
() => props.trigger,
(newTrigger, oldTrigger) => {
if (newTrigger !== oldTrigger ) {
events = {}
outerEvents = {}
attachEvents()
}
}
)

将组件方法导出去
我们这个Tooltip组件有可能会在其他组件中使用,所以将控制显示、隐藏的方法暴露出去,供外部使用:

1
2
3
4
5
import { TooltipInstance } from './types'
defineExpose<TooltipInstance>({
show: openFinal,
hide: closeFinal
})

知识点
在写的过程中,有一些知识点不清楚,记录下来。

  • Record<Keys, Type> 构造一个对象类型,其属性键为Keys, 其属性值为Type。此工具类型可用于将一种类型的属性映射到另一种类型。
  • Partial<Type> 构造一个将Type的所有属性设置为可选的类型。
  • withDefaults Vue中提供的编译器宏,为了解决defineProps声明的时候没有可以给props提供默认值的方式。
  • defineExpose Vue中使用<script setup>的组件默认是关闭的————通过模板的引用或者$parent链获取到的组件的公开实例,不会暴露任何在<script setup>中声明的绑定。可以通过defineExpose编译器宏来显示指定在<script setup>组件中要暴露出去的属性。