本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com
前端瓶子君,关注公众号
回复算法,加入前端编程面试算法每日一题群
前言
今年随着 Vue3 的成为正式版本,我们的 Element-plus 也有了稳定版,那今天我们主要是讲一个功能。我们先来看一下 Element-plus 新出现的一个玩意:
image.png
虚拟列表选择器?这是啥玩意,还能虚拟?
大家都知道 Vue 的虚拟 dom,我用简单的话讲述一下:大概就是一棵以 JavaScript 对象作为基础的树,每一个节点称为 VNode ,用对象属性来描述节点,实际上它是一层对真实 DOM 的抽象,最终可以通过渲染操作使这棵树映射到真实环境上。啥事抽象,我也不懂,就跟你问我啥是面向对象一样。
让我们来看一张皇帝选妃图:
image.png
这么多的妃子,皇帝肯定一下子不可能全看完,总的一排一排来。再次大殿上装下三千嫔妃,是不是很拥堵。
这就好比我们的页面,后端接口一下给了你 10 万条数据,如果你一次性渲染到 DOM 上,会出现很严重的卡顿问题,简称为页面阻塞,这里就暂不讲解 url 从输入到浏览器渲染的问题,留着下次跟大家分享。
那我们应该怎么解决这种既有 10 万数据,又可以渲染时不卡顿的现象?
这时候虚拟列表跑出来了,它说:哎,我可以,快用我,快用我,快用我,重要的事情说三遍。
那行呗,那咱就用他,本次呢,我用Vue2和element Ui的提供的select组件来实现 Vue2版的select虚拟列表选择器
思路
利用滚动事件,去计算可视窗口内的第一项和最后一项,利用总数据数组分割的方法slice截取到可视列表数据,计算每条数据的height(高度)和offset(距离顶部的位置)缓存到一个数组上来保存信息,利用每条高度求出总数据的应该有的高度listTotalHeight,最后利用相对定位和绝对定位的结合,使用transform控制translateY使可视列表位置保持在可视窗口,如下图:
image.png
实现
首先我们来实现一个纯页面输出的虚拟列表,根据上面的思路实现一下,基本组件select-v2.vue:
<template> <div ref="wrapper" @scroll="refreshView()" style="width: 100%; height: 100%; overflow: auto; position: relative; margin: 0; padding: 0; border: none;" > <div :style="{ height: listTotalHeight + 'px' }" style="width: 100%; padding: 0; margin: 0;"></div> <div ref="item-wrapper" style="position: absolute; top: 0; left: 0; width: 100%; padding: 0; margin: 0;" > <div v-for="(d) in listViewWithInfo" :key="d.index" :style="{ height: d.height + 'px' }"> <slot :item="d.item" :height="d.height" :offset="d.offset" :index="d.index"></slot> </div> </div> </div></template><script>export default { props: { list: Array, // 列表数据 itemHeightGetter: Function, // 获取列表高度的函数 defaultItemHeight: Number, // 默认item高度 }, data() { return { listView: [], // 可视列表数据 listTotalHeight: 0, // 列表总高度 itemOffsetCache: [], // item信息缓存 topItemIndex: 0, // 可视窗口的第一项 }; }, computed: { listViewWithInfo() { // 封装listView,提供index、height、offset数据 return this.listView.map((item, viewIndex) => { const index = this.topItemIndex + viewIndex; const { height, offset } = this.getItemInfo(index); return { index, item, height, offset, } }); } }, watch: { list() { this.refreshView(); }, }, mounted() { this.refreshView({ resize: true }); }, methods: { // 重渲染可视列表(可供组件外部调用) refreshView(config) { if (config) { if (config.resize) { // 只有resize为true时对wrapper高度重新取值,减少DOM取值操作 this._viewHeight = this.$refs.wrapper.clientHeight; } if (config.clearCache) { // 清空缓存 this.itemOffsetCache = []; } } const scrollTop = this.$refs.wrapper.scrollTop; // 当前scrollTop const viewHeight = this._viewHeight; // 可视窗口高度 const topItemIndex = this.findItemIndexByOffset(scrollTop); // 可视窗口的第一项 const bottomItemIndex = this.findItemIndexByOffset(scrollTop + viewHeight); // 可视窗口的最后项 this.topItemIndex = topItemIndex; this.listView = this.list.slice(topItemIndex, bottomItemIndex + 1); // 可视列表 // 列表总高度 // 若提供了默认item高度(defaultItemHeight),则高度 = 已计算item的高度总合 + 未计算item数 * 默认item高度;否则全部使用计算高度 // 这里已计算过的item会缓存,所有item只会计算一次 const listTotalHeight = this.defaultItemHeight ? this.getItemInfo(this.itemOffsetCache.length - 1).offset + (this.list.length - this.itemOffsetCache.length) * this.defaultItemHeight : this.getItemInfo(this.list.length - 1).offset; this.listTotalHeight = listTotalHeight; console.log(listTotalHeight) this.$refs['item-wrapper'].style.transform = `translateY(${this.getItemInfo(topItemIndex - 1).offset}px)`; // 控制translateY使可视列表位置保持在可视窗口 // 对外抛出scroll事件 this.$emit('scroll', { topItemIndex, bottomItemIndex, listTotalHeight, scrollTop }); }, // 根据offset获取item的在列表中的index findItemIndexByOffset(offset) { // 如果offset大于缓存数组的最后项,按序依次往后查找(调用getItemInfo的过程也会缓存数组) if (offset >= this.getItemInfo(this.itemOffsetCache.length - 1).offset) { for (let index = this.itemOffsetCache.length; index < this.list.length; index++) { if (this.getItemInfo(index).offset > offset) { return index; } } return this.list.length - 1; } else { // 如果offset小于缓存数组的最后项,那么在缓存数组中二分法查找 let begin = 0; let end = this.itemOffsetCache.length - 1; while (begin < end) { let mid = (begin + end) / 2 | 0; let midOffset = this.getItemInfo(mid).offset; if (midOffset === offset) { return mid; } else if (midOffset > offset) { end = mid - 1; } else { begin = mid + 1; } } if (this.getItemInfo(begin).offset < offset && this.getItemInfo(begin + 1).offset > offset) { begin = begin + 1; } return begin; } }, // 获取item信息(有缓存则取缓存,无缓存则计算并缓存) getItemInfo(index) { // 超出取值范围,返回默认值 if (index < 0 || index > this.list.length - 1) { return { offset: 0, height: 0, }; } let cache = this.itemOffsetCache[index]; // 如果没有缓存,进行计算并缓存结果 if (!cache) { // 优先用itemHeightGetter计算高度,无itemHeightGetter则取defaultItemHeight作为高度 let height = (this.itemHeightGetter ? this.itemHeightGetter(this.list[index], index) : this.defaultItemHeight); cache = this.itemOffsetCache[index] = { height, // item高度 offset: this.getItemInfo(index - 1).offset + height, // 递归得出item的bottom距离列表顶部的距离,item的offset = 上个item的offset + 自己的height }; } // 如果已有缓存,直接返回缓存的结果 return cache; }, },}</script>复制代码使用select-v2.vue组件:
<template> <div id="app"> <div class="m-container"> <div class="m-header">我是头部</div> <div class="m-list"> <div class="m-list-container"> <select-v2 ref="list-view" @scroll="listScroll" :list="list" :item-height-getter="itemHeightGetter" :default-item-height="defaultItemHeight" > <div slot-scope="scope" class="item"> <div :style="{ color: scope.item.color }" >NO: {{ scope.item.no }}, height: {{ scope.height }}px, offset: {{ scope.offset }}px</div> </div> </select-v2> </div> </div> </div> </div></template><script>import selectv2 from './components/select-v2.vue'export default { name: 'App', components: { 'select-v2': selectv2, }, data() { return { list: [], page: 0, itemHeightGetter(item) { if (item.no % 33 === 0) { return 100; } return 20 + item.no % 10; }, defaultItemHeight: 30, } }, created() { this.getData().then(d => { this.list = d; }); }, methods: { listScroll(data) { if (!this._getting && data.bottomItemIndex >= this.list.length - 3) { this._getting = true; this.getData().then(d => { this.list.push(...d); this.page++; this._getting = false; }); } }, getData() { return new Promise(resolve => { setTimeout(() => { const baseIndex = this.page * 2000; resolve(new Array(2000).fill(0).map((i, index) => { return { no: baseIndex + index, color: ['#33d', '#3d3', '#d33', '#333'][Math.random() * 4 | 0], }; })); }, 100); }) }, },}</script><style lang="scss">html,body,#app { margin: 0; width: 100vw; height: 100vh; padding: 0;}.m-container { width: 100%; height: 100%; display: flex; flex-direction: column; .m-header { height: 40px; background: greenyellow; } .m-list { flex: 1; position: relative; .m-list-container { position: absolute; width: 100%; height: 100%; .item { height: 100%; display: flex; align-items: center; } } }}</style>复制代码最后如果你的效果是这样的,那么说明你就成功了一大步了
cvb13-6ldc7.gif
代码中是有 2000 条的数据的,但是我们在渲染的时候刚好满足可视窗口高度的列表,因为代码中为了显示特别一点,还特意加了一个独特的高度的,所以在滚动的时候无法精确到每次都有一定的条数。item-height-getter可以通过这个来添加个别Item特别的高度。default-item-height这个可以设置 item 默认的高度,在没有设置item-height-getter情况下,就可以固定每次显示多少条,因为高度都是一样的,可视窗口就那么宽。有兴趣的朋友可以自己去试一下,这里就不做展示了。
但是有伙伴会说我不知道一个 item 的内容有多少字,可能会有很多的字,但是我又不想单独的设置item-height-getter,譬如我们将App.vue改写成这样:
...<select-v2 ref="list-view" @scroll="listScroll" :list="list" :item-height-getter="itemHeightGetter" :default-item-height="defaultItemHeight"> <div slot-scope="scope" class="item"> <div v-if="scope.item.no === 1">代码中是有2000条的数据的,但是我们在渲染的时候刚好满足可视窗口高度的列表,因为代码中为了显示特别一点,还特意加了一个独特的高度的,所以在滚动的时候无法精确到每次都有一定的条数。`item-height-getter`可以通过这个来添加个别`Item`特别的高度。`default-item-height`这个可以设置item默认的高度,在没有设置`item-height-getter`情况下,就可以固定每次显示多少条,因为高度都是一样的,可视窗口就那么宽。有兴趣的朋友可以自己去试一下,这里就不做展示了。</div> <div v-else :style="{ color: scope.item.color }" >NO: {{ scope.item.no }}, height: {{ scope.height }}px, offset: {{ scope.offset }}px</div> </div> ...复制代码页面就变成了这样:
image.png
我们可以将select-v2.vue改一个小地方就可以了:
...<div ref="item-wrapper" style="position: absolute; top: 0; left: 0; width: 100%; padding: 0; margin: 0;"> <div v-for="(d) in listViewWithInfo" :key="d.index" :style="{ 'min-height': d.height + 'px' }"> <slot :item="d.item" :height="d.height" :offset="d.offset" :index="d.index"></slot> </div></div>...复制代码细心的伙伴可能就已经发现了,就是将item的高度设置时,改为min-height即可, 页面就回归正常了,有兴趣了伙伴可以尝试一下:
image.png
现在我们已经完成了我们最基本的虚拟列表组件,但是我们最终的目标是:虚拟列表选择器。
虚拟列表选择器
那来呗,我们先安装和引入 ElementUI:
npm i element-ui -S
复制代码//在main.js引入import ElementUI from 'element-ui';import 'element-ui/lib/theme-chalk/index.css';Vue.use(ElementUI);复制代码我们来将select-v2.vue组件改写:
<template> <el-select @change="handleChange" :placeholder="placeholder" clearable filterable remote :remote-method="remoteMethod" :loading="loading" v-model="selectValue" :popper-class="`m-el-select-v2 ${popperClass ? popperClass : ''}`" @visible-change="visibleChange" > <div v-if="isShow" ref="wrapper" class="m-virtual-wrapper" @scroll="refreshView()" style=" width: 100%; height: 100%; overflow: auto; position: relative; margin: 0; padding: 0; border: none; " > <div :style="{ height: listTotalHeight + 'px' }" ref="listTotalHeightRef" style="width: 100%; padding: 0; margin: 0" ></div> <div ref="item-wrapper" style="position: absolute; top: 0; left: 0; width: 100%; padding: 0; margin: 0" > <div v-for="d in listViewWithInfo" :key="d.index" :style="{ height: d.height + 'px' }"> <slot :item="d.item" :height="d.height" :offset="d.offset" :index="d.index"> <el-option :label="d.item.label" :value="d.item.value"> </el-option> </slot> </div> </div> </div> </el-select></template><script>export default { name: "m-select-v2", props: { list: Array, // 列表数据 itemHeightGetter: Function, // 获取列表高度的函数 defaultItemHeight: { type: Number, default: 45, }, // 默认item高度 placeholder: { type: String, default: "请选择", }, popperClass: String, value: [String, Number], }, data() { return { listView: [], // 可视列表数据 listTotalHeight: 0, // 列表总高度 itemOffsetCache: [], // item信息缓存 topItemIndex: 0, // 可视窗口的第一项 isShow: true,//模糊搜索筛选时,会出现偏移出错问题,添加这个重新渲染解决问题 loading: false, allList: [], // selectValue:"" }; }, computed: { selectValue: { get() { return this.value; }, set(newValue) { return newValue; }, }, listViewWithInfo() { // 封装listView,提供index、height、offset数据 return this.listView.map((item, viewIndex) => { const index = this.topItemIndex + viewIndex; const { height, offset } = this.getItemInfo(index); return { index, item, height, offset, }; }); }, }, watch: { list() { this.allList = this.list; // console.log("传进来的数据列表",this.list) this.refreshView(); }, }, mounted() { // console.log("我进来了", this.list); this.refreshView({ resize: true }); }, methods: { handleChange(val) { this.$emit("input", val); this.$emit("change", val); }, visibleChange(status) { console.log(status); this.allList = this.list; this.refreshView({ clearCache: true }); this.$emit("visible-change", status); }, remoteMethod(query) { // console.log("我进来了", query); if (query.trim() !== "") { this.loading = true; this.isShow = false; setTimeout(() => { this.loading = false; this.isShow = true; // console.log("搜索输的值",this.list) // this.$emit('filter',query) var list = this.list.filter((item) => { return item.label && item.label.indexOf(query) > -1; }); // console.log("搜索输的值", list); this.allList = list; this.$nextTick(() => { this.refreshView({ clearCache: true }); }); }, 200); } else { this.allList = this.list; this.refreshView({ clearCache: true }); } }, // 重渲染可视列表(可供组件外部调用) refreshView(config) { // console.log("滚动了吗"); if (config) { if (config.resize) { // 只有resize为true时对wrapper高度重新取值,减少DOM取值操作 this._viewHeight = this.$refs.wrapper.clientHeight; } if (config.clearCache) { // 清空缓存 this.itemOffsetCache = []; } } // console.log("当前scrollTop",this.$refs.wrapper.scrollTop) const scrollTop = this.$refs.wrapper.scrollTop; // 当前scrollTop const viewHeight = this._viewHeight || 274; // 可视窗口高度 const topItemIndex = this.findItemIndexByOffset(scrollTop); // 可视窗口的第一项 const bottomItemIndex = this.findItemIndexByOffset(scrollTop + viewHeight); // 可视窗口的最后项 this.topItemIndex = topItemIndex; this.listView = this.allList.slice(topItemIndex, bottomItemIndex + 1); // 可视列表 // 列表总高度 // 若提供了默认item高度(defaultItemHeight),则高度 = 已计算item的高度总合 + 未计算item数 * 默认item高度;否则全部使用计算高度 // 这里已计算过的item会缓存,所有item只会计算一次 const listTotalHeight = this.defaultItemHeight ? this.getItemInfo(this.itemOffsetCache.length - 1).offset + (this.allList.length - this.itemOffsetCache.length) * this.defaultItemHeight : this.getItemInfo(this.allList.length - 1).offset; this.listTotalHeight = listTotalHeight; this.$refs["item-wrapper"].style.transform = `translateY(${ this.getItemInfo(topItemIndex - 1).offset }px)`; console.log(this.scrollTop, scrollTop, topItemIndex, bottomItemIndex); // 对外抛出scroll事件 this.$emit("scroll", { topItemIndex, bottomItemIndex, listTotalHeight, scrollTop, }); this.$forceUpdate(); }, // 根据offset获取item的在列表中的index findItemIndexByOffset(offset) { // 如果offset大于缓存数组的最后项,按序依次往后查找(调用getItemInfo的过程也会缓存数组) if (offset >= this.getItemInfo(this.itemOffsetCache.length - 1).offset) { for (let index = this.itemOffsetCache.length; index < this.allList.length; index++) { if (this.getItemInfo(index).offset > offset) { return index; } } return this.allList.length - 1; } else { // 如果offset小于缓存数组的最后项,那么在缓存数组中二分法查找 let begin = 0; let end = this.itemOffsetCache.length - 1; while (begin < end) { let mid = ((begin + end) / 2) | 0; let midOffset = this.getItemInfo(mid).offset; if (midOffset === offset) { return mid; } else if (midOffset > offset) { end = mid - 1; } else { begin = mid + 1; } } if ( this.getItemInfo(begin).offset < offset && this.getItemInfo(begin + 1).offset > offset ) { begin = begin + 1; } return begin; } }, // 获取item信息(有缓存则取缓存,无缓存则计算并缓存) getItemInfo(index) { // 超出取值范围,返回默认值 if (index < 0 || index > this.allList.length - 1) { return { offset: 0, height: 0, }; } let cache = this.itemOffsetCache[index]; // 如果没有缓存,进行计算并缓存结果 if (!cache) { // 优先用itemHeightGetter计算高度,无itemHeightGetter则取defaultItemHeight作为高度 let height = this.itemHeightGetter ? this.itemHeightGetter(this.allList[index], index) : this.defaultItemHeight; cache = this.itemOffsetCache[index] = { height, // item高度 offset: this.getItemInfo(index - 1).offset + height, // 递归得出item的bottom距离列表顶部的距离,item的offset = 上个item的offset + 自己的height }; } // 如果已有缓存,直接返回缓存的结果 return cache; }, },};</script><style lang="scss">.m-el-select-v2 { .el-select-dropdown__wrap { overflow: hidden; margin-bottom: 0px !important; margin-right: 0px !important; .el-select-dropdown__list { width: 100%; height: 274px; overflow: hidden; .m-virtual-wrapper { &::-webkit-scrollbar { width: 6px; } &::-webkit-scrollbar-thumb { background-color: #a1a3a9; border-radius: 3px; } &::-webkit-scrollbar-track { // background: #f5f7fa; background: transparent; } &::-webkit-scrollbar-corner { background: #f5f7fa; } } } }}</style>复制代码其中默认添加了 elementUI 组件提供的属性,其中的功能主要有: 清空、筛选(远程搜索的相应事件修改过来的)、插槽(可以修改选择列表中展示的内容)、滚动事件回调可以方便你增加页面去请求数据。功能都就可以根据自己的需要做出增加和修改。
这里我就举一个插槽的效果:
//App.vue//......其他的就省略不写了,因为都一样<select-v2 v-model="selectValue" @scroll="listScroll" :list="list" :itemHeightGetter="itemHeightGetter" :default-item-height="defaultItemHeight"> <template #default="{ item }"> <el-option :label="item.label" :value="item.value"> <span>{{ item.label }}({{ item.no }})</span> </el-option> </template></select-v2>//......其他的就省略不写了,因为都一样复制代码最终效果:
f5mhw-b92ki.gif
好了,最终的就这样完成了。 但是这个做的过程中我发现一个问题,我用来最不好的办法去解决的,我把问题告诉伙伴们,伙伴们可以自己感受一下,可以自己琢磨一下。
问题:当模糊搜索的时候,我们肯定是要从搜索到的结果的第一项去展示出来的,但是事实并不是这样的,可以看下面效果图:
f9wwp-7e7wb.gif
通过上面效果图可以得知设置的translateY 偏移量去展示的已经归零了,但是我们滚动上去的部分就回不来了。这是为什么呢?这个问题有小伙伴们去思考吧。
我的解决方案就是:通过isShow重新渲染。可以没有上面的问题了。
总结
一张图诠释虚拟列表所有的想法
20220228_pic_0.gif
关于本文
来源:前端周星星
https://juejin.cn/post/7069681651789332493
最后
欢迎关注【前端瓶子君】✿✿ヽ (°▽°) ノ✿
回复「算法」,加入前端编程源码算法群,每日一道面试题(工作日),第二天瓶子君都会很认真的解答哟!
回复「交流」,吹吹水、聊聊技术、吐吐槽!
回复「阅读」,每日刷刷高质量好文!
如果这篇文章对你有帮助,「在看」是最大的支持
》》面试官也在看的算法资料《《
“在看和转发” 就是最大的支持