关于el-popover在循环引用时造成的性能问题
普遍用法
//通过插槽
<el-popover
placement="bottom"
title="标题"
width="200"
trigger="click"
content="这是一段内容,这是一段内容,这是一段内容,这是一段内容。">
<el-button slot="reference">click 激活</el-button>
</el-popover>
//通过v-popover指令
<el-popover
ref="popover"
placement="bottom"
title="标题"
width="200"
trigger="click"
content="这是一段内容,这是一段内容,这是一段内容,这是一段内容。">
</el-popover>
<el-button v-popover:popover>click 激活</el-button>
trigger属性用于设置何时触发 Popover,支持四种触发方式:hover,click,focus 和 manual。对于触发 Popover 的元素,有两种写法:使用 slot="reference" 的具名插槽,或使用自定义指令v-popover指向 Popover 的索引ref。
引用
使用插槽绑定el-popover,由于是具名插槽,所以这里有且只能有一个DOM绑定(需要n个DOM可以展示el-popover,就需要生成n个el-popover)。
使用v-popover指令绑定el-popover:
<el-popover
ref="testPopover"
placement="right"
title="标题"
width="200"
trigger="hover"
content="这是一段内容,这是一段内容,这是一段内容,这是一段内容。">
</el-popover>
<el-button ref="btn1" v-popover:testPopover>hover 激活</el-button>
<el-button ref="btn2" v-popover:testPopover>hover 激活1</el-button>
<el-button ref="btn3" v-popover:testPopover>hover 激活2</el-button>
<el-button ref="btn4" v-popover:testPopover>hover 激活3</el-button>
这里写了四个按钮都绑定了同一个el-popover,但实际上只有最后一个能够正确的触发el-popover。
问题
在正常用法下,一个el-popover通常对应一个触发DOM,也就是说在列表中,多少个触发DOM就会生成多少个el-popover元素,当DOM达到一定数量不可避免的会造成性能问题,而实际上在列表中,el-popover样式大都是一样的,只是数据不同,每个DOM对应一个el-popover实际是不必要的、浪费的性能开销。
默认用法效果图:
可以看到在详情时还算流畅,到编辑时,由于可操作DOM的增加,界面明显卡顿,这里还只有4个人×31天,如果是10,20...人则更卡。
优化方案
通过翻阅Element文档,并未找到有其他触发方式,而在Element-Plus中提出了一种虚拟触发方式。
像 Tooltip一样,Popover 可以由虚拟元素触发,这个功能就很适合使用在触发元素和展示内容元素是分开的场景。通常我们使用 #reference 来放置我们的触发元素, 用 triggering-element API,您可以任意设置您的触发元素 但注意到触发元素应该是接受 mouse 和 keyboard 事件的元素。
<template>
<el-button ref="buttonRef" v-click-outside="onClickOutside">
Click me
</el-button>
<el-popover
ref="popoverRef"
:virtual-ref="buttonRef"
trigger="click"
title="With title"
virtual-triggering
>
<span> Some content</span>
</el-popover>
</template>
<script setup lang="ts">
import { ref, unref } from 'vue'
import { ClickOutside as vClickOutside } from 'element-plus'
const buttonRef = ref()
const popoverRef = ref()
const onClickOutside = () => {
unref(popoverRef).popperRef?.delayHide?.()
}
</script>
通过查看文档理解,这里的触发方式是通过动态把触发的元素通过ref获取并传递给el-popover
这种触发方式把DOM和el-popover进行分离,使多个DOM可以对应一个el-popover也就达到了我们所要的。
当然这里是Element-Plus所提供的实例,而我们的项目是vue2,用不了Element-Plus。
查阅文档和查看组件库代码,其实vue2版本的Element也支持虚拟元素触发
下面是一段组件库源码:
<template>
<span>
<transition
:name="transition"
@after-enter="handleAfterEnter"
@after-leave="handleAfterLeave">
<div
class="el-popover el-popper"
:class="[popperClass, content && 'el-popover--plain']"
ref="popper"
v-show="!disabled && showPopper"
:style="{ width: width + 'px' }"
role="tooltip"
:id="tooltipId"
:aria-hidden="(disabled || !showPopper) ? 'true' : 'false'"
>
<div class="el-popover__title" v-if="title" v-text="title"></div>
<slot>{{ content }}</slot>
</div>
</transition>
<span class="el-popover__reference-wrapper" ref="wrapper" >
<slot name="reference"></slot>
</span>
</span>
</template>
<script>
props: {
reference: {}, //这里传入需要绑定的元素
}
mounted() {
//核心 这里首先获取从外部传入的绑定DOM 如果没有传入则 reference 值为 undefined
let reference = this.referenceElm = this.reference || this.$refs.reference;
const popper = this.popper || this.$refs.popper;
//没有传入 reference 时,将 reference 赋值为 this.$refs.wrapper.children[0] =》 传入的solt组件
//补充: 在上面通过 v-popover 绑定,本质上也是将DOM传递到solt中,而HTML代码是从新到后依次执行渲染,所以在渲染时,最后绑定 v-popover 的DOM才 会真正绑定到el-popover上,这也是为什么上面例子中最后一个按钮触发,其他都不触发(类似于css同属性新盖旧)。
if (!reference && this.$refs.wrapper.children) {
reference = this.referenceElm = this.$refs.wrapper.children[0];
}
//下面会进行一段事件绑定和逻辑处理等代码
...由于源码较多这里省略
}
</script>
通过这段代码可以发现,外部传递的DOM会比slot插槽优先级更高,所以我们可以尝试使用传递DOM的方式进行改造:
<template>
<el-popover
ref="popover"
:reference="currentButtonRef"
placement="bottom"
width="400"
trigger="click"
>
</el-popover>
<vxe-table ref="xTable" :data="tableData">
<vxe-column>
<template #default="{ row }">
<!-- 核心:因为是在表格中渲染,这里实际代表每一个单元格,我们要触发el-popover必须要区分开每一个单元格 -->
<!-- 这里通过uerId和对应单元格的日期来区分 -->
<div
:ref="'buttonRef-'+row.userId+'-'+row.data.find(item => item.day === date[2])?.day"
@click="handleEdit(row,row.data.find(item => item.day === date[2])?.day)"
>
</div>
</template>
</vxe-column>
</vxe-table>
</template>
<script>
export default {
data() {
return {
currentButtonRef: null, //定义传递给 el-popover 的 reference 组件
}
},
methods: {
//需要触发 el-popover 的绑定DOM的点击事件
handleEdit(row, day) {
this.$nextTick(() => {
this.currentButtonRef = this.$refs['buttonRef-' + row.userId + '-' + day][0] //将需要触发的DOM绑定给el-popover组件
this.$nextTick(async() => {
...这里省略获取popover内容的操作
//这里通过 popover 组件中的 doShow() 方法即可显示el-popover组件
this.$refs.popover.doShow()
})
})
},
}
}
</script>
这里看下实际效果:
这里能看出来组件可以显示出来,内容也能跟着变化,但是位置一直都是第一个触发元素的位置。
原因: 通过上面源码可知,绑定DOM的操作是在mounted中进行的,而mounted生命周期只会触发一次,所以只有组件第一次渲染时才会绑定DOM,后续即使更新了DOM也不会在进行绑定操作,导致内容虽然变化了,位置并不会改变。
解决方案: 知道了原因后,解决起来就非常简单了,只需要在改变绑定的DOM时,再次触发组件的绑定逻辑就好了。
方案一: 通过继承组件并修改组件方法,将绑定逻辑抽离并在改变绑定DOM时手动触发该方法。(需要将源代码复制并抽离成API,虽然不复杂但是略微麻烦)
方案二: 既然是mounted生命周期,那就可以通过v-if(会销毁和创建组件,即每次都会触发组件的mounted生命周期),无需编写过多代码,简洁方便。
这里优化后的代码:
<template>
<el-popover
ref="popover"
:reference="currentButtonRef"
v-if="showPop"
placement="bottom"
width="400"
trigger="click"
>
</el-popover>
<vxe-table ref="xTable" :data="tableData">
<vxe-column>
<template #default="{ row }">
<!-- 核心:因为是在表格中渲染,这里实际代表每一个单元格,我们要触发el-popover必须要区分开每一个单元格 -->
<!-- 这里通过uerId和对应单元格的日期来区分 -->
<div
:ref="'buttonRef-'+row.userId+'-'+row.data.find(item => item.day === date[2])?.day"
@click="handleEdit(row,row.data.find(item => item.day === date[2])?.day)"
>
</div>
</template>
</vxe-column>
</vxe-table>
</template>
<script>
export default {
data() {
return {
showPop: false,
currentButtonRef: null, //定义传递给 el-popover 的 reference 组件
}
},
methods: {
//需要触发 el-popover 的绑定DOM的点击事件
handleEdit(row, day) {
this.$nextTick(() => {
this.showPop = false //每次绑定前将上次渲染的组件移除
this.$nextTick(() => {
this.currentButtonRef = this.$refs['buttonRef-' + row.userId + '-' + day][0] //将需要触发的DOM绑定给el-popover组件
this.showPop = true //将组件重新渲染,这时组件内部的mounted生命周期会再次执行并绑定DOM
this.$nextTick(async() => {
...这里省略获取popover内容的操作
//这里通过 popover 组件中的 doShow() 方法即可显示el-popover组件
this.$refs.popover.doShow()
})
})
})
},
}
}
</script>
到这里对于el-popover的性能问题已经解决。
拓展
注: 这里是根据项目要求进行的逻辑补充
el-popover通过click进行触发时,在点击el-popover外的其他区域都会关闭popover 注:这里组件本身特性,只是在我们项目中需要特殊处理
el-popover在渲染时,动态改变容器高度时,会导致popover错位
el-popover未关闭时,绑定的DOM消失,会导致popover错位
这里演示下未处理的时候:
这些问题其实只要在源码中,加上适配我们业务要求的代码即可,那我们该如何修改源码使其达到业务要求呢。
一般组件源码都是通过npm引入的,会存放在项目的node_modules文件夹中,而这个文件夹一般都很大,git上传大都会忽略这个文件夹(大部分都是部署的时候再 npm install),所以无法直接通过修改node_modules中对应的组件代码来实现业务逻辑。
想修改组件源码一般通过以下两种方案:
方案一: 通过组件实例修改(通过ref获取组件实例,实例上会有组件的方法,重写对应方法以达到项目要求)。
方案二: 通过编写自定义组件来继承element组件,再对组件方法进行修改(这种方法比第一种要方便和简洁一些)。
这里通过方案二来修改业务逻辑:
1.el-popover通过click进行触发时,在点击el-popover外的其他区域都会关闭popover
这里popover的显示和隐藏肯定是通过某种方法进行修改的,先查看源码
<template>
<span>
<transition
:name="transition"
@after-enter="handleAfterEnter"
@after-leave="handleAfterLeave">
<div
class="el-popover el-popper"
:class="[popperClass, content && 'el-popover--plain']"
ref="popper"
v-show="!disabled && showPopper"
:style="{ width: width + 'px' }"
role="tooltip"
:id="tooltipId"
:aria-hidden="(disabled || !showPopper) ? 'true' : 'false'"
>
<div class="el-popover__title" v-if="title" v-text="title"></div>
<slot>{{ content }}</slot>
</div>
</transition>
<span class="el-popover__reference-wrapper" ref="wrapper" >
<slot name="reference"></slot>
</span>
</span>
</template>
可以看到代码中有 v-show="!disabled && showPopper",很明显是通过这里来控制显示隐藏的,而这两个参数名也很好确认各自的用处disabled:是否禁用,showPopper:是否显示popover。
那只需要找到何时将showPopper修改为false即找到了关闭事件。(下面代码只是把我这里要修改的对应方法源码列举出来,不同组件方法并不相同,需要根据实际场景去找对应方法,这里只是提供思路)
handleDocumentClick(e) {
let reference = this.reference || this.$refs.reference;
const popper = this.popper || this.$refs.popper;
if (!reference && this.$refs.wrapper.children) {
reference = this.referenceElm = this.$refs.wrapper.children[0];
}
if (!this.$el ||
!reference ||
this.$el.contains(e.target) ||
reference.contains(e.target) ||
!popper ||
popper.contains(e.target)) return;
this.showPopper = false;
},
这个就是我们需要调整的方法,那如何继承组件并修改组件方法呢。
首先在目录下新建
然后编写代码:
<script>
import { Popover } from 'element-ui' //引入element的popover组件
import Vue from 'vue' //引入vue
export default Vue.extend({ //Vue.extend是vue中的api,用于继承组件
extends: Popover, //指定要继承的组件
//以下都是业务逻辑代码
name: 'MyPopover',
props: {
isShowPopup: { //这里标记是否需要点击区域外关闭 popover
type: Boolean,
default: false
}
},
methods: {
handleDocumentClick(e) {
let reference = this.reference || this.$refs.reference
const popper = this.popper || this.$refs.popper
if (!reference && this.$refs.wrapper.children) {
reference = this.referenceElm = this.$refs.wrapper.children[0]
}
if (!this.$el ||
!reference ||
this.$el.contains(e.target) ||
reference.contains(e.target) ||
!popper ||
popper.contains(e.target) || this.isShowPopup) { //这里多加一个标记判断
return
}
this.showPopper = false
}
}
})
</script>
注:此写法是vue框架所支持,所有组件均可通过该方法继承并修改。
使用继承组件:等同于自定义组件,只需引入即可正常使用。
<template>
<MyPopover
popper-class="mySchedulingPopover"
v-if="showPop"
ref="popover"
:reference="currentButtonRef"
placement="bottom"
width="400"
trigger="click"
:isShowPopup="isShowPopup"
@hide="popoverHide"
>
//这里是业务代码
</MyPopover>
</template>
<script>
//简化版本...
import MyPopover from '@/components/myElPopover/myElPopover.vue'
export default {
components: { MyPopover },
methods:{
closeDialogFunc() { //关闭添加商户弹窗延迟修改标记值
this.timeOut = setTimeout(() => {
this.isShowPopup = false
}, 500)
},
showDialogFunc() { //打开添加商户弹窗修改标记值并清除计时器
clearTimeout(this.timeOut)
this.isShowPopup = true
},
}
}
</script>
这里通过打开关闭添加商户弹窗来更改标记值,使popover在添加商户弹窗打开时,不能通过点击其他区域关闭来达到业务需求。
2.el-popover在渲染时,动态改变容器高度时,会导致popover错位
动态高度其实popover内部已经有事件帮我们处理掉了。
这里简单看段效果:
当我在拖动滚动条时,popover也会跟随滚动
通过F12查看popover元素,发现他的父元素是body,而popover是绝对定位,那就可以明确他是根据body来进行定位的,而不是根据触发DOM来进行定位的。
那滚动后,popover要想跟着触发DOM滚动,必然是有监听滚动事件动态updatepopover的位置。
注: 这段代码并非在popover组件中,对于这种组件代码无法找到的方法,可以通过ref查看组件实例上的方法猜测。
这里updatePopper很明显就是更新popper。注: 因为popover是基于Vue-popper开发的,所以这段代码实际上实在vue-popper.js中,至于他内部做了什么这里暂时不研究,有兴趣可以去看看源码。
找到对应方法就很好解决问题,只需要在接口请求完成后,手动刷新一下popover即可:
handleEdit(row, day) {
this.showPop = false //隐藏 popover 使其卸载
this.$nextTick(() => {
this.currentButtonRef = this.$refs['buttonRef-' + row.userId + '-' + day][0]
this.showPop = true
this.$nextTick(async() => {
this.$set(this, 'checkList', [])
this.popoverLoading = true
this.$refs.popover.doShow()
try {
const res = await getScheduleRecordV1({ userId: row.userId, day })
this.selectMerIds = res.data
this.$set(this.editPopoverForm, 'userId', row.userId)
this.$set(this.editPopoverForm, 'day', day)
this.$set(this.editPopoverForm, 'nickname', row.nickname)
const scheduleList = row.data.find(item => item.day === day)?.scheduleList
scheduleList.forEach(item => {
this.checkList.push(item.scheduleId)
})
this.isShowRest = this.checkList.length > 0
} finally {
//接口数据回来后
this.popoverLoading = false
await this.$nextTick()
this.$refs.popover.updatePopper() //手动刷新popover
}
})
})
}
3.el-popover未关闭时,绑定的DOM消失,会导致popover错位
这个就很好处理了,只需要在请求数据操作之前,手动关闭popover即可。
async getList() {
this.loading = true
try {
this.$refs.popover?.doClose() //通过实例上的close方法来手动关闭弹窗
} finally {
try {
const res = await getScheduleRecordListV2(this.queryParams)
this.tableData = res.rows
this.total = res.total
} finally {
this.loading = false
}
}
}
处理完之后:
到此,通过继承组件和popover的虚拟渲染,将popover对业务中不适配的逻辑全都进行了优化处理。