一个文笔一般,想到哪是哪的唯心论前端小白。
前言
说真的,这个需求我是一丁点都不想做!!!
但是既然做了,就简单记录一下吧!!!
说下这个需求的来龙去脉,有一个已经维护了好几年的项目,最近测试提出了一个bug,说是某个抽屉(el-drawer)打开的太慢,希望能优化一下!
WTF?这什么bug,本着人道主义去点了点,发现还真TMD有点慢,点开控制台一看,接口响应速度还可以,不是接口导致的慢,而且点击关闭也得关闭好几秒。这么一来确实是前端渲染 dom 的时间比较久了。
再看业务逻辑,整个表单是由接口返回的一串json渲染出来的(类似于低代码开发),有一堆的 if-else 进行表单项的渲染,这都没什么问题,因为总共加起来也不过30多个表单项,隔壁抽屉里面的表单项跟这个差不多,响应速度是正常的。而且这个问题原来也没有被发现过,所以可能是这个 json 的原因导致的。
再往下梳理了一下,嗬,有个 select 类型的项,居然返回了 6000+ 的下拉菜单,而且这个下拉菜单在未来只会越来越多!这这这 . . . 总得有一天崩了的呀!
所以优化变成了必然,就有了这个工具。
效果如下:
思路
要优化这个场景,思路上有这么几个方向:
- 修改现在 json 生成部分的业务逻辑,增加远程搜索组件,从根本上解决这个问题。
- 结合社区方案,前端实现分页展示,滚动条触底或触顶实现分页,并配合本地搜索。
- 确定组件功能边界,自己实现虚拟列表,仅支持当前需求
目前就分析这三个方向,其实还有其他方案,但万变不离其宗这三个方案代表的是(修改业务、高投入的通用方案、低投入的解决方案):
- 第一个方案无疑是最完美的,这个问题的原因本身就是产品没有考虑到下拉菜单太多的场景,需要修改业务逻辑来满足大量下拉菜单的业务场景,但是这就需要重新研究这个组件的实现方式,如果远程搜索组件则需要后台增加查询接口,前端在渲染下拉菜单的时候去调用后台接口。这样有可能就需要前端每增加一个组件就需要和后台来沟通是否要修改后台,就脱离了原来的业务初衷,这里的逻辑也变得不伦不类。
- 这个方案也是可以考虑的,分页代表的就是只展示部分,同时也可以使用虚拟列表,配合本地查询,在现在的 el-select 的基础上进行扩展。丝毫没有问题,但是开发周期比较长,也没有那么多的场景去调试。
- 这个方案无疑是最适合的,为什么这么说嘞?原因我们已经能确认的是下拉菜单成千上万的场景是不应该存在的,!为什么要去花费心思去在一条错的路上去浪费资源呢?定好产品边界,使用方案二的简化版本先将当前的困境度过去呗!
需求简介
在现在的el-select的基础上使用分页或者虚拟列表的形式去处理大量的下拉菜单,保证页面的正常渲染及el-select的正常回显。
需求分析
主要涉及几个点:
- 下拉菜单主体实现虚拟展示,保证渲染效率
- 展开和关闭时要保证已选中的选项在虚拟列表内,保证回显的是 label,而不是 value
- select 清空时,虚拟列表回归到顶部
- 下拉菜单发生改变时,重新计算滚动条长度,并回归到顶部
- 多选场景暂不支持,因为无论如何都不可避免更多的js计算逻辑,两个数组去比较在极限场景下是无法避免的,所以我就砍掉了。
- 本地搜索时更新组件传入的下拉菜单。
开发
主要是需要关注插槽的使用,通过看文档发现,el-select 默认有一个 匿名插槽,放一张文档的截图:
通过社区分享的方案,并通过几次调试,发现里面的 el-scroll 是自适应高度的,即插槽内容的高度会默认撑起来滚动条。
这就方便多了,自己实现虚拟列表就好了!!!
分享
使用:
<el-form-item label="本地搜索">
<el-select
v-model="formData.sel"
placeholder="请选择"
clearable
@visible-change="changeVisible($event)"
>
<virtual-options
ref="virtualRef"
:virtualData="options"
:select-value="formData.sel"
/>
</el-select>
</el-form-item>
<script>
import VirtualOptions from "./components/VirtualOptions.vue";
const options = Array(10 * 1000)
.fill(null)
.map((item, i) => ({ label: `item-${i}`, value: i }));
export default {
components: { VirtualOptions },
data() {
return {
options: options,
virtualoptions: [],
filterOptions: [],
formData: {
sel: "",
},
filterValue: "",
};
},
methods: {
changeVisible(cb) {
this.$nextTick(() => cb && this.$refs["virtualRef"].resetVirtual()); // 解决打开白屏问题,必须使用 $nextTick 延时处理
},
},
};
</script>
虚拟option组件:
<template>
<div class="option-wrap" :id="randomId">
<!-- 真实dom -->
<div class="virtual-dom">
<!-- 使用虚拟列表渲染el-option组件 -->
<el-option
v-for="(item, index) in virtualOptions"
:label="item.label"
:value="item.value"
:key="`virtual_options_${index}`"
:style="`display: ${item.hide ? 'none' : 'block'}`"
></el-option>
<!-- 增加一个空的,解决第一次展示为空的问题 -->
<el-option
v-if="virtualData && virtualData.length"
disabled
style="display: none;"
:value="null"
:label="null"
></el-option>
</div>
</div>
</template>
<script>
export default {
props: {
virtualData: {
type: Array,
default: () => [],
},
selectValue: {
type: [String, Number],
default: "",
},
},
watch: {
// 监听selectValue的变化,当其发生变化时调用resetVirtual方法
selectValue() {
this.resetVirtual();
},
// 监听virtualData的变化,当其发生变化时调用initWrapHeight方法
virtualData() {
this.initWrapHeight();
},
},
data() {
return {
randomId: "", // 随机生成的ID值
virtualOptions: [], // 虚拟列表的选项数据
leafNumber: 1, // 选项的叶子节点数量
optionHeight: 34, // 选项的高度
};
},
mounted() {
// 初始化随机ID值,并在下一次DOM更新时调用initVirtual方法
this.initId();
this.$nextTick(() => this.initVirtual());
},
methods: {
// 生成一个随机的id值
initId() {
this.randomId =
"virtual_" + parseInt(Math.random() * 10 * 1024 * 1024 + "");
},
/**
* 工具 - 根据class名称递归查询父节点
* @param {HTMLElement} element 当前节点element
* @param {string} className 需要查询的class值
*/
getParents(element, className) {
var returnParentElement = null;
function getpNode(element, className) {
// 创建父级节点的类数组
let pClassList = element.parentNode.getAttribute("class").split(" ");
let pNode = element.parentNode;
if (!pClassList || !pClassList.length) {
// 如果未找到类名数组,表示父类无类名,则再次递归
getpNode(pNode, className);
} else if (pClassList && !pClassList.includes(className)) {
// 如果父类的类名中没有预期类名,则再次递归
getpNode(pNode, className);
} else if (pClassList && pClassList.includes(className)) {
returnParentElement = pNode;
}
}
getpNode(element, className);
return returnParentElement;
},
/**
* 重置虚拟列表,使用场景有两个
* 1- select选中时,保证选中数据正常为label值
* 2- 下拉框展开保证下拉菜单回显正常
*/
resetVirtual() {
const $wrap = document.getElementById(this.randomId);
const $virtualDom = $wrap.querySelector(".virtual-dom");
const $scroll = this.getParents($wrap, "el-select-dropdown__wrap");
const _scrollHeight = $scroll.offsetHeight;
let vIndex = 0;
if (this.selectValue !== "") {
// 查找选中值在虚拟数据中的索引
vIndex = this.virtualData.findIndex(
(item) => item.value === this.selectValue
);
}
// 计算可视区域内需要显示的选项数量
const showNumber =
parseInt(_scrollHeight / this.optionHeight) + this.leafNumber;
// 更新虚拟列表的选项数据
this.virtualOptions = this.virtualData.slice(vIndex, vIndex + showNumber);
this.$nextTick(() => {
// 更新虚拟列表的位置和滚动条的位置,实现选项的正确显示
$virtualDom.style.transform = `translate(0, ${vIndex *
this.optionHeight}px)`;
$scroll.scrollTop = vIndex * this.optionHeight;
});
},
// 初始化虚拟列表容器的高度
initWrapHeight() {
const $wrap = document.getElementById(this.randomId);
$wrap.style.height =
this.virtualData.length * this.optionHeight + 100 + "px"; // 设定虚拟列表的总高度
},
// 初始化虚拟列表
initVirtual() {
const $wrap = document.getElementById(this.randomId);
const $virtualDom = $wrap.querySelector(".virtual-dom");
$wrap.style.height =
this.virtualData.length * this.optionHeight + 100 + "px"; // 设定虚拟列表的总高度
const $scroll = this.getParents($wrap, "el-select-dropdown__wrap");
const scrollFunc = () => {
const _scrollHeight = $scroll.offsetHeight;
const _scrollTop = $scroll.scrollTop;
const showNumber =
parseInt(_scrollHeight / this.optionHeight) + this.leafNumber;
$virtualDom.style.transform = `translate(0, ${_scrollTop}px)`;
// 保证选中项超出可视范围后导致回显选择项异常
let vIndex = parseInt(_scrollTop / this.optionHeight);
// 保证最后一屏显示正常 1 为 leaf
if(vIndex > this.virtualData.length - showNumber){
vIndex = this.virtualData.length - showNumber + 1
}
if (
this.selectValue !== "" &&
this.virtualOptions.findIndex(
(item) => item.value === this.selectValue
) > -1
) {
this.virtualOptions = [
...this.virtualData.slice(vIndex, vIndex + showNumber),
Object.assign(
{},
this.virtualData.find((item) => item.value === this.selectValue),
{ hide: true }
),
];
} else {
this.virtualOptions = this.virtualData.slice(
vIndex,
vIndex + showNumber
);
}
};
// TODO 重置virtualData时,清理滚动方法并初始化滚动容器高度
this.$nextTick(() => $scroll.addEventListener("scroll", scrollFunc));
},
},
};
</script>
后记
上面的源码注释已经算是比较完善了,就不进行过多的赘述了,本来就是一个坑,有需要的可以在此基础上进行扩展,思路简单写一下:
- 本地搜索逻辑:在本地搜索filter-method属性提供的方法中,修改子组件的virtualData的值,它会触发重置虚拟列表高度,同时要将选中项也放进去。
- 多选场景,多选场景入参就是一个数组了,现在单选的时候使用的判断都是是否为空,所以多选以后要考虑的就是长度为零。同时为了保证回显正常,每次滚动的时候要进行已选结果和总集进行遍历对比,将已选中的拼在虚拟列表的最下方,并隐藏在那。这也是我为什么说多选场景不希望支持的一个重要原因。
所思即所得:
- 一个开箱即用的 虚拟列表解决方案。
- select组件各个场景需要关注的点。
- 原生js操作当前节点、父节点、子节点,并修改其样式或者其他属性
啥也不是~~~ SEE U !