Virtual List 虚拟列表
简易虚拟列表泛型组件。
组件源码
vue
<script lang="ts" generic="T" setup>
import { throttle } from '@utils/common';
import { useVirtualList } from './hooks/use-virtual-list';
import { computed, onMounted, useTemplateRef, watch } from 'vue';
import { useResizeObserver } from './hooks/use-resize-observer';
const {
dataList,
itemHeight,
overscan = 5,
hiddenScrollbar = false,
scrollThrottleTime = 16
} = defineProps<{
dataList: T[];
getItemKey: (item: T) => string | number | void;
containerHeight: string;
itemHeight: number;
overscan?: number;
containerStyle?: string;
itemStyle?: string;
hiddenScrollbar?: boolean;
scrollThrottleTime?: number;
}>();
const $emits = defineEmits<{
contextmenu: [e: MouseEvent];
rowClick: [data: T, index: number];
scroll: [e: Event];
change: [data: T[], beginIndex: number, endIndex: number];
}>();
const virtualListRef = useTemplateRef('virtual-list');
const handleScroll = (options?: ScrollToOptions) => {
virtualListRef.value?.scroll(options);
};
const handleScrollTo = (index: number) => {
if (!virtualListRef.value) {
return;
}
const height = virtualListRef.value.clientHeight;
const scrollHeight = virtualListRef.value.scrollHeight;
if (height < scrollHeight) {
let top = itemHeight * (index - 1);
if (top < 0) {
top = 0;
}
const scrollTop = virtualListRef.value.scrollTop;
if (top <= scrollTop || top >= scrollTop + height) {
virtualListRef.value.scrollTop = top;
}
}
};
const scrollHeight = computed(() => {
return dataList.length * itemHeight;
});
const { renderRange, topHeight, setRenderRange, pagesize } = useVirtualList(
virtualListRef,
{
itemHeight,
overscan
}
);
const visibleList = computed(() => {
return dataList.slice(
renderRange.value.beginIndex,
renderRange.value.endIndex + 1
);
});
watch(
renderRange,
(range) => {
$emits(
'change',
dataList.slice(range.beginIndex, range.endIndex + 1),
range.beginIndex,
range.endIndex
);
},
{ deep: true, immediate: true }
);
watch(
() => dataList,
(data) => {
if (dataList.length >= pagesize.value) {
setRenderRange(data.length);
return;
}
virtualListRef.value?.scrollTo({ top: 0, behavior: 'instant' });
},
{ immediate: true, deep: true }
);
const handleContextmenu = (event: MouseEvent) => {
event.preventDefault();
$emits('contextmenu', event);
};
const handleRowClick = (data: T, index: number) => {
$emits('rowClick', data, index);
};
const handleVirtualListScroll = throttle<Event>(
(e) => {
setRenderRange();
$emits('scroll', e);
},
scrollThrottleTime,
{ leading: true, trailing: true }
);
onMounted(() => {
if (virtualListRef.value) {
pagesize.value = Math.floor(virtualListRef.value.clientHeight / itemHeight);
}
setRenderRange();
});
useResizeObserver(virtualListRef, () => {
if (!virtualListRef.value) {
return;
}
const size = Math.floor(virtualListRef.value.clientHeight / itemHeight);
if (size === pagesize.value) {
return;
}
pagesize.value = size;
setRenderRange();
});
defineExpose({
handleScroll,
handleScrollTo
});
</script>
<template>
<div
ref="virtual-list"
class="virtual-list"
:class="{ 'virtual-list--hidden-scrollbar': hiddenScrollbar }"
:style="`height: ${containerHeight};` + containerStyle"
@contextmenu="handleContextmenu"
@scroll="handleVirtualListScroll">
<div :style="`height: ${scrollHeight}px; min-height: ${containerHeight};`">
<div v-if="visibleList.length" :style="`height: ${topHeight}px;`"></div>
<template
v-for="(item, itemIndex) in visibleList"
:key="getItemKey(item) ?? itemIndex">
<div
:style="`height: ${itemHeight}px; ${itemStyle}`"
class="virtual-list__item"
@click="handleRowClick(item, itemIndex + renderRange.beginIndex)">
<slot :item="item" :itemIndex="itemIndex + renderRange.beginIndex" />
</div>
</template>
<template v-if="!visibleList.length">
<slot name="empty" />
</template>
</div>
</div>
</template>
<style lang="scss" scoped>
.virtual-list {
overflow-y: auto;
width: 100%;
.virtual-list__item {
position: relative;
box-sizing: border-box;
}
}
.virtual-list--hidden-scrollbar {
scrollbar-width: none; /* 适用于 Firefox */
&::-webkit-scrollbar {
display: none;
}
}
</style>hooks 源码
ts
import { computed, ref, type ShallowRef } from 'vue';
export const useVirtualList = (
element: ShallowRef<HTMLDivElement | null>,
{
itemHeight,
overscan = 5
}: {
itemHeight: number;
overscan?: number;
}
) => {
const pagesize = ref(0);
const renderRange = ref({
beginIndex: 0,
endIndex: 0
});
/**
* 内容盒(渲染列表)上面的空容器高度
*/
const topHeight = computed(() => {
return renderRange.value.beginIndex * itemHeight;
});
const setRenderRange = (itemCount?: number) => {
if (!element.value) {
return;
}
const height = element.value.clientHeight;
pagesize.value = Math.floor(height / itemHeight);
const scrollTop = element.value.scrollTop;
if (scrollTop === 0) {
renderRange.value = {
beginIndex: 0,
endIndex: pagesize.value + overscan
};
return;
}
// 处理超出最大滚动高度的情况
const maxScrollTop = (itemCount ?? 0) * itemHeight - height;
if (itemCount && scrollTop >= maxScrollTop) {
element.value?.scrollTo({ top: maxScrollTop, behavior: 'instant' });
return;
}
const currentLine = Math.floor(scrollTop / itemHeight);
renderRange.value.beginIndex = currentLine - overscan;
if (renderRange.value.beginIndex < 0) {
renderRange.value.beginIndex = 0;
}
renderRange.value.endIndex = currentLine + pagesize.value + overscan;
};
return {
pagesize,
renderRange,
topHeight,
setRenderRange
};
};基本用法
传入每项高度、容器总高度、每一项的 key 以及数据列表即可。
1
示例代码
vue
<script lang="ts" setup>
import VirtualList from '@/VirtualList/VirtualList.vue';
import { ref } from 'vue';
const list = ref<number[]>([]);
for (let i = 0; i < 50; i++) {
list.value.push(i);
}
</script>
<template>
<div class="container">
<virtual-list
:item-height="30"
container-height="210px"
:data-list="list"
:get-item-key="(v) => v"
container-style="background-color: #f0f0f0;"
item-style="color: #333; border-bottom: 1px solid #999; text-align: center;">
<template #default="{ item }">
{{ item + 1 }}
</template>
</virtual-list>
</div>
</template>
<style lang="scss" scoped>
.container {
display: flex;
gap: 40px;
height: 210px;
box-sizing: border-box;
border: 1px dotted #000;
}
</style>API
Props
| 参数名 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| dataList | 渲染列表数据 | T[] | - |
| itemHeight | 每项高度 | number | - |
| containerHeight | 容器高度 | string | - |
| getItemKey | 每一项的 key | (item: T) => string | number | void | - |
| overscan | 预渲染的额外项目数量,提升滚动流畅度 | number | 5 |
| containerStyle | 容器样式 | string | - |
| itemStyle | 每项样式 | string | - |
| hiddenScrollbar | 是否隐藏滚动条 | boolean | false |
| scrollThrottleTime | 滚动节流时间 | number | 16 |
Events
| 事件名 | 说明 | 参数 |
|---|---|---|
| contextmenu | 鼠标右键事件 | e: MouseEvent |
| rowClick | 行点击事件 | data: T, index: number |
| scroll | 滚动事件 | e: Event |
| change | 可视区域数据变化时触发 | visibleData: T[], beginIndex: number, endIndex: number |
Slots
| 插槽名 | 说明 | 参数 |
|---|---|---|
| default | 行内容 | { item: T, itemIndex: number } |
| empty | 空状态内容 | _ |
Methods
| 方法名 | 说明 | 参数 |
|---|---|---|
| handleScroll | 滚动到指定位置 | options?: ScrollToOptions |
| handleScrollTo | 滚动到指定行 | index: number |