什么,让我自己实现Popover组件?

3,985 阅读6分钟

某次做到一个需求,由于某些技术原因,主流组件库的 Popover 组件无法正常使用,且也是因为这个原因一些类似于 popper.js 的基础库也用不了,但需求实现又必须用到这个组件,于是只能自己实现了,再造一个 popper.js是不可能的,这个自行实现的 Popover只需要满足基本的使用场景即可。一般的基础组件例如ButtonMenuCheckbox之类的实现起来没啥难度,但在实现这个 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标识触发的方式,可选值有 hoverclick

有两个slotreference 代表触发元素,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()

以上述信息为基础,进行逻辑编写

弹层定位

避免元素位置跳动

由于 referenceRefbodyRef 内的内容都是用户决定的,所以它们的宽高都是不确定的,但 referenceRef 的定位又必须使用到这二者的宽高,,所以涉及到元素尺寸的测量问题,当组件在 DOM上渲染出来后进行测量,然后计算得到正确的位置信息赋给弹层元素

但是这么一来,由于弹层的初始位置是不正确的(因为没测量之前根本不知道尺寸信息没法确定),那么从组件渲染完毕到重新设置到正确位置这个过程中,就会出现视觉上的弹层位置跳动,弹层忽然从一个位置跳到正确位置了,这属于不可忽视的体验问题

那么怎么才能既能让元素渲染出来,又不热用户看到错误定位的弹层呢?其实也不难,只要看不到就行了嘛,当还没测量完毕的时候,将弹层元素的 css属性 opacity 设为 0,计算完毕再改为 1,这样用户在视觉上看到弹层的时候,它就已经在正确的位置上了

三角箭头元素的位置计算

position 一般会有 12 个可选值,分别用于指示弹层相对于触发元素的位置

1.jpeg

弹层本身的位置是很好确定的,例如对于 topLefttoptopRight 这三个值来说,弹层元素 css 属性 top 是一样的,都是 referenceRef.value.offsetTop - bodyRect.height,有点麻烦的是弹层下的三角箭头arrow位置

三角箭头的实现方式有很多,这里我选择了先绘制一个正方形元素,然后把这个正方形旋转 45deg,再遮盖掉一半剩下的就是三角箭头了,这种做法的好处是可以很精确地控制三角箭头的位置,虽然正方形旋转 45deg之后,其在水平方向上的宽度看起来不再是原先设置的 width,但由于元素的位置变换依旧是以原先正方形为基础进行的,并不需要什么勾股定理计算偏移量

2.jpeg

对于 positiontoprightbottomleft这四个值,三角图标arrow的位置也是好确定的,主要是其余8个属性,arrow的定位会因为 referenceRefbodyRefRect之间相对尺寸大小的不同而不同

4.jpeg 3.jpeg 对于 topLeft这种情况,弹层箭头的位置应当指向 referenceRef 水平中心点,当 bodyRect元素宽度大于 referenceRef宽度的时候,这是可以办到的,但是如果不是,那么三角箭头无论如何也不可能指在 referenceRef 水平中心点,那么这个时候就得将三角箭头元素的位置调整到 referenceRef元素的最右侧了(不算是最右,考虑到舒适性,所以距离最右侧还有一点距离),所以对于这 8position值来说,一共有 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 方法就是顺着当前点击元素往上逐层找父元素,一直找到父元素是 referenceRefbodyRefnull

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' 时,只有鼠标hoverreferenceRefbodyRef 上时才展示弹层,否则就不展示,只需要给这两个元素分别添加 mouseentermouseleave 事件即可,mouseenter 时展示弹层,mouseleave 时隐藏弹层

但是这里会有一个问题,也就是当鼠标从触发元素 referenceRef 移动到弹层元素bodyRef这个过程中,由于先触发了 mouseleave 所以弹层隐藏,那么后续就不可能再出发 bodyRefmouseenter事件了,因为 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 组件就完成了