@@ -34,260 +48,283 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = defineProps({
data: {
type: Array,
- required: true,
+ required: false,
+ default: () => []
+ },
+ items: {
+ type: Array,
+ required: false,
default: () => []
},
itemKey: {
type: [String, Function],
default: 'id'
},
+ keyField: {
+ type: String,
+ default: 'id'
+ },
estimatedHeight: {
type: Number,
default: 100
},
buffer: {
type: Number,
- default: 5
+ default: 3
},
- placeholderHeight: {
+ bufferSize: {
+ type: Number,
+ default: 3
+ },
+ height: {
+ type: [String, Number],
+ default: '100%'
+ },
+ width: {
+ type: [String, Number],
+ default: '100%'
+ },
+ renderMode: {
+ type: String,
+ default: 'default',
+ validator: (value) => ['default', 'top'].includes(value)
+ },
+ direction: {
+ type: String,
+ default: 'reverse',
+ validator: (value) => ['normal', 'reverse'].includes(value)
+ },
+ bottomPlaceholderHeight: {
type: Number,
default: 350
}
})
-const emit = defineEmits(['scroll', 'scroll-start', 'scroll-end'])
+const computedData = computed(() => {
+ return props.data.length > 0 ? props.data : props.items
+})
-const scrollContainerRef = ref(null)
+const computedItemKey = computed(() => {
+ if (typeof props.itemKey === 'function') return props.itemKey
+ if (props.itemKey !== 'id') return props.itemKey
+ return props.keyField
+})
+
+const computedBuffer = computed(() => {
+ return props.buffer !== 3 ? props.buffer : props.bufferSize
+})
+
+const emit = defineEmits(['scroll', 'scroll-start', 'scroll-end', 'visible-change'])
+
+const containerRef = ref(null)
+const wrapperRef = ref(null)
+const renderContainerRef = ref(null)
const itemRefs = new Map()
const itemHeights = ref(new Map())
const resizeObserver = ref(null)
+const scrollTop = ref(0)
const isScrolling = ref(false)
const scrollTimeout = ref(null)
+const isInitialized = ref(false)
+const pendingScrollToBottom = ref(false)
+const previousDataLength = ref(0)
-const getKey = (item, index) => {
- if (typeof props.itemKey === 'function') {
- return props.itemKey(item, index)
+const containerStyle = computed(() => {
+ return {
+ height: '100%',
+ width: '100%',
+ position: 'relative'
}
- if (item && typeof item === 'object') {
- return item[props.itemKey] ?? index
- }
- return index
-}
-
-const getItemHeight = (key) => {
- return itemHeights.value.get(key) ?? props.estimatedHeight
-}
-
-const containerStyle = computed(() => ({
- height: '100%',
- width: '100%',
- position: 'relative'
-}))
-
-const wrapperStyle = computed(() => ({
- height: '100%',
- width: '100%',
- position: 'relative',
- overflow: 'hidden',
- transform: 'rotate(180deg)',
- direction: 'rtl'
-}))
-
-const containerInnerStyle = computed(() => ({
- position: 'absolute',
- top: 0,
- left: 0,
- right: 0,
- bottom: 0,
- overflowX: 'hidden',
- overflowY: 'auto',
- direction: 'ltr'
-}))
-
-const totalDataHeight = computed(() => {
- let height = 0
- for (let i = 0; i < props.data.length; i++) {
- const key = getKey(props.data[i], i)
- height += getItemHeight(key)
- }
- return height
})
const totalHeight = computed(() => {
- return props.placeholderHeight + totalDataHeight.value
+ let height = 0
+ const len = computedData.value.length
+
+ for (let i = 0; i < len; i++) {
+ const cachedHeight = itemHeights.value.get(i)
+ height += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
+ }
+
+ height += props.bottomPlaceholderHeight
+
+ return height
})
-const spacerStyle = computed(() => ({
- height: `${totalHeight.value}px`,
- width: '100%',
- flexShrink: 0
-}))
-
-const placeholderStyle = computed(() => ({
- position: 'absolute',
- top: 0,
- left: 0,
- right: 0,
- height: `${props.placeholderHeight}px`,
- zIndex: 1
-}))
-
-const getItemOffsets = () => {
- const offsets = []
+const getItemPosition = (index) => {
let offset = 0
- for (let i = 0; i < props.data.length; i++) {
- offsets.push(offset)
- const key = getKey(props.data[i], i)
- offset += getItemHeight(key)
+
+ for (let i = 0; i < index; i++) {
+ const cachedHeight = itemHeights.value.get(i)
+ offset += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
}
- return offsets
+
+ const height = itemHeights.value.get(index) ?? props.estimatedHeight
+
+ return { offset, height }
}
+const containerHeight = computed(() => {
+ if (!renderContainerRef.value) return 0
+ return renderContainerRef.value.clientHeight
+})
+
const visibleRange = computed(() => {
- const count = props.data.length
- if (count === 0) {
+ if (!renderContainerRef.value || computedData.value.length === 0) {
return { start: 0, end: 0, offset: 0 }
}
-
- const el = scrollContainerRef.value
- if (!el) {
- return { start: 0, end: Math.min(count - 1, 9), offset: 0 }
- }
-
- const scrollTop = el.scrollTop
- const viewportHeight = el.clientHeight || 600
- const bufferCount = props.buffer
-
- // In inverted scroll (180deg rotation):
- // - scrollTop = 0: visual BOTTOM (shows newer data, lower index)
- // - scrollTop = max: visual TOP (shows older data, higher index)
- // - Items are positioned from top: placeholderHeight, then data items
- // - visibleStart/visibleEnd are offsets in the data area (after placeholder)
- // When scrollTop = 0, we're at visual bottom, showing items near the START of data
- // When scrollTop = max, we're at visual top, showing items near the END of data
+ const viewportHeight = containerHeight.value
+ const currentScrollTop = scrollTop.value
+ const bufferCount = computedBuffer.value
- // The visible area in data coordinates:
- // - scrollTop 0 means we see items at offset 0 (start of data)
- // - scrollTop increases means we see items at higher offsets (end of data)
-
- const visibleStart = Math.max(0, scrollTop - props.placeholderHeight)
- const visibleEnd = visibleStart + viewportHeight
-
let startIndex = 0
- let endIndex = count - 1
+ let endIndex = computedData.value.length - 1
let startOffset = 0
- let currentOffset = 0
-
- // Find startIndex: first item that ends after visibleStart
- for (let i = 0; i < count; i++) {
- const key = getKey(props.data[i], i)
- const height = getItemHeight(key)
- const itemEnd = currentOffset + height
-
- if (itemEnd > visibleStart) {
+
+ let offset = 0
+ for (let i = 0; i < computedData.value.length; i++) {
+ const cachedHeight = itemHeights.value.get(i)
+ const height = cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
+
+ if (offset + height > currentScrollTop) {
startIndex = Math.max(0, i - bufferCount)
break
}
-
- currentOffset += height
+
+ offset += height
}
-
- // Calculate startOffset for startIndex
+
startOffset = 0
for (let i = 0; i < startIndex; i++) {
- const key = getKey(props.data[i], i)
- startOffset += getItemHeight(key)
+ const cachedHeight = itemHeights.value.get(i)
+ startOffset += cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
}
-
- // Find endIndex: last item that starts before visibleEnd
- currentOffset = startOffset
- for (let i = startIndex; i < count; i++) {
- const key = getKey(props.data[i], i)
- const height = getItemHeight(key)
-
- // Check if this item is visible (item starts before visibleEnd)
- if (currentOffset >= visibleEnd) {
- // This item starts after visibleEnd, so previous item is the last visible
- endIndex = Math.min(count - 1, Math.max(startIndex, i - 1 + bufferCount))
+
+ offset = startOffset
+ endIndex = startIndex
+ for (let i = startIndex; i < computedData.value.length; i++) {
+ const cachedHeight = itemHeights.value.get(i)
+ const height = cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
+
+ offset += height
+
+ if (offset > currentScrollTop + viewportHeight) {
+ endIndex = Math.min(computedData.value.length - 1, i + bufferCount)
break
}
-
+
endIndex = i
- currentOffset += height
}
-
- return { start: startIndex, end: endIndex, offset: startOffset }
+
+ return { start: Math.min(startIndex, endIndex), end: Math.max(startIndex, endIndex), offset: startOffset }
})
const visibleItems = computed(() => {
const { start, end, offset } = visibleRange.value
const items = []
- const count = props.data.length
-
- if (count === 0) return items
-
- const safeStart = Math.max(0, start)
- const safeEnd = Math.min(count - 1, end)
-
- if (safeStart > safeEnd) return items
-
- let currentOffset = offset + props.placeholderHeight
- const seenKeys = new Set()
-
- for (let i = safeStart; i <= safeEnd; i++) {
- const data = props.data[i]
- if (!data) continue
-
- const key = getKey(data, i)
+
+ let currentOffset = offset
+
+ for (let i = start; i <= end && i < computedData.value.length; i++) {
+ const cachedHeight = itemHeights.value.get(i)
+ const height = cachedHeight !== undefined ? cachedHeight : props.estimatedHeight
- // Deduplicate by key
- if (seenKeys.has(key)) continue
- seenKeys.add(key)
-
- const height = getItemHeight(key)
-
items.push({
- data,
+ item: computedData.value[i],
index: i,
- key,
- offset: currentOffset,
+ offset: currentOffset + props.bottomPlaceholderHeight,
height
})
-
+
currentOffset += height
}
-
+
return items
})
-const getItemStyle = (item) => ({
+const wrapperStyle = computed(() => ({
+ direction: 'rtl',
+ height: '100%',
+ position: 'relative',
+ scrollbarWidth: 'auto',
+ overflow: 'hidden',
+ transform: 'rotate(180deg)',
+ width: '100%'
+}))
+
+const renderContainerStyle = computed(() => ({
+ direction: 'ltr',
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'flex-end',
+ bottom: 0,
+ left: 0,
+ overflowX: 'hidden',
+ overflowY: 'auto',
position: 'absolute',
+ right: 0,
top: 0,
+ width: '100%'
+}))
+
+const bottomPlaceholderStyle = computed(() => ({
+ position: 'absolute',
left: 0,
right: 0,
- transform: `translateY(${item.offset}px)`,
- willChange: 'transform'
-})
+ top: 0,
+ width: '100%',
+ height: `${props.bottomPlaceholderHeight}px`,
+ transform: `translateY(0px)`,
+ zIndex: 1
+}))
-const setItemRef = (el, key) => {
- if (el) {
- itemRefs.set(key, el)
- } else {
- itemRefs.delete(key)
+const getItemKey = (item, index) => {
+ const keyField = computedItemKey.value
+ if (typeof keyField === 'function') {
+ return keyField(item, index)
+ }
+ if (typeof keyField === 'string' && item && typeof item === 'object') {
+ return item[keyField] ?? index
+ }
+ return index
+}
+
+const getItemStyle = (renderItem) => {
+ return {
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ top: 0,
+ width: '100%',
+ transform: `translateY(${renderItem.offset}px)`,
+ willChange: 'transform'
}
}
-const measureItem = (key, element) => {
+const setItemRef = (el, index) => {
+ if (el) {
+ itemRefs.set(index, el)
+ } else {
+ itemRefs.delete(index)
+ }
+}
+
+const measureItem = (index, element) => {
if (!element) return
-
- const target = element.firstElementChild || element
- const height = target.getBoundingClientRect().height
-
- if (height > 0 && height !== itemHeights.value.get(key)) {
- const newHeights = new Map(itemHeights.value)
- newHeights.set(key, height)
- itemHeights.value = newHeights
+
+ const firstChild = element.firstElementChild
+ const targetElement = firstChild || element
+
+ const height = Math.ceil(targetElement.offsetHeight)
+
+ if (height > 0) {
+ const cachedHeight = itemHeights.value.get(index)
+ if (cachedHeight !== height) {
+ const newHeights = new Map(itemHeights.value)
+ newHeights.set(index, height)
+ itemHeights.value = newHeights
+ }
}
}
@@ -295,223 +332,336 @@ const setupResizeObserver = () => {
if (resizeObserver.value) {
resizeObserver.value.disconnect()
}
-
+
resizeObserver.value = new ResizeObserver((entries) => {
for (const entry of entries) {
- const key = entry.target.dataset.key
- if (key !== undefined) {
- measureItem(key, entry.target)
+ const index = parseInt(entry.target.dataset.index, 10)
+ if (!isNaN(index)) {
+ measureItem(index, entry.target)
}
}
})
}
-const observeItems = () => {
+const handleWheel = (event) => {
+ if (!renderContainerRef.value) return
+
+ const { deltaY } = event
+ const el = renderContainerRef.value
+
+ el.scrollBy({
+ top: -deltaY,
+ behavior: 'instant'
+ })
+
+ event.preventDefault()
+}
+
+const scrollCleanupTimeout = ref(null)
+
+const handleScroll = (event) => {
+ const target = event.target
+ scrollTop.value = target.scrollTop
+ isScrolling.value = true
+
+ if (scrollTimeout.value) {
+ clearTimeout(scrollTimeout.value)
+ }
+
+ scrollTimeout.value = setTimeout(() => {
+ isScrolling.value = false
+ }, 150)
+
+ // 滚动时添加防抖清理,每100ms最多执行一次
+ if (scrollCleanupTimeout.value) {
+ clearTimeout(scrollCleanupTimeout.value)
+ }
+ scrollCleanupTimeout.value = setTimeout(() => {
+ cleanupExtraItems(visibleItems.value)
+ }, 300)
+
+ const st = target.scrollTop
+ const scrollHeight = target.scrollHeight
+ const clientHeight = target.clientHeight
+
+ const distanceToContainerTop = st
+ const distanceToContainerBottom = scrollHeight - st - clientHeight
+
+ const distanceToPageTop = distanceToContainerBottom
+ const distanceToPageBottom = distanceToContainerTop
+ const isAtPageTop = distanceToPageTop <= 0
+ const isAtPageBottom = distanceToPageBottom <= 0
+
+ emit('scroll', {
+ target,
+ scrollTop: st,
+ scrollHeight,
+ clientHeight,
+ distanceToPageTop,
+ distanceToPageBottom,
+ isAtPageTop,
+ isAtPageBottom
+ })
+
+ if (isAtPageTop) {
+ emit('scroll-start')
+ }
+
+ if (isAtPageBottom) {
+ emit('scroll-end')
+ }
+}
+
+const scrollToIndex = (index, behavior = 'auto') => {
+ if (!renderContainerRef.value || index < 0 || index >= computedData.value.length) return
+
+ const position = getItemPosition(index)
+
+ renderContainerRef.value.scrollTo({
+ top: position.offset,
+ behavior
+ })
+}
+
+const scrollToBottom = (behavior = 'smooth') => {
+ if (!renderContainerRef.value) {
+ pendingScrollToBottom.value = true
+ return
+ }
+
+ requestAnimationFrame(() => {
+ if (!renderContainerRef.value) return
+
+ renderContainerRef.value.scrollTo({
+ top: 0,
+ behavior
+ })
+ })
+}
+
+const scrollToTop = (behavior = 'smooth') => {
+ if (!renderContainerRef.value) return
+
+ requestAnimationFrame(() => {
+ if (!renderContainerRef.value) return
+
+ const scrollHeight = renderContainerRef.value.scrollHeight
+ renderContainerRef.value.scrollTo({
+ top: scrollHeight,
+ behavior
+ })
+ })
+}
+
+const getScrollElement = () => renderContainerRef.value
+
+const getVisibleIndices = () => {
+ const { start, end } = visibleRange.value
+ return Array.from({ length: end - start + 1 }, (_, i) => start + i)
+}
+
+const resetMeasurements = () => {
+ itemHeights.value = new Map()
+ itemRefs.clear()
+}
+
+const isAtPageBottom = () => {
+ if (!renderContainerRef.value) return false
+ const { scrollTop } = renderContainerRef.value
+ return scrollTop <= 0
+}
+
+const isAtPageTop = () => {
+ if (!renderContainerRef.value) return false
+ const { scrollTop, scrollHeight, clientHeight } = renderContainerRef.value
+ return scrollHeight - scrollTop - clientHeight <= 0
+}
+
+const observeVisibleItems = () => {
if (!resizeObserver.value) return
-
+
resizeObserver.value.disconnect()
-
- for (const [key, element] of itemRefs) {
+
+ for (const [index, element] of itemRefs) {
if (element) {
resizeObserver.value.observe(element)
}
}
}
-const handleWheel = (event) => {
- if (!scrollContainerRef.value) return
-
- scrollContainerRef.value.scrollBy({
- top: -event.deltaY,
- behavior: 'instant'
- })
-
- event.preventDefault()
-}
-
-const handleScroll = (event) => {
- const target = event.target
- const scrollHeight = target.scrollHeight
- const scrollTop = target.scrollTop
- const clientHeight = target.clientHeight
-
- isScrolling.value = true
-
- if (scrollTimeout.value) {
- clearTimeout(scrollTimeout.value)
- }
-
- scrollTimeout.value = setTimeout(() => {
- isScrolling.value = false
- }, 150)
-
- // In inverted scroll:
- // - distanceToTop (visual top) = scrollHeight - scrollTop - clientHeight
- // - distanceToBottom (visual bottom) = scrollTop
- // - isAtTop (visual top, older data) = distanceToTop <= threshold
- // - isAtBottom (visual bottom, newer data) = distanceToBottom <= threshold
- const distanceToTop = scrollHeight - scrollTop - clientHeight
- const distanceToBottom = scrollTop
- const threshold = 5
-
- const isAtTop = distanceToTop <= threshold
- const isAtBottom = distanceToBottom <= threshold
-
- emit('scroll', {
- scrollTop,
- scrollHeight,
- clientHeight,
- distanceToTop,
- distanceToBottom,
- isAtTop,
- isAtBottom
- })
-
- // scroll-start: reached visual top (older data, need to load more)
- if (isAtTop) {
- emit('scroll-start')
- }
-
- // scroll-end: reached visual bottom (newer data)
- if (isAtBottom) {
- emit('scroll-end')
- }
-}
-
-const scrollToIndex = (index, behavior = 'auto') => {
- if (!scrollContainerRef.value || index < 0 || index >= props.data.length) return
-
- let offset = 0
- for (let i = 0; i < index; i++) {
- const key = getKey(props.data[i], i)
- offset += getItemHeight(key)
- }
-
- const targetScrollTop = offset + props.placeholderHeight
-
- scrollContainerRef.value.scrollTo({
- top: targetScrollTop,
- behavior
- })
-}
-
-const scrollToBottom = (behavior = 'smooth') => {
- if (!scrollContainerRef.value) return
-
- requestAnimationFrame(() => {
- if (!scrollContainerRef.value) return
- // In inverted scroll, bottom is scrollTop = 0
- scrollContainerRef.value.scrollTo({ top: 0, behavior })
- })
-}
-
-const scrollToTop = (behavior = 'smooth') => {
- if (!scrollContainerRef.value) return
-
- requestAnimationFrame(() => {
- if (!scrollContainerRef.value) return
- // In inverted scroll, top is scrollTop = max
- scrollContainerRef.value.scrollTo({
- top: scrollContainerRef.value.scrollHeight,
- behavior
- })
- })
-}
-
-const getScrollElement = () => scrollContainerRef.value
-
-const isAtTop = () => {
- if (!scrollContainerRef.value) return false
- const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.value
- return scrollHeight - scrollTop - clientHeight <= 5
-}
-
-const isAtBottom = () => {
- if (!scrollContainerRef.value) return false
- return scrollContainerRef.value.scrollTop <= 5
-}
-
-const reset = () => {
- itemHeights.value = new Map()
- itemRefs.clear()
-}
-
-watch(() => props.data, (newData, oldData) => {
- const newLength = newData?.length || 0
+watch(() => computedData.value, (newData, oldData) => {
const oldLength = oldData?.length || 0
-
- if (newLength < oldLength) {
- reset()
+ const newLength = newData.length
+
+ if (newLength !== oldLength) {
+ const newHeights = new Map()
+
+ const minLen = Math.min(oldLength, newLength)
+ for (let i = 0; i < minLen; i++) {
+ if (itemHeights.value.has(i)) {
+ newHeights.set(i, itemHeights.value.get(i))
+ }
+ }
+
+ itemHeights.value = newHeights
+
+ nextTick(() => {
+ observeVisibleItems()
+ })
}
+
+ previousDataLength.value = newLength
+}, { deep: false })
- nextTick(observeItems)
+watch(visibleItems, (newItems) => {
+ nextTick(() => {
+ observeVisibleItems()
+ cleanupExtraItems(newItems)
+ })
+ if (newItems.length > 0) {
+ const firstItem = newItems[0]
+ const lastItem = newItems[newItems.length - 1]
+ emit('visible-change', firstItem.index, lastItem.index)
+ }
}, { deep: true })
-watch(visibleItems, () => {
- nextTick(observeItems)
-}, { deep: true })
+const cleanupExtraItems = (currentVisibleItems) => {
+ if (!renderContainerRef.value || !currentVisibleItems.length) return
+
+ // 构建当前应该可见的索引集合
+ const visibleIndices = new Set(currentVisibleItems.map(item => item.index))
+
+ // 直接获取 render-container 内所有实际渲染的 .virtual-scroller-item 元素
+ const renderedItems = renderContainerRef.value.querySelectorAll('.virtual-scroller-item')
+
+ const toRemove = []
+
+ for (const el of renderedItems) {
+ const dataIndex = parseInt(el.getAttribute('data-index'), 10)
+
+ // 如果元素的 data-index 不在可见范围内,标记为删除
+ if (!isNaN(dataIndex) && !visibleIndices.has(dataIndex)) {
+ toRemove.push(el)
+ }
+ }
+
+ // 从 DOM 中删除多余元素
+ for (const el of toRemove) {
+ if (el.parentNode) {
+ el.parentNode.removeChild(el)
+ }
+ // 同步清理 itemRefs Map
+ const index = parseInt(el.getAttribute('data-index'), 10)
+ if (!isNaN(index)) {
+ itemRefs.delete(index)
+ }
+ }
+
+ if (toRemove.length > 0) {
+ console.log(`[VirtualScroller] 清理了 ${toRemove.length} 个多余DOM元素`)
+ }
+}
onMounted(() => {
setupResizeObserver()
- nextTick(observeItems)
+ isInitialized.value = true
+ previousDataLength.value = computedData.value.length
+
+ nextTick(() => {
+ if (pendingScrollToBottom.value) {
+ pendingScrollToBottom.value = false
+ scrollToBottom()
+ }
+
+ observeVisibleItems()
+ cleanupExtraItems(visibleItems.value)
+ })
})
onBeforeUnmount(() => {
if (resizeObserver.value) {
resizeObserver.value.disconnect()
}
-
+
if (scrollTimeout.value) {
clearTimeout(scrollTimeout.value)
}
-
+
+ if (scrollCleanupTimeout.value) {
+ clearTimeout(scrollCleanupTimeout.value)
+ }
+
itemRefs.clear()
})
defineExpose({
scrollToIndex,
+ scrollToItem: scrollToIndex,
scrollToBottom,
scrollToTop,
getScrollElement,
- isAtTop,
- isAtBottom,
- reset,
- scrollContainerRef
+ getVisibleIndices,
+ resetMeasurements,
+ containerRef,
+ isAtPageBottom,
+ isAtPageTop
})