本文核心内容:
- 核心解决中后台系统,当同时存在多个大数据量的下拉列表导致页面卡顿、无法响应的问题。
- 解决方案采用的是虚拟列表,基于vue2.5 + element2.0对el-select进行了一层封装,实现el-select下拉的虚拟列表。
相信点进这里的童鞋,可能都遇到了同一个问题,并在寻找合适的解决方案。话不多说,直接进入主题吧~
一、背景讲解
1.简单讲述背景:
- 最近接的需求,一个后端哥们捣鼓的前端项目。页面中一个有很多筛选框的组件,通过切换一定的条件拉取不同的下拉数据(就是下面贴图的组件),一个很常见的中台交互行为。
- 下拉数据的获取方式比较特殊,是通过:爬一个ssr页面中的内容获取。具体行为大概是通过正则匹配出一个变量名获取一个js对象字符串,大概是总大小30多k的一个字符串吧,最后用
eval
、new Function
等方式把字符串变成正常的js对象。
2.问题排查:
-
根据某个操作路径操作,发现页面卡顿,基本是卡崩状体,任何ui交互无反应,只能等卡顿完才有响应。由以上表象,从:
- js线程阻塞问题。监控performance面板的js执行时间,未发现明显js执行慢占据线程问题,也使用了worker,把字符串转为js对象的那段代码用woker执行未发现改善。
- 内存排查。一开始将js字符串转换为js对象时采用的方法是直接用
eval(str)
的方案,从网上搜到相关eval
多次执行内存释放的问题、还有eval
的直接执行和间接执行(可参考:这篇关于eval的文章),尝试后也没有改善。 - dom节点问题。由于之前做项目的时候,有遇到过整个页面卡死的现象,引起的原因就是瞬间大量的dom操作,导致浏览器这个tab的进程卡顿。尝试把dom的部分注释掉,js执行部分保留后发现,页面交互丝滑得难以置信。好吧,这就破案了~
3.简单分析:
- 使用
document.getElementsByTagName('*').length
分析当前页面的dom数量 - 如下图,这些select会根据不同条件获取数据,默认第一项时候的dom数
- 如下图,切换了组件类型的条件后,dom数量剧增
- 由此可见,dom数量变得巨大,主要集中在地区、运营商这种下拉数据中,浏览器一次渲染了大量的dom。这也是导致后续再切换条件,整个浏览器tab卡到失去响应的罪魁祸首(浏览器进行大量的dom的增删操作)。
二、解决方案
1.使用element提供的远程搜索 remote-method
- 远程搜索绝对是解决这类问题的成本相对低的方案了。但是讲述背景里也介绍到了,这个项目的数据是爬ssr页面拿下来了,不能直接改造成远程加载的方式获取数据。
- 前端实现远程搜索。把拉回来的数据存在内存,通过
filter-method
自定义搜索,模拟远程搜索。但是这样可能存在一个问题,搜索到一个重复度高的keyword,也会导致一次性加载过多的数据,所以也被弃用了。
2.对el-select封装一层,实现虚拟列表
-
虚拟列表绝对是前端优化的一个利器,能解决很多页面的性能问题,本文实现虚拟列表的方案是使用 交叉监视器
IntersectionObserver
+padding
。 -
具体虚拟列表实现可参考:虚拟列表无限滚动
-
说说组件实现思路吧:
-
在el-select中添加子元素
<li class="start" />
和<li class="end" />
,作为显示区的开始结束标志,用IntersectionObserver
对其进行监听 -
通过
v-if
控制,根据当前的startIndex
和endIndex
控制元素是否插入dom。即nowIndex >= startIndex && nowIndex < endIndex
-
通过计算一个
li
的高度,乘上startIndex
充当滚动列表的padding-top
,让上方淡出可视区被销毁的dom元素有一个占位空间,保证滚动列表的正常,一次实现具体的虚拟滚动。 -
最后贴张效果图
-
三、开箱即用
-
直接贴出整个组件的代码~ 给到正真需要的开发小伙伴哈
-
使用方法完全跟正常使用element的select组件一样,只是不需要自己完成
el-option
的v-for
步骤。接入后可直接参考element2.0的select使用文档。传入数组:[ { label: '', value: '' } ]
即可。如需定制化,可以传入一个arrange的function
自己实现 -
<template> <el-select ref="elSelectRef" v-model="proxyValue" :filter-method="selectFilter" v-bind="$attrs" v-on="$listeners" @visible-change="handleVisible" > <li class="start" /> <template v-for="(item, i) in optionsDuplicate"> <el-option v-if="isRender(i)" ref="elOptionItem" :key="item.value + i" :label="item.label" :value="item.value" /> </template> <li class="end" /> </el-select> </template> <script> import cloneDeep from 'lodash.clonedeep' const maxRender = 60 const refreshRender = 30 let listItemHeight = 34, // 默认每项list高度 fatherUlDomNormalPaddingTop = 6 // 默认下拉ul的paddingTop export default { name: "BaseSelect", props: { options: { type: Array, default () { return [] } }, value: { type: [String, Array, Number], default () { return [] } } }, data () { return { observer: Object.create(null), startIndex: 0, optionsDuplicate: [], fatherUlDom: Object.create(null) } }, computed: { proxyValue: { get () { return this.value }, set (val) { this.$emit('update:value', val) } }, isRender () { return i => i >= this.startIndex && i < this.endIndex }, endIndex () { return this.startIndex + maxRender } }, watch: { options (val) { this.optionsDuplicate = cloneDeep(val) this.$nextTick(_ => this.handleVisible(true)) } }, methods: { selectFilter (enterStr) { this.initData() if (!enterStr) { this.optionsDuplicate = this.options return } this.optionsDuplicate = this.options.filter(item => item.label.includes(enterStr)) }, handleVisible (isVisible) { if (!isVisible || !this.$refs.elOptionItem) { this.observer.disconnect && this.observer.disconnect() return } this .initData() .initObserve() }, initData () { this.startIndex = 0 this.fatherUlDom.style && (this.fatherUlDom.style.paddingTop = fatherUlDomNormalPaddingTop + 'px') return this }, initObserve () { this.$nextTick(() => { const listDomVm = this.$refs.elOptionItem[0] if (!listDomVm) return const listDom = this.$refs.elOptionItem[0].$el listItemHeight = listDom.offsetHeight || listItemHeight this.fatherUlDom = listDom.parentElement fatherUlDomNormalPaddingTop = parseFloat(window.getComputedStyle(this.fatherUlDom).paddingTop) || fatherUlDomNormalPaddingTop // 在elSelect实例中找到下拉dom const dropDownDomVm = this.$refs.elSelectRef.$children.find(_ => _.$el.className.includes('el-select-dropdown')) if (!dropDownDomVm) return const dropDownDom = dropDownDomVm.$el const [startDom, endDom] = [dropDownDom.querySelector('.start'), dropDownDom.querySelector('.end')] this.observer = new IntersectionObserver((entries) => { console.log('entries', entries) if (entries.length >= 2) return // 避免在元素交替删除的瞬间,start、end同时进入可视区导致出现逻辑问题 const dom = entries[0] // 取出第一个做判断 if (!dom.isIntersecting) return console.log(dom.intersectionRatio, dom) if (dom.target === endDom) { // 向下滚动 console.log('first', this.startIndex) const resultIndex = this.startIndex + refreshRender this.startIndex = resultIndex > this.optionsDuplicate.length ? this.startIndex : resultIndex console.log('second', this.startIndex) } else { // 向上滚动 const resultIndex = this.startIndex - refreshRender this.startIndex = resultIndex < 0 ? 0 : resultIndex } this.fatherUlDom.style.paddingTop = this.startIndex * listItemHeight + fatherUlDomNormalPaddingTop + 'px' // 填充高度 }) this.observer.observe(startDom) this.observer.observe(endDom) }) } } } </script> 复制代码
-
对于封装
element
组件的小心得,如果想获取到对应组件的对应dom元素,直接在element
的实例中取,比如上面代码中,要找当前el-select
的对应下拉框,都不是直接用dom的api
去获取的,而是在这个组件实例中的$children
获取 -
关于这种padding的无法覆盖的业务需求:
- list中item内容不等高。如果这种场景该组件不能很好使用,若不是硬性需求,可从布局上进行item的高度限制,不折行且溢出隐藏
-
完工啦~试用之后完美解决了页面卡顿的问,且业务功能正常不受影响。如果用起来有发现什么bug可以反馈哈,我会持续优化跟进~
-
最后,如果你有什么更好建议,方案,赶紧告诉我,让小弟学习学习~?