Vue实现虚拟列表

技术栈:

  • react

前言

具体的原理可以看知乎上的一篇文章,原文是react,这里使用 vue3 实现了一遍,并添加一些功能

如果你看完了上面推荐的文章,虚拟列表的原理主要就是将需要展示的组件,通过计算,展示到视图中,减少了大量数据的渲染问题

本文添加了一个虚拟列表的动态加载,并且对原文进行了一些优化

项目结构

本文是部分结构,实现的是一个TODO的虚拟列表,暂未抽象成组件

home.vue:提供数据的组件

1
2
3
<template>
<Reminder/>
</template>

reminder.vue:主要组件

1
2
3
<template>
<Item/>
</template>

item.vue :列表item

代码

home.vue: 提供了初始化的数据加载和之后的加载更多数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
onMounted(() => {
Array.from({ length: 10 }, (_, i) => i + 1).forEach((i) => {
data.value.backlog.push({
title: `订单号${i} 商品:商品${i}(${i}) 未发货`,
status: false,
});
});
});
const loadMore = async () => {
console.log("load more");
setTimeout(() => {
Array.from({ length: 15 }, (_, i) => i + 1).forEach((i) => {
data.value.backlog.push({
title: `订单号${i} 商品:商品${i}(${i}) 未发货`,
status: false,
});
});
}, 1000);
};
1
2
3
4
5
6
7
8
9
10
<template>
<Reminder
:title="'待办事项'"
:titleIcon="'bx-note'"
:backlog="data.backlog"
:flexBasis="'300px'"
:tbodyMaxHeight="500"
@load-more="loadMore"
/>
</template>

reminder.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
<script setup lang="ts">
import { computed, CSSProperties, ref, useTemplateRef } from "vue";
import { watch } from "vue";
import Item from "./item.vue";
const props = defineProps<{
title: string;
titleIcon: string;
flexBasis?: string;
backlog: {
title: string;
status: boolean;
}[];
tbodyMaxHeight?: number;
total?: number;
viewCount?: number;
}>();
const emit = defineEmits<{
(e: "loadMore"): void;
}>();
const styleScroll = computed<CSSProperties | undefined>(() => {
return props.tbodyMaxHeight
? {
maxHeight: props.tbodyMaxHeight + "px",
}
: {};
});

// 虚拟链表
const defaultHeight = 60;
// 初始化每个元素的高度
type Position = {
height: number;
index: number;
top: number;
bottom: number;
};
const positions = ref<Position[]>([]);
watch(
() => props.backlog.length,
(newLength, oldLength) => {
oldLength = oldLength || 0;
if (newLength !== undefined && newLength > oldLength) {
const bottom =
oldLength === 0 ? 0 : positions.value[oldLength - 1].bottom;
positions.value = positions.value.concat(
new Array(newLength - oldLength).fill(0).map((_, i) => ({
height: defaultHeight,
index: oldLength + i,
top: bottom + defaultHeight * i,
bottom: bottom + defaultHeight * (i + 1),
}))
);
}
positions.value.forEach((item) => {
console.log(item.index, item.height, item.top, item.bottom);
});
},
{ immediate: true }
);
// 计算列表总高度
const height = ref(props.backlog.length * defaultHeight || 0);
watch(
positions,
() => {
if (positions.value.length > 0) {
height.value = positions.value.reduce(
(acc, cur) => acc + cur.height,
0
);
}
},
{ deep: true }
);
const updatePositions = (index: number, height: number) => {
positions.value[index].height = height;
positions.value[index].bottom = positions.value[index].top + height;
};

const viewport = useTemplateRef<HTMLElement>("viewport"); // 获取视口
const phantom = useTemplateRef<HTMLElement>("phantom"); // 获取幻影

const scrollTop = ref<number>(0); // 滚动距离
const onScroll = () => {
scrollTop.value = viewport.value ? viewport.value.scrollTop : 0; // 更新滚动距离
};
const viewportNumber = computed(() =>
props.tbodyMaxHeight
? Math.floor(props.tbodyMaxHeight / defaultHeight)
: 10
); // 视口高度
// 开始索引
const startIndex = computed(() => {
let item = positions.value.find((i) => {
return i && i.bottom >= scrollTop.value && i.top <= scrollTop.value;
});
if (!item) return 0;
return item.index;
});
// 结束索引
const endIndex = computed(() => startIndex.value + viewportNumber.value);
const data = computed(() => {
return props.backlog.slice(startIndex.value, endIndex.value);
});
const transformDiv = computed<CSSProperties>(() => {
const startOffset = positions.value[startIndex.value]?.top || 0;
return {
transform: `translate3d(0,${startOffset}px,0)`,
};
});
// 动态加载
const loadBuffer = 2;
watch(endIndex, () => {
if (endIndex.value + loadBuffer > positions.value.length - 1) {
emit("loadMore");
}
});
</script>

<template>
<div
class="reminders"
:class="{ reminderFlex: !!props.flexBasis }"
:style="{ flexBasis: props.flexBasis }"
>
<div class="header">
<i
class="bx"
:class="props.titleIcon"
></i>
<h3>{{ props.title }}</h3>
<i
class="bx bx-filter"
@click="emit('handleFilter')"
></i>
<i
class="bx bx-plus"
@click="emit('handlePlus')"
></i>
</div>
<div
class="task-list"
:style="styleScroll"
ref="viewport"
@scroll="onScroll"
>
<div
class="list-phantom"
:style="{ height: height + 'px' }"
ref="phantom"
></div>
<div :style="transformDiv">
<Item
v-for="(item, index) in data"
:item="item"
:index="index + startIndex"
@update-positions="
(i, height) => updatePositions(i, height)
"
/>
</div>
</div>
</div>
</template>

<style scoped lang="scss">
.reminders {
&.reminderFlex {
flex-grow: 1;
}
color: $dark;
border-radius: 20px;
background: $light;
padding: 24px;

.header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;

h3 {
margin-right: auto;
font-size: 24px;
font-weight: 600;
}

.bx {
cursor: pointer;
}
}

.task-list {
overflow-y: scroll;
position: relative;
width: 100%;

.list-phantom {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
}
}
@media screen and (max-width: 576px) {
.reminders .task-list {
min-width: 340px;
}
}
</style>

item.vue :列表item

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
<script lang="tsx" setup>
import { useTemplateRef, watch } from "vue";

const props = defineProps<{
item: {
title: string;
status: boolean;
};
index: number;
}>();

const listArea = useTemplateRef<HTMLElement | null>("listArea");
watch(
() => props.index,
() => {
emit(
"updatePositions",
props.index,
listArea.value?.clientHeight || 61
);
},
{ immediate: true }
);
</script>

<template>
<div
ref="listArea"
class="task-item"
:class="props.item.status ? 'completed' : 'not-completed'"
>
<div class="task-title">
<i
class="bx"
:class="props.item.status ? 'bx-check-circle' : 'bx-x-circle'"
></i>
<p>{{ props.item.title }}</p>
</div>
<i
class="bx bx-dots-vertical-rounded"
></i>
</div>
</template>

<style lang="scss" scoped>
.task-item {
width: 100%;
margin-bottom: 16px;
background: $grey;
padding: 14px 10px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: space-between;

.task-title {
display: flex;
align-items: center;
}

.task-title p {
margin-left: 6px;
}

.bx {
cursor: pointer;
}

&.completed {
border-left: 10px solid $success;
p {
text-decoration: line-through;
}
}

&.not-completed {
border-left: 10px solid $danger;
}

&:last-child {
margin-bottom: 0;
}
}
</style>

优化点

  • 原文的计算每个列表的高度是使用了一个数据结构

构造记录列表项位置信息 position 的数组 positions

  1. top: 当前项顶部到列表顶部的距离
  2. height: 当前项的高度
  3. bottom: 当前项底部到列表顶部的距离
  4. index: 当前项的标识

作者在计算的item的高度的时候会每次都遍历一遍positions数组,其实并不需要,直接计算当前项即可

  • 添加了动态加载

    设置了一个loadmore触发器,之后发送事件即可

卡住的点

  • 数据在加载之前都是 undifined需要判断,有时候会报错
  • index的判断需要注意,由于调用的是slice,所以index是从0开始,需要更具实际情景添加startIndex
  • 数据的滑动关键在于一个动画和一个计算值
    • transform: translate3d(0,${startOffset}px,0),这个动画直接将渲染的组件移动到视图窗口
    • startOffset 这个值应该是startIndextop到顶部的距离,而不是 scrollTop滚动距离,后者没有连续的滑动
  • 计算需要注意初始值和边界情况