Files
fe.ems.vue3/src/components/VirtualList/index.vue
2024-09-03 11:00:00 +08:00

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>