在 vue3 后台系统中使用 el-popover 的场景及相关尝试

919 阅读7分钟

使用场景一

某些页面列表的字段特别多,查询条件也很多,需求是页面上仅展示部分查询条件和一个更多按钮,其余查询条件放在 el-popover 弹出框内,点击更多按钮后显示。

与 el-select 一起用时的问题

因为是查询条件,所以就会出现在 el-popover 弹出框内使用 el-select 选择器的情况,当点击选择器的下拉框时,el-popover 的弹出框会自动隐藏;而正常的逻辑应该是当点击 el-popover 弹出框以外的内容时,弹出框才会隐藏,显然交互逻辑出了点问题。

<template>
  <el-popover placement="bottom-start" trigger="click">
    <el-form-item label="例子" prop="example">
      <el-select v-model="value" placeholder="请选择" clearable>
        <el-option :key="item.value" :label="item.label" :value="item.value" v-for="item in examples" />
      </el-select>
    </el-form-item>

    <template #reference>
      <el-form-item>
        <el-button text type="primary" icon="DArrowRight">更多</el-button>
      </el-form-item>
    </template>
  </el-popover>
</template>

<script setup lang="ts">
const value = ref()
const examples = [
  // ...例子的选项
]
</script>

当时的措施

定义变量,通过 el-popover 的 visible 属性来控制弹出框的显示和隐藏,然后在获取列表数据的接口调用时隐藏弹出框;

在开发过程中遇到问题时,找对角度真的很重要,否则就会事倍功半浪费大量时间;我这里角度已经偏了,不过好在实现的效果差强人意,可以用作过渡;

由于这里通过变量来控制弹框的显示和隐藏,所以点击其他区域后自动隐藏弹出框的逻辑需要自己来实现,当时本着多一事不如少一事的心理就没有深入,而在用户点击查询调用接口后隐藏弹出框这个逻辑也基本符合需求。

<template>
  <el-popover placement="bottom-start" :visible="visible">
    <el-form-item label="例子" prop="example">
      <el-select v-model="value" placeholder="请选择" clearable>
        <el-option :key="item.value" :label="item.label" :value="item.value" v-for="item in examples" />
      </el-select>
    </el-form-item>

    <template #reference>
      <el-form-item>
        <el-button text type="primary" icon="DArrowRight" @click="visible = !visible">更多</el-button>
      </el-form-item>
    </template>
  </el-popover>
  <el-button icon="search" type="primary" @click="getList">查询</el-button>
</template>

<script setup lang="ts">
const visible = ref(false)
const getList = () => {
  // ...获取数据的逻辑
  visible.value = false
}
</script>

ClickOutside 指令

后来回顾这里的逻辑,发现点击其他区域后自动隐藏弹出框这个逻辑也不复杂;

el-popover 组件有一个 teleported 属性默认为 true ,会将弹出框对应的元素插入至 body 元素中,这时弹出框内区域的点击事件就不会冒泡至 vue 应用实例的容器 app 元素上,所以只要给 app 元素注册点击事件就能符合条件;

<template>
  <el-popover placement="bottom-start" :visible="visible" @after-enter="initClickOutside">
    <el-form-item label="例子" prop="example">
      <el-select v-model="value" placeholder="请选择" clearable>
        <el-option :key="item.value" :label="item.label" :value="item.value" v-for="item in examples" />
      </el-select>
    </el-form-item>

    <template #reference>
      <el-form-item>
        <el-button text type="primary" icon="DArrowRight" @click="visible = !visible">更多</el-button>
      </el-form-item>
    </template>
  </el-popover>
  <el-button icon="search" type="primary" @click="getList">查询</el-button>
</template>

<script setup lang="ts">
const value = ref()
const examples = [
  // ...例子的选项
]
const visible = ref(false)
const getList = () => {
  // ...获取数据的逻辑
  visible.value = false
}
// 由于更多按钮在 app 元素内,给 app 元素注册的点击事件必须在弹出框显示后并且是一次性的
// 防止更多按钮的事件与之冲突
const initClickOutside = () => {
  document.getElementById('app')!.addEventListener('click', () => {
    visible.value = false
  }, { once: true })
}
</script>

其实这部分逻辑 Element Plus 中有封装一个自定义指令 ClickOutside,不过并没有专门的文字说明,而是藏在 el-popover 组件的例子虚拟触发里面;

ClickOutside 实现的逻辑比我自己想的半吊子要完善的多,大致的思路是直接给 document 注册点击事件,从事件对象的 target 属性中拿到鼠标点击位置的元素,通过 DOM 元素的 contains 方法来判断点击的元素是否为弹出框内部的元素;

然而实际使用 ClickOutside 指令时,发现点击弹出框内部区域时也会触发指令的回调,所以需要自己实现判断是否点击了内部的逻辑,略微繁琐;尝试将虚拟触发这个例子中关于 ClickOutside 的代码删除后,丝毫不影响组件的正常使用,而且文档中也没有一处关于 ClickOutside 的文字描述,所以官方应该本就不想让我们用这个指令,只是不知道为啥要写到例子的代码里。

<template>
  <el-popover ref="popoverRef" placement="bottom-start" :visible="visible">
    <el-form-item label="例子" prop="example">
      <el-select v-model="value" placeholder="请选择" clearable>
        <el-option :key="item.value" :label="item.label" :value="item.value" v-for="item in examples" />
      </el-select>
    </el-form-item>

    <template #reference>
      <el-form-item>
        <el-button v-click-outside="handleClickOutside" text type="primary" icon="DArrowRight"
          @click="visible = !visible">更多</el-button>
      </el-form-item>
    </template>
  </el-popover>
  <el-button icon="search" type="primary" @click="getList">查询</el-button>
</template>

<script setup lang="ts">
import { ClickOutside as vClickOutside } from 'element-plus'

const value = ref()
const examples = [
  // ...例子的选项
]
const visible = ref(false)
const popoverRef = ref()
const getList = () => {
  // ...获取数据的逻辑
  visible.value = false
}
const handleClickOutside = (mouseup: MouseEvent) => {
  // contentRef 就是弹出框对应的元素
  if (!popoverRef.value.popperRef.contentRef.contains(mouseup.target))
    visible.value = false
}
</script>

teleported 属性

虽然尝试自己实现了 ClickOutside 的逻辑,但是关于 el-select 的问题仍旧没有解决,不过在探索了这部分逻辑后答案已经显而易见了,即与上面提到的 teleported 属性有关;

由于 el-select 组件默认将 teleported 属性设置为了 true,它的下拉框就被插入到了 body 元素中,而下拉框不是 el-popover 组件弹出框内部的元素,使用 DOM 的 contains 方法来判断时就出了问题,所以只要设置 teleported 属性为 false 就行了;

<template>
  <el-popover placement="bottom-start" trigger="click">
    <el-form-item label="例子" prop="example">
      <el-select v-model="value" :teleported="false" placeholder="请选择" clearable>
        <el-option :key="item.value" :label="item.label" :value="item.value" v-for="item in examples" />
      </el-select>
    </el-form-item>

    <template #reference>
      <el-form-item>
        <el-button text type="primary" icon="DArrowRight">更多</el-button>
      </el-form-item>
    </template>
  </el-popover>
</template>

<script setup lang="ts">
const value = ref()
const examples = [
  // ...例子的选项
]
</script>

在回头梳理整个问题解决的过程的时候,也让我有了一些经验,以后在使用其他库遇到问题没有思路时,应该适当的去看一下源码,了解了一定的实现原理更有助于我们快速定位解决问题的方向。

使用场景二

还是因为某些页面表格的字段特别多,需求是加一个设置按钮,点击后显示弹出框,以多选的形式让用户在所有字段中自定义表格中需要展示的字段;

hide 方法

这里要用 el-popover 实现选择器中的一个逻辑,也就是需要在用户选择完毕,点击确定按钮之后关闭弹出框;但在文档中并未明确说明有暴露出这个方法,本来想再通过 visible 属性来控制,不过在虚拟触发例子中发现了 delayHide 方法,尝试后并没有效果,随后打印了一下 el-popover 的组件实例,发现了其中的 hide 方法并实现了效果;

<template>
  <el-popover ref="popoverRef" :width="230" title="表头筛选" trigger="click" placement="bottom-start">
    <template #reference>
      <el-button icon="Tools" text />
    </template>

    <el-checkbox-group v-model="checkList">
      <el-checkbox v-for="item in options" :key="item.prop" :label="item.prop">
        {{ item.label }}
      </el-checkbox>
    </el-checkbox-group>

    <footer>
      <el-button @click="handleClose">取消</el-button>
      <el-button type="primary" @click="handleSubmit">确认</el-button>
    </footer>
  </el-popover>
</template>

<script setup lang="ts">
const popoverRef = ref()
const checkList = ref<(string | number)[]>([])
const options = [
  { prop: 'example', label: '例子', ...其他字段 },
  // ...其他选项
]
const handleSubmit = () => {
  // ...切换表格字段的逻辑
  handleClose()
}
const handleClose = () => {
  popoverRef.value?.hide()
}
</script>

封装组件

封装组件的好处不必多说,除了筛选列头的功能,其他有需要类似逻辑选择器的时候也可以使用;

我这里在封装的时候尝试了将虚拟触发和非虚拟触发的逻辑都封装了进来,一开始是直接使用 props 传进来,不过好像是因为中间多了一层的原因,弹出框显示的位置跑到了左上角;代码增增删删改了一番后,才反应过来 vue3 还有透传 Attributes 这一功能,并且同时支持 v-bind 和 v-on,即在给组件添加属性或事件时,如果组件内部并未接收,则会自动将其添加至组件根元素上,对于想要二次封装组件库的人来说可谓是十分友好;

只需要用 props 接收一下 virtual-triggering 属性来判断一下是否显示组件内默认的按钮就能兼顾虚拟触发了;

<template>
  <el-popover ref="popoverRef" trigger="click" placement="bottom-start" :width="230"
    :virtual-triggering="virtualTriggering" @before-enter="checkList = [...defaultValue]"
    @after-leave="checkList = []">
    <template #reference v-if="!virtualTriggering">
      <slot name="trigger">
        <el-button icon="Tools" text />
      </slot>
    </template>

    <el-checkbox-group v-model="checkList">
      <el-checkbox v-for="item in options" :key="item.prop" :label="item.prop" style="display: block;">
        {{ item.label }}
      </el-checkbox>
    </el-checkbox-group>

    <footer>
      <el-button @click="handleClose">取消</el-button>
      <el-button type="primary" @click="handleClose(); emit('submit', checkList)">确认</el-button>
    </footer>
  </el-popover>
</template>

<script setup lang="ts" name="popover-select">
// 组件传参
interface Props {
  // 选项
  options: { prop: string, label: string, [key: string]: any }[]
  // 默认选中值
  defaultValue: (string | number)[]
  // 是否虚拟触发
  virtualTriggering?: Boolean
}
interface Emits {
  // 提交
  (e: 'submit', value: (string | number)[]): void
}
withDefaults(defineProps<Props>(), {})
const emit = defineEmits<Emits>()

const popoverRef = ref()
const checkList = ref<(string | number)[]>([])
const handleClose = () => {
  popoverRef.value?.hide()
}
</script>

并且使用虚拟触发时的写法也和 el-popover 组件一致。

<template>
  <el-button ref="virtualRef" icon="Tools" />
  <popover-select title="表头筛选" :virtual-ref="virtualRef" virtual-triggering :options="options"
    :default-value="['example']" @submit="handleSubmit" />
</template>

<script setup lang="ts">
const virtualRef = ref()
const options = [
  { prop: 'example', label: '例子' },
  // ...其他选项
]
const handleSubmit = (checkList: string[]) => {
  // ...切换表格字段的逻辑
}
</script>

结语

由于部分尝试最后没有在项目中实际使用,但这些尝试也有一定的价值,所以还是值得记录的,并且在记录过程中也收获到了其他经验,看来温故的确使人知新。