虚拟列表:你有勇气给我 10 万,我就有本事展示给你看
↓推荐关注↓
今年随着Vue3的成为正式版本,我们的Element-plus也有了稳定版,那今天我们主要是讲一个功能。我们先来看一下Element-plus新出现的一个玩意:
虚拟列表选择器?这是啥玩意,还能虚拟?
大家都知道Vue的虚拟dom,我用简单的话讲述一下:大概就是一棵以 JavaScript 对象作为基础的树,每一个节点称为 VNode ,用对象属性来描述节点,实际上它是一层对真实 DOM 的抽象,最终可以通过渲染操作使这棵树映射到真实环境上。啥事抽象,我也不懂,就跟你问我啥是面向对象一样。
让我们来看一张皇帝选妃图:
这么多的妃子,皇帝肯定一下子不可能全看完,总的一排一排来。再次大殿上装下三千嫔妃,是不是很拥堵。
这就好比我们的页面,后端接口一下给了你10万条数据,如果你一次性渲染到DOM上,会出现很严重的卡顿问题,简称为页面阻塞,这里就暂不讲解url从输入到浏览器渲染的问题,留着下次跟大家分享。
那我们应该怎么解决这种既有10万数据,又可以渲染时不卡顿的现象?
这时候虚拟列表跑出来了,它说:哎,我可以,快用我,快用我,快用我,重要的事情说三遍。
那行呗,那咱就用他,本次呢,我用Vue2
和element Ui
的提供的select
组件来实现 Vue2版
的select虚拟列表选择器
思路
利用滚动事件,去计算可视窗口内的第一项和最后一项,利用总数据数组分割的方法slice
截取到可视列表数据,计算每条数据的height
(高度)和offset
(距离顶部的位置)缓存到一个数组上来保存信息,利用每条高度求出总数据的应该有的高度listTotalHeight
,最后利用相对定位和绝对定位的结合,使用transform
控制translateY
使可视列表位置保持在可视窗口,如下图:
实现
首先我们来实现一个纯页面输出的虚拟列表,根据上面的思路实现一下,基本组件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>
最后如果你的效果是这样的,那么说明你就成功了一大步了
图片过大,截图处理
代码中是有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>
...
页面就变成了这样:
我们可以将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
即可,页面就回归正常了,有兴趣了伙伴可以尝试一下:
现在我们已经完成了我们最基本的虚拟列表组件,但是我们最终的目标是:虚拟列表选择器。
虚拟列表选择器
那来呗,我们先安装和引入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
:filter-method="filterMethod"
:remote-method="remoteMethod"
:loading="loading"
v-model="selectValue"
:popper-class="`m-el-select-v2 ${popperClass ? popperClass : ''}`"
@visible-change="visibleChange"
>
<div
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, // 可视窗口的第一项
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);
},
filterMethod(query) {
console.log(query);
this.remoteMethod(query)
},
remoteMethod(query) {
// console.log("我进来了", query);
if (query.trim() !== "") {
this.loading = true;
setTimeout(() => {
this.loading = false;
// 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);
if(list.length){
this.allList = list;
this.$nextTick(() => {
this.$refs["item-wrapper"].style.transform = "translateY(0px)";
this.$refs.wrapper.scrollTop = 0;
this.refreshView({ clearCache: true, isTranslateY: true });
});
}else{
console.log("没有找到,支持远程搜索,可以自定义事件")
this.$emit("remote-method",query)
}
}, 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 ;
margin-right: 0px ;
.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>
//......其他的就省略不写了,因为都一样
最终效果:
图片过大,截图处理
好了,最终的就这样完成了。但是这个做的过程中我发现一个问题,伙伴们可以自己感受一下,可以自己琢磨一下。
问题:当模糊搜索的时候,我们肯定是要从搜索到的结果的第一项去展示出来的,但是事实并不是这样的
设置的translate
Y偏移量去展示的已经归零了,但是我们滚动上去的部分就回不来了。这是为什么呢?这个问题有小伙伴们去思考吧。
我的解决方案就是:
//在remoteMethod方法中添加下面两句
//......
this.$refs["item-wrapper"].style.transform = "translateY(0px)";
this.$refs.wrapper.scrollTop = 0;
//......
作者:前端周星星
https://juejin.cn/post/7069681651789332493
- EOF -
觉得本文对你有帮助?请分享给更多人
推荐关注「前端大全」,提升前端技能
点赞和在看就是最大的支持❤️