el-popover在大数据列表中的性能问题

869 阅读8分钟

关于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,支持四种触发方式:hoverclickfocusmanual。对于触发 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

testPopover-solt

问题

在正常用法下,一个el-popover通常对应一个触发DOM,也就是说在列表中,多少个触发DOM就会生成多少个el-popover元素,当DOM达到一定数量不可避免的会造成性能问题,而实际上在列表中,el-popover样式大都是一样的,只是数据不同,每个DOM对应一个el-popover实际是不必要的、浪费的性能开销。

默认用法效果图:

defaultPopover

可以看到在详情时还算流畅,到编辑时,由于可操作DOM的增加,界面明显卡顿,这里还只有4个人×31天,如果是10,20...人则更卡

优化方案

通过翻阅Element文档,并未找到有其他触发方式,而在Element-Plus中提出了一种虚拟触发方式。

官方是这样描述的:

像 Tooltip一样,Popover 可以由虚拟元素触发,这个功能就很适合使用在触发元素和展示内容元素是分开的场景。通常我们使用 #reference 来放置我们的触发元素, 用 triggering-element API,您可以任意设置您的触发元素 但注意到触发元素应该是接受 mousekeyboard 事件的元素。

<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

这种触发方式把DOMel-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>

image-20241023143204269

这里看下实际效果:

myPopover1

这里能看出来组件可以显示出来,内容也能跟着变化,但是位置一直都是第一个触发元素的位置。

原因: 通过上面源码可知,绑定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>

myPopover3

到这里对于el-popover的性能问题已经解决。

拓展

注: 这里是根据项目要求进行的逻辑补充

el-popover通过click进行触发时,在点击el-popover外的其他区域都会关闭popover 注:这里组件本身特性,只是在我们项目中需要特殊处理

el-popover在渲染时,动态改变容器高度时,会导致popover错位

el-popover未关闭时,绑定的DOM消失,会导致popover错位

这里演示下未处理的时候:

myPopover4

这些问题其实只要在源码中,加上适配我们业务要求的代码即可,那我们该如何修改源码使其达到业务要求呢。

一般组件源码都是通过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;
},

这个就是我们需要调整的方法,那如何继承组件并修改组件方法呢。

首先在image-20241028101136472目录下新建image-20241028101152665

然后编写代码:

<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内部已经有事件帮我们处理掉了。

这里简单看段效果:

myPopover5

当我在拖动滚动条时,popover也会跟随滚动

image-20241028103218492

通过F12查看popover元素,发现他的父元素是body,而popover绝对定位,那就可以明确他是根据body来进行定位的,而不是根据触发DOM来进行定位的。

那滚动后,popover要想跟着触发DOM滚动,必然是有监听滚动事件动态updatepopover的位置。

注: 这段代码并非在popover组件中,对于这种组件代码无法找到的方法,可以通过ref查看组件实例上的方法猜测。

image-20241028134935165

image-20241028142036390

这里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
        }
    }
}

处理完之后:

myPopover6

到此,通过继承组件popover的虚拟渲染,将popover对业务中不适配的逻辑全都进行了优化处理。