import { ref, computed, watch, nextTick } from 'vue' export default function useVirtualScroller(props, emit, scrollerRef) { const heights = ref(new Map()) const positions = ref([]) const scrollTop = ref(0) const clientHeight = ref(0) const isLoading = ref(false) let lastScrollTime = 0 let animationFrameId = null const savedScrollPosition = ref(null) const getHeight = (index) => { return heights.value.get(index) || props.estimatedHeight } const initPositions = () => { const data = props.data || [] positions.value = [] let top = 0 for (let i = 0; i < data.length; i++) { const height = getHeight(i) positions.value.push({ index: i, top, bottom: top + height, height }) top += height } } const totalHeight = computed(() => { if (positions.value.length === 0) return 0 return positions.value[positions.value.length - 1].bottom }) const getVisibleRange = () => { const data = props.data || [] if (data.length === 0) return { start: 0, end: 0 } let start = 0 let end = data.length const currentScrollTop = scrollTop.value const currentClientHeight = clientHeight.value for (let i = 0; i < positions.value.length; i++) { if (positions.value[i].bottom > currentScrollTop) { start = i break } } for (let i = start; i < positions.value.length; i++) { if (positions.value[i].top > currentScrollTop + currentClientHeight) { end = i break } } start = Math.max(0, start - props.buffer) end = Math.min(data.length, end + props.buffer) return { start, end } } const visibleData = computed(() => { const { start, end } = getVisibleRange() const data = props.data || [] const result = [] for (let i = start; i < end; i++) { result.push({ item: data[i], index: i, top: positions.value[i]?.top || 0 }) } return result }) const handleScroll = (event) => { const now = Date.now() const throttleTime = 16 if (now - lastScrollTime < throttleTime) return lastScrollTime = now if (animationFrameId) { cancelAnimationFrame(animationFrameId) } animationFrameId = requestAnimationFrame(() => { const target = event.target scrollTop.value = target.scrollTop clientHeight.value = target.clientHeight checkLoadMore(target) }) } const checkLoadMore = (target) => { if (isLoading.value || !props.onLoadMore) return const { scrollTop: st, scrollHeight, clientHeight: ch } = target const distanceToTop = st const distanceToBottom = scrollHeight - st - ch if (props.renderMode === 'bottom') { if (distanceToTop <= props.scrollThreshold) { loadMore() } } else { if (distanceToBottom <= props.scrollThreshold) { loadMore() } } } const loadMore = async () => { if (isLoading.value || !props.onLoadMore) return isLoading.value = true emit('load-more') try { await props.onLoadMore() } finally { isLoading.value = false } } const scrollToBottom = () => { nextTick(() => { if (scrollerRef.value) { scrollerRef.value.scrollTop = scrollerRef.value.scrollHeight } }) } const scrollToTop = () => { nextTick(() => { if (scrollerRef.value) { scrollerRef.value.scrollTop = 0 } }) } const scrollToIndex = (index) => { nextTick(() => { if (scrollerRef.value && positions.value[index]) { scrollerRef.value.scrollTop = positions.value[index].top } }) } const updateData = (newData) => { initPositions() } const getScrollPosition = () => { if (scrollerRef.value) { return scrollerRef.value.scrollTop } return 0 } const setScrollPosition = (position) => { nextTick(() => { if (scrollerRef.value) { scrollerRef.value.scrollTop = position } }) } const updateItemHeight = (index, height) => { if (!props.cacheHeight) return const oldHeight = heights.value.get(index) || props.estimatedHeight if (oldHeight === height) return heights.value.set(index, height) const oldScrollTop = scrollerRef.value?.scrollTop || 0 const oldScrollHeight = scrollerRef.value?.scrollHeight || 0 initPositions() nextTick(() => { if (scrollerRef.value && props.renderMode === 'bottom') { const newScrollHeight = scrollerRef.value.scrollHeight const heightDiff = newScrollHeight - oldScrollHeight if (heightDiff > 0) { scrollerRef.value.scrollTop = oldScrollTop + heightDiff } } }) } const clearHeightCache = () => { heights.value.clear() initPositions() } const initScroll = () => { initPositions() nextTick(() => { if (scrollerRef.value) { clientHeight.value = scrollerRef.value.clientHeight if (props.renderMode === 'bottom') { scrollToBottom() } } }) } watch(() => props.data, () => { const oldScrollTop = scrollerRef.value?.scrollTop || 0 const oldScrollHeight = scrollerRef.value?.scrollHeight || 0 initPositions() nextTick(() => { if (scrollerRef.value && props.renderMode === 'bottom') { const newScrollHeight = scrollerRef.value.scrollHeight const heightDiff = newScrollHeight - oldScrollHeight if (heightDiff > 0) { scrollerRef.value.scrollTop = oldScrollTop + heightDiff } } }) }, { deep: true, immediate: true }) return { visibleData, totalHeight, scrollToBottom, scrollToTop, scrollToIndex, updateData, getScrollPosition, setScrollPosition, updateItemHeight, clearHeightCache, handleScroll, initScroll } }