某次做到一个需求,由于某些技术原因,主流组件库的 Popover 组件无法正常使用,且也是因为这个原因一些类似于 popper.js 的基础库也用不了,但需求实现又必须用到这个组件,于是只能自己实现了,再造一个 popper.js是不可能的,这个自行实现的 Popover只需要满足基本的使用场景即可。一般的基础组件例如Button、Menu、Checkbox之类的实现起来没啥难度,但在实现这个 Popover组件的过程中,发现还是有些东西值得说道的
代码基于
vue@3.3.x
代码已上传到 github,最终实现效果如下
组件模板
一般 Popover用法如下
<template>
<Popover position="top" trigger="hover">
<template v-slot:reference>
<button>top</button>
</template>
content
</Popover>
</template>
组件最少会有两个属性(标准Popover通用组件会有很多属性,但这里仅考虑基础的);position 标识弹层相对于触发元素的位置,trigger标识触发的方式,可选值有 hover、click
有两个slot,reference 代表触发元素,default代表弹层内容
所以组件的模板可如下
<template>
<div class="custom-popover">
<!-- 触发元素 -->
<div class="custom-popover-reference" ref="referenceRef"><slot name="reference"></slot></div>
<!-- 弹层元素 -->
<Teleport to="body">
<div ref="bodyRef">
<div class="custom-popover-body-content"><slot></slot></div>
<span class="custom-tooltip-body-arrow"></span>
</div>
</Teleport>
</div>
</template>
referenceRef.value 代表触发元素实例,referenceRefRect = referenceRef.value.getBoundingClientRect(),bodyRef.value 代表触发弹层元素,bodyRefRect = bodyRefRect.value.getBoundingClientRect()
以上述信息为基础,进行逻辑编写
弹层定位
避免元素位置跳动
由于 referenceRef 和 bodyRef 内的内容都是用户决定的,所以它们的宽高都是不确定的,但 referenceRef 的定位又必须使用到这二者的宽高,,所以涉及到元素尺寸的测量问题,当组件在 DOM上渲染出来后进行测量,然后计算得到正确的位置信息赋给弹层元素
但是这么一来,由于弹层的初始位置是不正确的(因为没测量之前根本不知道尺寸信息没法确定),那么从组件渲染完毕到重新设置到正确位置这个过程中,就会出现视觉上的弹层位置跳动,弹层忽然从一个位置跳到正确位置了,这属于不可忽视的体验问题
那么怎么才能既能让元素渲染出来,又不热用户看到错误定位的弹层呢?其实也不难,只要看不到就行了嘛,当还没测量完毕的时候,将弹层元素的 css属性 opacity 设为 0,计算完毕再改为 1,这样用户在视觉上看到弹层的时候,它就已经在正确的位置上了
三角箭头元素的位置计算
position 一般会有 12 个可选值,分别用于指示弹层相对于触发元素的位置
弹层本身的位置是很好确定的,例如对于 topLeft、top、topRight 这三个值来说,弹层元素 css 属性 top 是一样的,都是 referenceRef.value.offsetTop - bodyRect.height,有点麻烦的是弹层下的三角箭头arrow位置
三角箭头的实现方式有很多,这里我选择了先绘制一个正方形元素,然后把这个正方形旋转 45deg,再遮盖掉一半剩下的就是三角箭头了,这种做法的好处是可以很精确地控制三角箭头的位置,虽然正方形旋转 45deg之后,其在水平方向上的宽度看起来不再是原先设置的 width,但由于元素的位置变换依旧是以原先正方形为基础进行的,并不需要什么勾股定理计算偏移量
对于 position的 top、right、bottom、left这四个值,三角图标arrow的位置也是好确定的,主要是其余8个属性,arrow的定位会因为 referenceRef和bodyRefRect之间相对尺寸大小的不同而不同
对于
topLeft这种情况,弹层箭头的位置应当指向 referenceRef 水平中心点,当 bodyRect元素宽度大于 referenceRef宽度的时候,这是可以办到的,但是如果不是,那么三角箭头无论如何也不可能指在 referenceRef 水平中心点,那么这个时候就得将三角箭头元素的位置调整到 referenceRef元素的最右侧了(不算是最右,考虑到舒适性,所以距离最右侧还有一点距离),所以对于这 8个 position值来说,一共有 16 种场景需要考虑到
click 情况下的弹层显隐
当 trigger: 'click' 时,只有点击除了 bodyRect 之外的位置才会改变弹层的显隐状态
所以需要注册一个全局 click 事件,别忘了组件销毁的时候移除事件
onMounted(() => {
if (props.trigger === 'click') {
window.addEventListener('click', listener)
}
})
onBeforeUnmount(() => {
if (props.trigger === 'click') {
window.removeEventListener('click', listener)
}
})
这个事件方法主要是用于检查当前点击的元素是不是 referenceRef 的子元素或其本身,如果是,那么需要对弹层当前的显隐状态取反,如果不是点击元素不是 referenceRef 的子元素或其本身,也不是 bodyRect 的子元素或其本身,那么就需要关闭弹层
const listener = (e: any) => {
if (isParent(e.target, referenceRef.value!)) {
visible.value = !visible.value
} else if (!isParent(e.target, bodyRef.value!)) {
visible.value = false
}
}
这个 isParent 方法就是顺着当前点击元素往上逐层找父元素,一直找到父元素是 referenceRef 或 bodyRef 或 null
const isParent = (child: HTMLElement | null, parent: HTMLElement): boolean => {
if (child === null) return false
if (child === parent) return true
return isParent(child.parentElement, parent)
}
hover 情况下的弹层显隐
当 trigger: 'click' 时,只有鼠标hover在 referenceRef 或 bodyRef 上时才展示弹层,否则就不展示,只需要给这两个元素分别添加 mouseenter、mouseleave 事件即可,mouseenter 时展示弹层,mouseleave 时隐藏弹层
但是这里会有一个问题,也就是当鼠标从触发元素 referenceRef 移动到弹层元素bodyRef这个过程中,由于先触发了 mouseleave 所以弹层隐藏,那么后续就不可能再出发 bodyRef的 mouseenter事件了,因为 bodyRef元素已经隐藏了,无论这两个元素靠得有多近,哪怕它们叠到一起了,这两个事件也会依次触发
避免的方式就是加个 debounce
当 mouseleave 事件发生的时候,启动一个定时器,定时器回调方法里就写弹层隐藏的逻辑,在 mouseenter 事件里再写个清除定时器的逻辑,当用户在一定的时间内从 referenceRef 移动到 bodyRef,或者反过来移动,都认为是不需要隐藏弹层,这个时间可以设定为 250ms(一般认为是人类所能意识到最短时间)
const handleMouseenter = () => {
if (props.trigger !== 'hover') return
clearTimeout(debounceTimer.value)
visible.value = true
}
const handleMouseleave = () => {
if (props.trigger !== 'hover') return
debounceTimer.value = window.setTimeout(() => {
visible.value = false
}, 250)
}
至此,一个具备基础功能的 Popover 组件就完成了