fix: replace virtual card grid with progressive loading

This commit is contained in:
jxxghp
2026-05-09 22:23:45 +08:00
parent 5909d2423c
commit 2f1a356e65
13 changed files with 289 additions and 326 deletions

View File

@@ -0,0 +1,252 @@
<script setup lang="ts">
import { useIntersectionObserver } from '@vueuse/core'
const props = withDefaults(
defineProps<{
items: any[]
minItemWidth?: number
itemAspectRatio?: number
estimatedItemHeight?: number
scrollToIndex?: number
gap?: number
initialCount?: number
batchSize?: number
overscanRows?: number
getItemKey?: (item: any, index: number) => string | number
}>(),
{
minItemWidth: 144,
itemAspectRatio: 1.5,
estimatedItemHeight: undefined,
scrollToIndex: undefined,
gap: 16,
initialCount: 24,
batchSize: 24,
overscanRows: 4,
getItemKey: undefined,
},
)
const containerRef = ref<HTMLElement | null>(null)
const sentinelRef = ref<HTMLElement | null>(null)
const renderedCount = ref(0)
let animationFrameId: number | null = null
const safeInitialCount = computed(() => Math.max(1, props.initialCount))
const safeBatchSize = computed(() => Math.max(1, props.batchSize))
const hasMoreItems = computed(() => renderedCount.value < props.items.length)
const visibleItems = computed(() => props.items.slice(0, renderedCount.value))
const gridStyle = computed(() => ({
columnGap: `${props.gap}px`,
gridTemplateColumns: `repeat(auto-fill, minmax(${props.minItemWidth}px, 1fr))`,
rowGap: `${props.gap}px`,
}))
function getComparableKey(item: any, index: number) {
if (props.getItemKey) {
return props.getItemKey(item, index)
}
return index
}
function resolveItemKey(item: any, index: number) {
return getComparableKey(item, index)
}
function appendNextBatch() {
renderedCount.value = Math.min(props.items.length, renderedCount.value + safeBatchSize.value)
}
function hasPageScroll() {
if (typeof window === 'undefined') {
return true
}
const scrollHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)
return scrollHeight - (window.innerHeight || document.documentElement.clientHeight) > 2
}
async function fillViewport() {
if (typeof window === 'undefined') {
return
}
const maxIterations = Math.ceil(props.items.length / safeBatchSize.value)
let iterations = 0
while (!hasPageScroll() && hasMoreItems.value && iterations < maxIterations) {
appendNextBatch()
iterations += 1
await nextTick()
}
}
function queueFillViewport() {
if (typeof window === 'undefined' || animationFrameId !== null) {
return
}
animationFrameId = window.requestAnimationFrame(() => {
animationFrameId = null
void fillViewport()
})
}
async function revealItem(index: number) {
if (typeof window === 'undefined' || index < 0 || index >= props.items.length) {
return
}
const minRenderedCount = Math.ceil((index + 1) / safeBatchSize.value) * safeBatchSize.value
renderedCount.value = Math.min(props.items.length, Math.max(renderedCount.value, minRenderedCount))
await nextTick()
const target = containerRef.value?.querySelector(`[data-progressive-grid-index="${index}"]`)
if (target instanceof HTMLElement) {
target.scrollIntoView({
behavior: 'auto',
block: 'start',
inline: 'nearest',
})
}
}
function resetVisibleItems() {
renderedCount.value = Math.min(props.items.length, safeInitialCount.value)
nextTick(() => {
if (props.scrollToIndex !== undefined && props.scrollToIndex >= 0) {
void revealItem(props.scrollToIndex)
return
}
queueFillViewport()
})
}
function didItemsAppend(nextItems: any[], previousItems: any[]) {
if (!previousItems.length || nextItems.length < previousItems.length) {
return false
}
return previousItems.every((item, index) => getComparableKey(item, index) === getComparableKey(nextItems[index], index))
}
function syncVisibleItems(nextItems: any[], previousItems: any[] = []) {
if (didItemsAppend(nextItems, previousItems)) {
renderedCount.value = Math.min(nextItems.length, Math.max(renderedCount.value, previousItems.length))
nextTick(() => {
if (props.scrollToIndex !== undefined && props.scrollToIndex >= 0) {
void revealItem(props.scrollToIndex)
return
}
queueFillViewport()
})
return
}
resetVisibleItems()
}
const { stop } = useIntersectionObserver(
sentinelRef,
([entry]) => {
if (!entry?.isIntersecting || !hasMoreItems.value) {
return
}
appendNextBatch()
queueFillViewport()
},
{
rootMargin: '1200px 0px',
},
)
onMounted(() => {
window.addEventListener('resize', queueFillViewport, { passive: true })
})
onUnmounted(() => {
stop()
window.removeEventListener('resize', queueFillViewport)
if (animationFrameId !== null) {
window.cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
})
watch(
[
() => props.minItemWidth,
() => props.initialCount,
() => props.batchSize,
],
() => {
queueFillViewport()
},
{ immediate: true },
)
watch(
() => props.items,
(nextItems, previousItems) => {
syncVisibleItems(nextItems, previousItems)
},
{ immediate: true },
)
watch(
[() => props.scrollToIndex, () => props.items.length],
([scrollToIndex]) => {
if (scrollToIndex === undefined || scrollToIndex < 0) {
return
}
nextTick(() => {
void revealItem(scrollToIndex)
})
},
{ immediate: true },
)
</script>
<template>
<div ref="containerRef" class="progressive-card-grid">
<div class="grid" :style="gridStyle">
<div
v-for="(item, index) in visibleItems"
:key="resolveItemKey(item, index)"
class="progressive-card-grid__item"
:data-progressive-grid-index="index"
>
<slot :item="item" :index="index" />
</div>
</div>
<div v-if="hasMoreItems" ref="sentinelRef" class="progressive-card-grid__sentinel" aria-hidden="true" />
</div>
</template>
<style scoped>
.progressive-card-grid {
inline-size: 100%;
}
.progressive-card-grid__item {
min-inline-size: 0;
}
.progressive-card-grid__sentinel {
block-size: 1px;
inline-size: 100%;
}
</style>

View File

@@ -1,285 +0,0 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{
items: any[]
minItemWidth?: number
itemAspectRatio?: number
estimatedItemHeight?: number
scrollToIndex?: number
gap?: number
overscanRows?: number
getItemKey?: (item: any, index: number) => string | number
}>(),
{
minItemWidth: 144,
itemAspectRatio: 1.5,
estimatedItemHeight: undefined,
scrollToIndex: undefined,
gap: 16,
overscanRows: 4,
getItemKey: undefined,
},
)
const containerRef = ref<HTMLElement | null>(null)
const gridRef = ref<HTMLElement | null>(null)
const columnCount = ref(1)
const itemWidth = ref(props.minItemWidth)
const itemHeight = ref(props.minItemWidth * props.itemAspectRatio)
const measuredItemHeight = ref(0)
const startIndex = ref(0)
const endIndex = ref(0)
const layoutSignature = ref('')
let resizeObserver: ResizeObserver | null = null
let animationFrameId: number | null = null
const baseItemHeight = computed(() => props.estimatedItemHeight ?? itemHeight.value)
const effectiveItemHeight = computed(() => Math.max(baseItemHeight.value, measuredItemHeight.value))
const rowStep = computed(() => effectiveItemHeight.value + props.gap)
const totalRows = computed(() => Math.ceil(props.items.length / columnCount.value))
const visibleItems = computed(() => props.items.slice(startIndex.value, endIndex.value))
const renderedRowCount = computed(() => {
if (!visibleItems.value.length) {
return 0
}
return Math.ceil(visibleItems.value.length / columnCount.value)
})
const totalContentHeight = computed(() => {
if (!totalRows.value) {
return 0
}
return totalRows.value * rowStep.value - props.gap
})
const topPadding = computed(() => {
if (!startIndex.value) {
return 0
}
return Math.floor(startIndex.value / columnCount.value) * rowStep.value
})
const renderedHeight = computed(() => {
if (!renderedRowCount.value) {
return 0
}
return renderedRowCount.value * rowStep.value - props.gap
})
const bottomPadding = computed(() => {
return Math.max(totalContentHeight.value - topPadding.value - renderedHeight.value, 0)
})
const gridStyle = computed(() => ({
columnGap: `${props.gap}px`,
gridTemplateColumns: `repeat(${columnCount.value}, minmax(0, 1fr))`,
paddingBottom: `${bottomPadding.value}px`,
paddingTop: `${topPadding.value}px`,
rowGap: `${props.gap}px`,
}))
function resolveItemKey(item: any, index: number) {
if (props.getItemKey) {
return props.getItemKey(item, startIndex.value + index)
}
return startIndex.value + index
}
function updateMeasuredItemHeight() {
const grid = gridRef.value
if (!grid || !grid.childElementCount) {
return
}
const nextSignature = `${columnCount.value}:${Math.round(itemWidth.value)}`
const childHeights = Array.from(grid.children).reduce((maxHeight, child) => {
if (!(child instanceof HTMLElement)) {
return maxHeight
}
return Math.max(maxHeight, Math.ceil(child.getBoundingClientRect().height))
}, 0)
if (!childHeights) {
return
}
if (layoutSignature.value !== nextSignature) {
layoutSignature.value = nextSignature
measuredItemHeight.value = childHeights
queueSyncVisibleRange()
return
}
if (childHeights > measuredItemHeight.value) {
measuredItemHeight.value = childHeights
queueSyncVisibleRange()
}
}
function syncVisibleRange() {
if (typeof window === 'undefined') {
return
}
const container = containerRef.value
if (!container || props.items.length === 0) {
startIndex.value = 0
endIndex.value = 0
return
}
const containerWidth = container.clientWidth
if (!containerWidth) {
return
}
const columns = Math.max(1, Math.floor((containerWidth + props.gap) / (props.minItemWidth + props.gap)))
columnCount.value = columns
itemWidth.value = (containerWidth - props.gap * (columns - 1)) / columns
itemHeight.value = props.estimatedItemHeight ?? itemWidth.value * props.itemAspectRatio
const rowHeight = rowStep.value || 1
const containerTop = window.scrollY + container.getBoundingClientRect().top
const viewportTop = window.scrollY - containerTop
const viewportBottom = viewportTop + window.innerHeight
const startRow = Math.max(0, Math.floor(viewportTop / rowHeight) - props.overscanRows)
const endRow = Math.min(totalRows.value, Math.ceil(viewportBottom / rowHeight) + props.overscanRows)
const endRowExclusive = Math.max(startRow + 1, endRow)
startIndex.value = Math.min(props.items.length, startRow * columns)
endIndex.value = Math.min(props.items.length, endRowExclusive * columns)
}
function queueSyncVisibleRange() {
if (typeof window === 'undefined' || animationFrameId !== null) {
return
}
animationFrameId = window.requestAnimationFrame(() => {
animationFrameId = null
syncVisibleRange()
void nextTick(() => {
updateMeasuredItemHeight()
})
})
}
function scrollToItemIndex(index: number) {
if (typeof window === 'undefined') {
return
}
const container = containerRef.value
if (!container || props.items.length === 0 || index < 0) {
return
}
syncVisibleRange()
const safeIndex = Math.min(index, props.items.length - 1)
const targetRow = Math.floor(safeIndex / columnCount.value)
const targetTop = window.scrollY + container.getBoundingClientRect().top + targetRow * rowStep.value
window.scrollTo({
top: Math.max(targetTop - props.gap, 0),
behavior: 'auto',
})
queueSyncVisibleRange()
}
onMounted(() => {
queueSyncVisibleRange()
window.addEventListener('scroll', queueSyncVisibleRange, { passive: true })
resizeObserver = new ResizeObserver(() => {
queueSyncVisibleRange()
})
if (containerRef.value) {
resizeObserver.observe(containerRef.value)
}
if (gridRef.value) {
resizeObserver.observe(gridRef.value)
}
})
onUnmounted(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('scroll', queueSyncVisibleRange)
if (animationFrameId !== null) {
window.cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
}
resizeObserver?.disconnect()
resizeObserver = null
})
watch(
[
() => props.items.length,
() => props.minItemWidth,
() => props.itemAspectRatio,
() => props.estimatedItemHeight,
() => props.gap,
],
() => {
nextTick(() => {
queueSyncVisibleRange()
})
},
{ immediate: true },
)
watch(
() => columnCount.value,
() => {
layoutSignature.value = ''
measuredItemHeight.value = 0
},
)
watch(
[() => props.scrollToIndex, () => props.items.length],
([scrollToIndex]) => {
if (scrollToIndex === undefined || scrollToIndex < 0) {
return
}
nextTick(() => {
scrollToItemIndex(scrollToIndex)
})
},
{ immediate: true },
)
</script>
<template>
<div ref="containerRef" class="virtual-card-grid">
<div ref="gridRef" class="grid" :style="gridStyle">
<template v-for="(item, index) in visibleItems" :key="resolveItemKey(item, index)">
<slot :item="item" :index="startIndex + index" />
</template>
</div>
</div>
</template>
<style scoped>
.virtual-card-grid {
inline-size: 100%;
}
</style>