|
| 1 | +<template> |
| 2 | + <div class="nt-virtual-list" ref="$list" @scroll.passive="handleScroll"> |
| 3 | + <div :style="{ height: `${totalSize}px` }"> |
| 4 | + <div :style="{ transform: `translate3d(0px, ${startOffset}px, 0px)` }"> |
| 5 | + <slot name="render" :data="visibleData"> |
| 6 | + <!-- 列表项 --> |
| 7 | + <div |
| 8 | + v-for="(item, index) in visibleData" |
| 9 | + :style="{ height: props.itemSize + 'px' }" |
| 10 | + :key="keyField != null ? (item as any)[keyField] : index" |
| 11 | + :class="['nt-virtual-item', itemClass]" |
| 12 | + > |
| 13 | + <slot :item="item"></slot> |
| 14 | + </div> |
| 15 | + </slot> |
| 16 | + </div> |
| 17 | + </div> |
| 18 | + </div> |
| 19 | +</template> |
| 20 | +<script setup lang="ts" generic="T"> |
| 21 | +import { onMounted, ref } from 'vue'; |
| 22 | +
|
| 23 | +const props = withDefaults( |
| 24 | + defineProps<{ |
| 25 | + /** 需要展示的数据 */ |
| 26 | + items: T[]; |
| 27 | + /** 列表项的高度,用于计算滚动大小和位置 */ |
| 28 | + itemSize: number; |
| 29 | + itemClass?: string; |
| 30 | + /** 选项 key 的字段名, 用于 v-for 的 key */ |
| 31 | + keyField?: string; |
| 32 | + /** 缓冲区数量,列表会在上下多渲染额外的项 */ |
| 33 | + buffer?: number; |
| 34 | + }>(), |
| 35 | + { |
| 36 | + itemClass: '', |
| 37 | + buffer: 2, |
| 38 | + }, |
| 39 | +); |
| 40 | +
|
| 41 | +const $list = ref<HTMLDivElement>(); |
| 42 | +
|
| 43 | +/** 实际显示的数据列表 */ |
| 44 | +const visibleData = ref<T[]>([]); |
| 45 | +/** 容器总高度 */ |
| 46 | +const totalSize = ref(0); |
| 47 | +/** 滑动便宜 */ |
| 48 | +const startOffset = ref(0); |
| 49 | +
|
| 50 | +/** 可视区域能够展示的最大元素个数 */ |
| 51 | +let numVisible = 0; |
| 52 | +
|
| 53 | +/** 滑动延迟处理,滑动完成后,延迟处理更新数据,避免频繁触发数据更新 */ |
| 54 | +let _t = -1; |
| 55 | +
|
| 56 | +function updateVisibleItems() { |
| 57 | + if ($list.value != null) { |
| 58 | + // 当前滚动位置 |
| 59 | + const scrollTop = $list.value.scrollTop; |
| 60 | + /** 可视区域开始索引 */ |
| 61 | + let startIndex = Math.floor(scrollTop / props.itemSize); |
| 62 | + /** 上缓冲区起始索引 */ |
| 63 | + let topStartIndex = Math.max(0, startIndex - props.buffer); |
| 64 | + /** 下缓冲区结束索引 */ |
| 65 | + const endIndex = Math.min( |
| 66 | + props.items.length, |
| 67 | + startIndex + numVisible + props.buffer, |
| 68 | + ); |
| 69 | + // 偏移量, 当滑动位置是某一项的一部分的时候,计算已经滚动的那一部分距离 |
| 70 | + let offset = scrollTop - (scrollTop % props.itemSize); |
| 71 | + // 生成可视区域数据 |
| 72 | + visibleData.value = props.items.slice(topStartIndex, endIndex) as any[]; |
| 73 | + /** 当前显示index-缓冲区的index就是缓冲区数量 */ |
| 74 | + startOffset.value = offset - (startIndex - topStartIndex) * props.itemSize; |
| 75 | + } |
| 76 | +} |
| 77 | +
|
| 78 | +function handleScroll() { |
| 79 | + cancelAnimationFrame(_t); |
| 80 | + _t = requestAnimationFrame(() => { |
| 81 | + updateVisibleItems(); // 重新渲染数据 |
| 82 | + }); |
| 83 | +} |
| 84 | +
|
| 85 | +onMounted(() => { |
| 86 | + totalSize.value = props.itemSize * props.items.length; |
| 87 | + if ($list.value != null) { |
| 88 | + let rect = $list.value.getBoundingClientRect(); |
| 89 | + numVisible = Math.ceil(rect.height / props.itemSize); |
| 90 | + updateVisibleItems(); // 初始化渲染数据 |
| 91 | + } |
| 92 | +}); |
| 93 | +</script> |
0 commit comments