diff --git a/src/components/misc/ProgressiveCardGrid.vue b/src/components/misc/ProgressiveCardGrid.vue index cbefe0e1..66a0aa2f 100644 --- a/src/components/misc/ProgressiveCardGrid.vue +++ b/src/components/misc/ProgressiveCardGrid.vue @@ -25,8 +25,8 @@ const props = withDefaults( scrollToIndex: undefined, gap: 16, columns: undefined, - initialCount: 24, - batchSize: 24, + initialCount: 6, + batchSize: 6, overscanRows: 4, getItemKey: undefined, }, @@ -54,6 +54,8 @@ const viewportBottom = ref(0) const heightVersion = ref(0) const frozenVisibleRange = ref(null) const isOverlayGrid = ref(false) +const progressiveStartIndex = ref(0) +const progressiveEndIndex = ref(0) const itemHeights = new Map() const observedElements = new Map() @@ -66,6 +68,7 @@ let overlayLockObserver: MutationObserver | null = null let scrollTarget: ScrollTarget | null = null let layoutFrameId: number | null = null let scrollFrameId: number | null = null +let progressiveFrameId: number | null = null let mounted = false let pendingRevealIndex: number | null = null let lastMeasuredColumnCount = 0 @@ -73,6 +76,8 @@ let lastMeasuredColumnWidth = 0 let documentOverlayLocked = false const safeGap = computed(() => Math.max(0, props.gap)) +const safeInitialCount = computed(() => Math.max(1, Math.floor(props.initialCount))) +const safeBatchSize = computed(() => Math.max(1, Math.floor(props.batchSize))) const safeMinItemWidth = computed(() => Math.max(1, props.minItemWidth)) const safeOverscanRows = computed(() => Math.max(1, props.overscanRows)) @@ -160,7 +165,7 @@ const rowMetrics = computed(() => { const totalHeight = computed(() => rowMetrics.value.totalHeight) -const calculatedVisibleRange = computed(() => { +const calculatedViewportRange = computed(() => { if (isOverlayGrid.value) { const rowCount = Math.max(1, Math.ceil(props.items.length / columnCount.value)) @@ -187,8 +192,24 @@ const calculatedVisibleRange = computed(() => { const bottom = Math.max(top, Math.min(viewportBottom.value, totalHeight.value)) const firstVisibleRow = findFirstRowAtOrAfterOffset(offsets, heights, top) const lastVisibleRow = findLastRowAtOrBeforeOffset(offsets, rowCount, bottom) - const startRow = clamp(firstVisibleRow - safeOverscanRows.value, 0, rowCount - 1) - const endRow = clamp(lastVisibleRow + safeOverscanRows.value, startRow, rowCount - 1) + + return { + endIndex: Math.min(props.items.length, (lastVisibleRow + 1) * columnCount.value), + endRow: lastVisibleRow, + startIndex: firstVisibleRow * columnCount.value, + startRow: firstVisibleRow, + } +}) + +const calculatedVisibleRange = computed(() => { + if (isOverlayGrid.value || !props.items.length) { + return calculatedViewportRange.value + } + + const viewportRange = calculatedViewportRange.value + const rowCount = rowMetrics.value.rowCount + const startRow = clamp(viewportRange.startRow - safeOverscanRows.value, 0, rowCount - 1) + const endRow = clamp(viewportRange.endRow + safeOverscanRows.value, startRow, rowCount - 1) return { endIndex: Math.min(props.items.length, (endRow + 1) * columnCount.value), @@ -200,10 +221,45 @@ const calculatedVisibleRange = computed(() => { const visibleRange = computed(() => frozenVisibleRange.value ?? calculatedVisibleRange.value) +// 视口首批内容同步提交,额外 overscan 分帧挂载;完整列表高度仍由 spacer 立即占位。 +const renderedVisibleRange = computed(() => { + const range = visibleRange.value + + if (isOverlayGrid.value || frozenVisibleRange.value || range.endIndex <= range.startIndex) { + return range + } + + const viewportRange = calculatedViewportRange.value + const initialEndIndex = Math.min( + range.endIndex, + Math.max( + viewportRange.endIndex, + Math.ceil((viewportRange.startIndex + safeInitialCount.value) / columnCount.value) * columnCount.value, + ), + ) + const stagedStartIndex = clamp(progressiveStartIndex.value, range.startIndex, viewportRange.startIndex) + const stagedEndIndex = clamp( + Math.max(progressiveEndIndex.value, initialEndIndex), + viewportRange.endIndex, + range.endIndex, + ) + const startRow = Math.max(range.startRow, Math.floor(stagedStartIndex / columnCount.value)) + const endRow = Math.min(range.endRow, Math.max(range.startRow, Math.ceil(stagedEndIndex / columnCount.value) - 1)) + + return { + ...range, + endIndex: Math.min(range.endIndex, (endRow + 1) * columnCount.value), + endRow, + startIndex: startRow * columnCount.value, + startRow, + } +}) + const visibleCells = computed(() => { const cells: VirtualCell[] = [] + const range = renderedVisibleRange.value - for (let index = visibleRange.value.startIndex; index < visibleRange.value.endIndex; index += 1) { + for (let index = range.startIndex; index < range.endIndex; index += 1) { cells.push({ item: props.items[index], index, @@ -219,18 +275,20 @@ const topSpacerHeight = computed(() => { return 0 } - return rowMetrics.value.offsets[visibleRange.value.startRow] ?? 0 + return rowMetrics.value.offsets[renderedVisibleRange.value.startRow] ?? 0 }) const visibleBlockHeight = computed(() => { - if (!props.items.length || visibleRange.value.endIndex <= visibleRange.value.startIndex) { + const range = renderedVisibleRange.value + + if (!props.items.length || range.endIndex <= range.startIndex) { return 0 } return Math.max( - (rowMetrics.value.offsets[visibleRange.value.endRow] ?? 0) + - (rowMetrics.value.heights[visibleRange.value.endRow] ?? 0) - - (rowMetrics.value.offsets[visibleRange.value.startRow] ?? 0), + (rowMetrics.value.offsets[range.endRow] ?? 0) + + (rowMetrics.value.heights[range.endRow] ?? 0) - + (rowMetrics.value.offsets[range.startRow] ?? 0), 0, ) }) @@ -331,11 +389,14 @@ function freezeVisibleRange() { } // 弹窗打开期间固定当前渲染窗口,防止 body 锁滚动造成坐标跳变并卸载触发弹窗的卡片。 - frozenVisibleRange.value = { ...calculatedVisibleRange.value } + cancelProgressiveRender() + frozenVisibleRange.value = { ...renderedVisibleRange.value } } function releaseVisibleRange() { frozenVisibleRange.value = null + syncProgressiveWindow() + queueProgressiveRender() } function handleOverlayLockChange() { @@ -646,6 +707,74 @@ function queueViewportSync() { }) } +function cancelProgressiveRender() { + if (progressiveFrameId === null) { + return + } + + window.cancelAnimationFrame(progressiveFrameId) + progressiveFrameId = null +} + +function syncProgressiveWindow() { + if (isOverlayGrid.value || frozenVisibleRange.value) { + return + } + + const range = calculatedVisibleRange.value + const viewportRange = calculatedViewportRange.value + const overlapsViewport = + progressiveEndIndex.value > viewportRange.startIndex && progressiveStartIndex.value < viewportRange.endIndex + + if (!overlapsViewport) { + progressiveStartIndex.value = viewportRange.startIndex + progressiveEndIndex.value = viewportRange.endIndex + } else { + progressiveStartIndex.value = clamp(progressiveStartIndex.value, range.startIndex, viewportRange.startIndex) + progressiveEndIndex.value = clamp(progressiveEndIndex.value, viewportRange.endIndex, range.endIndex) + } + + const initialEndIndex = Math.min( + range.endIndex, + Math.max( + viewportRange.endIndex, + Math.ceil((viewportRange.startIndex + safeInitialCount.value) / columnCount.value) * columnCount.value, + ), + ) + progressiveEndIndex.value = Math.max(progressiveEndIndex.value, initialEndIndex) +} + +function queueProgressiveRender() { + if ( + typeof window === 'undefined' || + !mounted || + progressiveFrameId !== null || + isOverlayGrid.value || + frozenVisibleRange.value + ) { + return + } + + const range = renderedVisibleRange.value + const targetRange = visibleRange.value + if (range.endIndex >= targetRange.endIndex && range.startIndex <= targetRange.startIndex) { + return + } + + progressiveFrameId = window.requestAnimationFrame(() => { + progressiveFrameId = null + const batchRows = Math.max(1, Math.floor(safeBatchSize.value / columnCount.value)) + const batchItems = batchRows * columnCount.value + + if (range.endIndex < targetRange.endIndex) { + progressiveEndIndex.value = Math.min(targetRange.endIndex, range.endIndex + batchItems) + } else { + progressiveStartIndex.value = Math.max(targetRange.startIndex, range.startIndex - batchItems) + } + queueProgressiveRender() + }) +} + function getTrackScrollTop() { const element = trackRef.value @@ -821,17 +950,20 @@ onMounted(() => { window.addEventListener('resize', handleWindowResize, { passive: true }) queueLayoutSync() + queueProgressiveRender() }) onActivated(() => { mounted = true refreshScrollTarget() queueLayoutSync() + queueProgressiveRender() requestAnimationFrame(queueLayoutSync) }) onDeactivated(() => { mounted = false + cancelProgressiveRender() removeScrollListener(scrollTarget) scrollTarget = null }) @@ -858,13 +990,22 @@ onUnmounted(() => { window.cancelAnimationFrame(scrollFrameId) scrollFrameId = null } + + cancelProgressiveRender() }) watch( itemKeys, (nextKeys, previousKeys) => { + if (!didKeysAppend(nextKeys, previousKeys)) { + cancelProgressiveRender() + progressiveStartIndex.value = 0 + progressiveEndIndex.value = 0 + } syncMeasurementsForItems(nextKeys, previousKeys) + syncProgressiveWindow() queueLayoutSync() + queueProgressiveRender() }, { immediate: true }, ) @@ -876,15 +1017,29 @@ watch( () => props.estimatedItemHeight, () => props.itemAspectRatio, () => props.columns, + () => props.initialCount, + () => props.batchSize, ], () => { + cancelProgressiveRender() + syncProgressiveWindow() queueLayoutSync() + queueProgressiveRender() }, ) watch([columnCount, columnWidth], () => { + cancelProgressiveRender() invalidateMeasurementsForLayoutChange() + syncProgressiveWindow() queueViewportSync() + queueProgressiveRender() +}) + +watch([calculatedViewportRange, calculatedVisibleRange], () => { + cancelProgressiveRender() + syncProgressiveWindow() + queueProgressiveRender() }) watch( diff --git a/src/components/misc/__tests__/ProgressiveCardGrid.spec.ts b/src/components/misc/__tests__/ProgressiveCardGrid.spec.ts index 62563e26..4ebf9d0b 100644 --- a/src/components/misc/__tests__/ProgressiveCardGrid.spec.ts +++ b/src/components/misc/__tests__/ProgressiveCardGrid.spec.ts @@ -1,5 +1,6 @@ import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue' import { render, waitFor } from '@testing-library/vue' +import { nextTick } from 'vue' import { afterEach, describe, expect, it, vi } from 'vitest' describe('ProgressiveCardGrid scroll target lifecycle', () => { @@ -35,3 +36,180 @@ describe('ProgressiveCardGrid scroll target lifecycle', () => { }) }) }) + +describe('ProgressiveCardGrid mount scheduling', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('mounts an appended visible range in frame-bounded batches', async () => { + const callbacks = new Map() + let frameId = 0 + vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { + frameId += 1 + callbacks.set(frameId, callback) + + return frameId + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => { + callbacks.delete(id) + }) + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(100) + + const initialItems = Array.from({ length: 8 }, (_, id) => ({ id })) + const { container, rerender } = render(ProgressiveCardGrid, { + props: { + batchSize: 4, + columns: 4, + estimatedItemHeight: 100, + gap: 0, + initialCount: 4, + items: initialItems, + getItemKey: (item: { id: number }) => item.id, + }, + slots: { + default: '
item
', + }, + }) + + const flushFrame = async () => { + const frameCallbacks = [...callbacks.values()] + callbacks.clear() + frameCallbacks.forEach(callback => callback(performance.now())) + await nextTick() + } + + expect(container.querySelectorAll('[data-progressive-grid-index]')).toHaveLength(4) + expect(container.querySelector('.progressive-card-grid__spacer')).toHaveStyle({ blockSize: '100px' }) + await flushFrame() + expect(container.querySelectorAll('[data-progressive-grid-index]')).toHaveLength(8) + + await rerender({ + batchSize: 4, + columns: 4, + estimatedItemHeight: 100, + gap: 0, + initialCount: 4, + items: Array.from({ length: 20 }, (_, id) => ({ id })), + getItemKey: (item: { id: number }) => item.id, + }) + + expect(container.querySelectorAll('[data-progressive-grid-index]')).toHaveLength(8) + expect(container.querySelector('.progressive-card-grid__spacer')).toHaveStyle({ blockSize: '300px' }) + await flushFrame() + expect(container.querySelectorAll('[data-progressive-grid-index]')).toHaveLength(12) + await flushFrame() + expect(container.querySelectorAll('[data-progressive-grid-index]')).toHaveLength(16) + }) + + it('resets the staged window around the viewport after forward and reverse jumps', async () => { + const callbacks = new Map() + let frameId = 0 + let scrollOffset = 0 + const getBoundingClientRect = HTMLElement.prototype.getBoundingClientRect + vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { + frameId += 1 + callbacks.set(frameId, callback) + + return frameId + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => { + callbacks.delete(id) + }) + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(100) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (!this.classList.contains('progressive-card-grid__track')) { + return getBoundingClientRect.call(this) + } + + return { + bottom: 2500 - scrollOffset, + height: 2500, + left: 0, + right: 400, + toJSON: () => ({}), + top: -scrollOffset, + width: 400, + x: 0, + y: -scrollOffset, + } + }) + + const { container } = render(ProgressiveCardGrid, { + props: { + batchSize: 4, + columns: 4, + estimatedItemHeight: 100, + gap: 0, + initialCount: 4, + items: Array.from({ length: 100 }, (_, id) => ({ id })), + getItemKey: (item: { id: number }) => item.id, + }, + slots: { + default: '
item
', + }, + }) + + const flushFrame = async () => { + const frameCallbacks = [...callbacks.values()] + callbacks.clear() + frameCallbacks.forEach(callback => callback(performance.now())) + await nextTick() + } + const renderedIndices = () => + Array.from(container.querySelectorAll('[data-progressive-grid-index]'), element => + Number(element.getAttribute('data-progressive-grid-index')), + ) + + await flushFrame() + scrollOffset = 2000 + window.dispatchEvent(new Event('scroll')) + await flushFrame() + expect(renderedIndices().length).toBeLessThanOrEqual(12) + expect(renderedIndices()).toEqual(expect.arrayContaining([80, 81, 82, 83])) + + scrollOffset = 0 + window.dispatchEvent(new Event('scroll')) + await flushFrame() + expect(renderedIndices().length).toBeLessThanOrEqual(12) + expect(renderedIndices()).toEqual(expect.arrayContaining([0, 1, 2, 3])) + }) + + it('mounts one complete overscan row when the column count exceeds the batch size', async () => { + const callbacks = new Map() + let frameId = 0 + vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { + frameId += 1 + callbacks.set(frameId, callback) + + return frameId + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => { + callbacks.delete(id) + }) + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(99) + + const { container } = render(ProgressiveCardGrid, { + props: { + batchSize: 6, + columns: 9, + estimatedItemHeight: 100, + gap: 0, + initialCount: 6, + items: Array.from({ length: 100 }, (_, id) => ({ id })), + getItemKey: (item: { id: number }) => item.id, + }, + slots: { + default: '
item
', + }, + }) + + expect(container.querySelectorAll('[data-progressive-grid-index]')).toHaveLength(9) + const frameCallbacks = [...callbacks.values()] + callbacks.clear() + frameCallbacks.forEach(callback => callback(performance.now())) + await nextTick() + + expect(container.querySelectorAll('[data-progressive-grid-index]')).toHaveLength(18) + }) +})