网上的虚拟列表支持单行多列(横向滚动列表)和单列多行(纵向滚动列表),npm也有虚拟列表和虚拟网格。例如:vue-virtual-scroll-list、vue-virtual-scroll-grid。使用起来不太方便,而且不支持插槽。
这个是虚拟列表的实现,多行多列虚拟列表在这个基础上扩展,使用行和列来代表数据下标,通过计算开始位置和结束位置,动态截取可见区域元素。
<script setup lang="ts">
import { computed, ref, StyleValue } from 'vue'
const props = defineProps<{
type: 'horizontal' | 'vertical'
itemWidth: number
itemHeight: number
rowSpace: number
columnSpace: number
repeatNumber: number
values: any[]
}>()
type Region = { rowCount: number; columnCount: number }
type Position = { row: number; column: number }
const itemWidthWithSpace = computed(() => props.itemWidth + props.columnSpace)
const itemHeightWithSpace = computed(() => props.itemHeight + props.rowSpace)
/**
* css能设置的最大宽高,这里取一半
* https://stackoverflow.com/questions/16637530/whats-the-maximum-pixel-value-of-css-width-and-height-properties
* Firefox: 33554400px
* Chrome: 33554428px
* Opera: 33554428px
* IE 9: 21474836.47px
*/
const CSSMaxWidth = computed(() => Math.floor(16777200 / itemWidthWithSpace.value) * itemWidthWithSpace.value)
const CSSMaxHeight = computed(() => Math.floor(16777200 / itemHeightWithSpace.value) * itemHeightWithSpace.value)
const offsetRow = computed(() => Math.floor(167772 / itemHeightWithSpace.value))
const offseColumn = computed(() => Math.floor(167772 / itemWidthWithSpace.value))
const wrapper = ref<HTMLElement>()
const width = computed(() => wrapper.value?.offsetWidth ?? 0)
const height = computed(() => wrapper.value?.offsetHeight ?? 0)
const itemWidthPx = computed(() => `${props.itemWidth}px`)
const itemHeightPx = computed(() => `${props.itemHeight}px`)
const columnSpacePx = computed(() => `${props.columnSpace}px`)
const rowSpacePx = computed(() => `${props.rowSpace}px`)
const contentMarginLeftPx = computed(() => `${-props.columnSpace}px`)
const contentMarginTopPx = computed(() => `${-props.rowSpace}px`)
const contentRegion = computed<Region>(() => {
const region = { rowCount: 1, columnCount: 1 }
if (props.type == 'horizontal') {
if (props.repeatNumber > 0) {
region.rowCount = Math.ceil(props.values.length / props.repeatNumber)
region.columnCount = Math.min(props.values.length, props.repeatNumber)
} else if (props.values.length > 0) {
region.columnCount = props.values.length
}
} else if (props.type == 'vertical') {
if (props.repeatNumber > 0) {
region.columnCount = Math.ceil(props.values.length / props.repeatNumber)
region.rowCount = Math.min(props.values.length, props.repeatNumber)
} else if (props.values.length > 0) {
region.rowCount = props.values.length
}
}
return region
})
// 可见区域能显示多少行,多少列
const showRegion = computed<Region>(() => {
return {
rowCount: Math.min(Math.floor((height.value + props.rowSpace) / itemHeightWithSpace.value) + 1, contentRegion.value.rowCount),
columnCount: Math.min(Math.floor((width.value + props.columnSpace) / itemWidthWithSpace.value) + 1, contentRegion.value.columnCount),
}
})
// 偏移位置,用于处理到达css最大长度的情况
const offsetRegion = ref<Region>({ rowCount: 0, columnCount: 0 })
// 渲染元素的第一个位置
const startPosition = ref<Position>({ row: 0, column: 0 })
const endPosition = computed<Position>(() => {
const position = { row: 0, column: 0 }
// 3倍是需要预留缓冲区域
position.row = Math.min(startPosition.value.row + showRegion.value.rowCount * 3, contentRegion.value.rowCount - 1)
position.column = Math.min(startPosition.value.column + showRegion.value.columnCount * 3, contentRegion.value.columnCount - 1)
if (props.type == 'horizontal') {
const index = position.row * contentRegion.value.columnCount + position.column
if (index >= props.values.length) {
position.row = contentRegion.value.rowCount - 1
position.column = contentRegion.value.columnCount - 1
}
if (props.repeatNumber <= 0) {
position.row = 0
}
} else if (props.type == 'vertical') {
const index = position.column * contentRegion.value.rowCount + position.row
if (index >= props.values.length) {
position.column = contentRegion.value.columnCount - 1
position.row = contentRegion.value.rowCount - 1
}
if (props.repeatNumber <= 0) {
position.column = 0
}
}
return position
})
const showList = computed(() => {
const result: any[] = []
if (props.type == 'horizontal') {
for (let row = startPosition.value.row; row <= endPosition.value.row; row++) {
const startIndex = row * contentRegion.value.columnCount + startPosition.value.column
const endIndex = row * contentRegion.value.columnCount + endPosition.value.column
result.push(...props.values.slice(startIndex, endIndex + 1))
}
} else if (props.type == 'vertical') {
for (let column = startPosition.value.column; column <= endPosition.value.column; column++) {
const startIndex = column * contentRegion.value.rowCount + startPosition.value.row
const endIndex = column * contentRegion.value.rowCount + endPosition.value.row
result.push(...props.values.slice(startIndex, endIndex + 1))
}
}
return result
})
const contentStyle = computed(() => {
let left = itemWidthWithSpace.value * (startPosition.value.column - offsetRegion.value.columnCount)
let top = itemHeightWithSpace.value * (startPosition.value.row - offsetRegion.value.rowCount)
let right = itemWidthWithSpace.value * (contentRegion.value.columnCount - 1 - (endPosition.value.column - offsetRegion.value.columnCount))
let bottom = itemHeightWithSpace.value * (contentRegion.value.rowCount - 1 - (endPosition.value.row - offsetRegion.value.rowCount))
if (left + right > CSSMaxWidth.value) {
if (left > CSSMaxWidth.value) {
left = CSSMaxWidth.value
}
right = CSSMaxWidth.value - left
}
if (top + bottom > CSSMaxHeight.value) {
if (top > CSSMaxHeight.value) {
top = CSSMaxHeight.value
}
bottom = CSSMaxHeight.value - top
}
const width = itemWidthWithSpace.value * (endPosition.value.column - startPosition.value.column + 1)
const height = itemHeightWithSpace.value * (endPosition.value.row - startPosition.value.row + 1)
return {
padding: `${top}px ${right}px ${bottom}px ${left}px`,
width: `${width}px`,
height: `${height}px`,
flexDirection: props.type == 'horizontal' ? 'row' : 'column',
} as StyleValue
})
const handleScroll = (e: UIEvent) => {
const target: HTMLElement = e.target as HTMLElement
// 可见区域第一个元素的index
const row = Math.floor((target.scrollTop + props.rowSpace) / itemHeightWithSpace.value) + offsetRegion.value.rowCount
const column = Math.floor((target.scrollLeft + props.columnSpace) / itemWidthWithSpace.value) + offsetRegion.value.columnCount
startPosition.value.row = row < showRegion.value.rowCount ? 0 : row - showRegion.value.rowCount
startPosition.value.column = column < showRegion.value.columnCount ? 0 : column - showRegion.value.columnCount
if (props.repeatNumber <= 0) {
if (props.type == 'horizontal') {
startPosition.value.row = 0
} else if (props.type == 'vertical') {
startPosition.value.column = 0
}
}
//TODO: 分页加载时,由于滚动过快,target.scrollTop并不会立即生效,导致数据加载会出现偏移
// 到达最低部或者最右部,分页加载数据
if (target.scrollTop + target.offsetHeight >= target.scrollHeight && endPosition.value.row < contentRegion.value.rowCount - 1) {
const offset = contentRegion.value.rowCount - 1 - endPosition.value.row > offsetRow.value ? offsetRow.value : contentRegion.value.rowCount - 1 - endPosition.value.row
offsetRegion.value.rowCount += offset
target.scrollTop -= itemHeightWithSpace.value * offset
} else if (offsetRegion.value.rowCount > 0 && startPosition.value.row < offsetRegion.value.rowCount) {
const offset = offsetRegion.value.rowCount > offsetRow.value ? offsetRow.value : offsetRegion.value.rowCount
offsetRegion.value.rowCount -= offset
target.scrollTop += itemHeightWithSpace.value * offset
} else if (target.scrollLeft + target.offsetWidth >= target.scrollWidth && endPosition.value.column < contentRegion.value.columnCount - 1) {
const offset = contentRegion.value.columnCount - 1 - endPosition.value.column > offseColumn.value ? offseColumn.value : contentRegion.value.columnCount - 1 - endPosition.value.column
offsetRegion.value.columnCount += offset
target.scrollLeft -= itemWidthWithSpace.value * offset
} else if (offsetRegion.value.columnCount > 0 && startPosition.value.column < offsetRegion.value.columnCount) {
const offset = offsetRegion.value.columnCount > offseColumn.value ? offseColumn.value : offsetRegion.value.columnCount
offsetRegion.value.columnCount -= offset
target.scrollLeft += itemWidthWithSpace.value * offset
}
}
</script>
<template>
<div ref="wrapper" :class="$style.wrapper" @scroll.passive="handleScroll">
<div :class="$style.content" :style="contentStyle">
<div v-for="(item, index) in showList" :key="index" :class="$style.item">
<slot :item="item" :index="index"></slot>
</div>
</div>
</div>
</template>
<style module lang="less">
.wrapper {
overflow: auto;
.content {
display: flex;
flex-wrap: wrap;
align-content: flex-start;
margin-left: v-bind(contentMarginLeftPx);
margin-top: v-bind(contentMarginTopPx);
.item {
width: v-bind(itemWidthPx);
height: v-bind(itemHeightPx);
margin-left: v-bind(columnSpacePx);
margin-top: v-bind(rowSpacePx);
position: relative;
}
}
}
</style>
由于CSS可设置的最大长度的限制,在谷歌浏览器最大能设置33554428px。普通的虚拟列表最多只能支持10几万条数据。为了支持上千万条数据,需要固定padding的最大值,我这里只取最大长度的一半,剩下的留给宽度、高度。之后引入偏移位置,当滚动到列表边界时,计算需要偏移的位置和scrollTop、scrollLeft,让滚动条可以重新滚动。即使加载上千万数据,只要浏览器的内存足够,是可以正常滚动的。
npm install @xuemiyang/vue-virtual-list
<script setup lang="ts">
import { ref } from "vue";
import { VirtualList } from "@xuemiyang/vue-virtual-list";
import "@xuemiyang/vue-virtual-list/dist/style.css";
const allList = ref<any[]>([]);
for (let i = 0; i < 10000000; i++) {
allList.value.push({ title: `title ${i}`, id: i });
}
</script>
<template>
<div>
<VirtualList
class="list"
type="horizontal"
:item-width="200"
:item-height="100"
:row-space="10"
:column-space="10"
:repeat-number="0"
:values="allList"
>
<template #default="{ item }">
<h2 class="item">{{ item.title }}</h2>
</template>
</VirtualList>
</div>
</template>
<style scoped>
.list {
width: 500px;
height: 300px;
}
.list .item {
background-color: bisque;
}
</style>
效果图
版权声明:本文为u012182853原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。