Vue实现虚拟列表
技术栈:
前言
具体的原理可以看知乎上的一篇文章,原文是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:
- top: 当前项顶部到列表顶部的距离
- height: 当前项的高度
- bottom: 当前项底部到列表顶部的距离
- index: 当前项的标识
作者在计算的item的高度的时候会每次都遍历一遍positions数组,其实并不需要,直接计算当前项即可
卡住的点
- 数据在加载之前都是
undifined
需要判断,有时候会报错
index
的判断需要注意,由于调用的是slice
,所以index
是从0开始,需要更具实际情景添加startIndex
- 数据的滑动关键在于一个动画和一个计算值
- transform:
translate3d(0,${startOffset}px,0)
,这个动画直接将渲染的组件移动到视图窗口
startOffset
这个值应该是startIndex
的top
到顶部的距离,而不是 scrollTop
滚动距离,后者没有连续的滑动
- 计算需要注意初始值和边界情况