perf(grid): stage virtual card mounting (#619)

This commit is contained in:
InfinityPacer
2026-07-31 20:32:32 +08:00
committed by GitHub
parent 5acf6ee84c
commit cfbbd89b1b
2 changed files with 345 additions and 12 deletions

View File

@@ -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<VirtualRange | null>(null)
const isOverlayGrid = ref(false)
const progressiveStartIndex = ref(0)
const progressiveEndIndex = ref(0)
const itemHeights = new Map<ItemKey, number>()
const observedElements = new Map<HTMLElement, ItemKey>()
@@ -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<VirtualRange>(() => {
const calculatedViewportRange = computed<VirtualRange>(() => {
if (isOverlayGrid.value) {
const rowCount = Math.max(1, Math.ceil(props.items.length / columnCount.value))
@@ -187,8 +192,24 @@ const calculatedVisibleRange = computed<VirtualRange>(() => {
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<VirtualRange>(() => {
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<VirtualRange>(() => {
const visibleRange = computed(() => frozenVisibleRange.value ?? calculatedVisibleRange.value)
// 视口首批内容同步提交,额外 overscan 分帧挂载;完整列表高度仍由 spacer 立即占位。
const renderedVisibleRange = computed<VirtualRange>(() => {
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<VirtualCell[]>(() => {
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(

View File

@@ -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<number, FrameRequestCallback>()
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: '<div>item</div>',
},
})
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<number, FrameRequestCallback>()
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: '<div>item</div>',
},
})
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<number, FrameRequestCallback>()
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: '<div>item</div>',
},
})
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)
})
})