205 lines
4.9 KiB
Vue
205 lines
4.9 KiB
Vue
<script lang="ts" setup>
|
|
import { reactive, ref, computed, unref, onUpdated, watchEffect } from 'vue';
|
|
|
|
const props = defineProps({
|
|
/**列表高度 */
|
|
height: {
|
|
type: Number,
|
|
default: 300,
|
|
},
|
|
/**列表项高度 */
|
|
itemHeight: {
|
|
type: Number,
|
|
default: 30,
|
|
},
|
|
/**数据 */
|
|
data: {
|
|
type: Array,
|
|
default: () => [],
|
|
},
|
|
/**预先兜底缓存数量 */
|
|
cache: {
|
|
type: Number,
|
|
default: 2,
|
|
},
|
|
/**是否动态加载 */
|
|
dynamic: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
});
|
|
|
|
const state = reactive<any>({
|
|
start: 0,
|
|
end: 10,
|
|
scrollOffset: 0,
|
|
cacheData: [],
|
|
});
|
|
const virtualListRef = ref();
|
|
|
|
const getWrapperStyle = computed(() => {
|
|
const { height } = props;
|
|
return {
|
|
height: `${height}px`,
|
|
};
|
|
});
|
|
|
|
const getInnerStyle = computed(() => {
|
|
return {
|
|
height: `${unref(getTotalHeight)}px`,
|
|
width: '100%',
|
|
};
|
|
});
|
|
|
|
const getListStyle = computed(() => {
|
|
return {
|
|
willChange: 'transform',
|
|
transform: `translateY(${state.scrollOffset}px)`,
|
|
};
|
|
});
|
|
|
|
// 数据数量
|
|
const total = computed(() => {
|
|
return props.data.length;
|
|
});
|
|
|
|
// 总体高度
|
|
const getTotalHeight = computed(() => {
|
|
if (!props.dynamic) return unref(total) * props.itemHeight;
|
|
return getCurrentTop(unref(total));
|
|
});
|
|
|
|
// 当前屏幕显示的数量
|
|
const clientCount = computed(() => {
|
|
return Math.ceil(props.height / props.itemHeight);
|
|
});
|
|
|
|
// 当前屏幕显示的数据
|
|
const clientData = computed(() => {
|
|
return props.data.slice(state.start, state.end);
|
|
});
|
|
|
|
const onScroll = (e: any) => {
|
|
const { scrollTop } = e.target;
|
|
if (state.scrollOffset === scrollTop) return;
|
|
const { cache, dynamic, itemHeight } = props;
|
|
const cacheCount = Math.max(1, cache);
|
|
|
|
let startIndex = dynamic
|
|
? getStartIndex(scrollTop)
|
|
: Math.floor(scrollTop / itemHeight);
|
|
|
|
const endIndex = Math.max(
|
|
0,
|
|
Math.min(unref(total), startIndex + unref(clientCount) + cacheCount)
|
|
);
|
|
|
|
if (startIndex > cacheCount) {
|
|
startIndex = startIndex - cacheCount;
|
|
}
|
|
|
|
// 偏移量
|
|
const offset = dynamic
|
|
? getCurrentTop(startIndex)
|
|
: scrollTop - (scrollTop % itemHeight);
|
|
|
|
Object.assign(state, {
|
|
start: startIndex,
|
|
end: endIndex,
|
|
scrollOffset: offset,
|
|
});
|
|
};
|
|
|
|
// 二分法去查找对应的index
|
|
const getStartIndex = (scrollTop = 0): number => {
|
|
let low = 0;
|
|
let high = state.cacheData.length - 1;
|
|
while (low <= high) {
|
|
const middle = low + Math.floor((high - low) / 2);
|
|
const middleTopValue = getCurrentTop(middle);
|
|
const middleBottomValue = getCurrentTop(middle + 1);
|
|
|
|
if (middleTopValue <= scrollTop && scrollTop <= middleBottomValue) {
|
|
return middle;
|
|
} else if (middleBottomValue < scrollTop) {
|
|
low = middle + 1;
|
|
} else if (middleBottomValue > scrollTop) {
|
|
high = middle - 1;
|
|
}
|
|
}
|
|
return Math.min(
|
|
unref(total) - unref(clientCount),
|
|
Math.floor(scrollTop / props.itemHeight)
|
|
);
|
|
};
|
|
|
|
const getCurrentTop = (index: number) => {
|
|
const lastIndex = state.cacheData.length - 1;
|
|
|
|
if (Object.hasOwn(state.cacheData, index)) {
|
|
return state.cacheData[index].top;
|
|
} else if (Object.hasOwn(state.cacheData, index - 1)) {
|
|
return state.cacheData[index - 1].bottom;
|
|
} else if (index > lastIndex) {
|
|
return (
|
|
state.cacheData[lastIndex].bottom +
|
|
Math.max(0, index - state.cacheData[lastIndex].index) * props.itemHeight
|
|
);
|
|
} else {
|
|
return index * props.itemHeight;
|
|
}
|
|
};
|
|
|
|
onUpdated(() => {
|
|
if (!props.dynamic) return;
|
|
const childrenList = virtualListRef.value.children || [];
|
|
[...childrenList].forEach((node: any, index: number) => {
|
|
const height = node.getBoundingClientRect().height;
|
|
const currentIndex = state.start + index;
|
|
if (state.cacheData[currentIndex].height === height) return;
|
|
|
|
state.cacheData[currentIndex].height = height;
|
|
state.cacheData[currentIndex].top = getCurrentTop(currentIndex);
|
|
state.cacheData[currentIndex].bottom =
|
|
state.cacheData[currentIndex].top + state.cacheData[currentIndex].height;
|
|
});
|
|
});
|
|
|
|
watchEffect(() => {
|
|
clientData.value.forEach((_, index) => {
|
|
const currentIndex = state.start + index;
|
|
if (Object.hasOwn(state.cacheData, currentIndex)) return;
|
|
state.cacheData[currentIndex] = {
|
|
top: currentIndex * props.itemHeight,
|
|
height: props.itemHeight,
|
|
bottom: (currentIndex + 1) * props.itemHeight,
|
|
index: currentIndex,
|
|
};
|
|
});
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
class="virtual-list-wrapper"
|
|
ref="wrapperRef"
|
|
:style="getWrapperStyle"
|
|
@scroll="onScroll"
|
|
>
|
|
<div class="virtual-list-inner" ref="innerRef" :style="getInnerStyle">
|
|
<div class="virtual-list" :style="getListStyle" ref="virtualListRef">
|
|
<div v-for="(item, index) in clientData" :key="index + state.start">
|
|
<slot name="default" :item="item"></slot>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="css" scoped>
|
|
.virtual-list-wrapper {
|
|
position: relative;
|
|
overflow-y: auto;
|
|
}
|
|
</style>
|