perf(site-resource): virtualize mobile overlay cards (#732)

This commit is contained in:
InfinityPacer
2026-08-30 18:35:52 +08:00
committed by GitHub
parent a7c377fa15
commit ca48c14642
4 changed files with 61 additions and 10 deletions
@@ -520,6 +520,7 @@ onMounted(() => {
:gap="12"
:estimated-item-height="220"
:overscan-rows="5"
virtualize-in-overlay
:get-item-key="getResourceItemKey"
>
<template #default="{ item }">
@@ -39,12 +39,13 @@ const ProgressiveCardGridStub = defineComponent({
props: {
getItemKey: { type: Function as PropType<(item: TorrentInfo, index: number) => string>, required: true },
items: { type: Array as PropType<TorrentInfo[]>, required: true },
virtualizeInOverlay: Boolean,
},
setup(props, { slots }) {
return () =>
h(
'section',
{ 'data-testid': 'progressive-grid' },
{ 'data-testid': 'progressive-grid', 'data-virtualize-in-overlay': String(props.virtualizeInOverlay) },
props.items.map((item, index) =>
h('article', { 'data-resource-key': props.getItemKey(item, index) }, slots.default?.({ item })),
),
@@ -399,6 +400,7 @@ describe('SiteResourceDialog', () => {
await renderDialog()
expect(await screen.findByText('详情资源')).toBeInTheDocument()
expect(screen.getByTestId('progressive-grid')).toHaveAttribute('data-virtualize-in-overlay', 'true')
expect(screen.getByText('移动端资源说明')).toBeInTheDocument()
expect(screen.getByText('移动标签')).toBeInTheDocument()
const cards = screen.getByTestId('progressive-grid').querySelectorAll('[data-resource-key]')
+23 -9
View File
@@ -16,6 +16,8 @@ const props = withDefaults(
initialCount?: number
batchSize?: number
overscanRows?: number
/** overlay 默认完整挂载以保证弹窗交互;仅在调用方能接受虚拟回收时显式开启。 */
virtualizeInOverlay?: boolean
getItemKey?: (item: any, index: number) => string | number
}>(),
{
@@ -28,6 +30,7 @@ const props = withDefaults(
initialCount: 6,
batchSize: 6,
overscanRows: 4,
virtualizeInOverlay: false,
getItemKey: undefined,
},
)
@@ -54,6 +57,8 @@ const viewportBottom = ref(0)
const heightVersion = ref(0)
const frozenVisibleRange = ref<VirtualRange | null>(null)
const isOverlayGrid = ref(false)
// 焦点进入虚拟化弹窗后保留全部条目,确保键盘导航不会跨越未挂载节点。
const keyboardInteractionActive = ref(false)
const progressiveStartIndex = ref(0)
const progressiveEndIndex = ref(0)
@@ -82,6 +87,9 @@ const safeInitialCount = computed(() => Math.max(1, Math.floor(props.initialCoun
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))
const shouldRenderOverlayFully = computed(
() => isOverlayGrid.value && (!props.virtualizeInOverlay || keyboardInteractionActive.value),
)
const columnCount = computed(() => {
if (props.columns && props.columns > 0) {
@@ -168,7 +176,7 @@ const rowMetrics = computed(() => {
const totalHeight = computed(() => rowMetrics.value.totalHeight)
const calculatedViewportRange = computed<VirtualRange>(() => {
if (isOverlayGrid.value) {
if (shouldRenderOverlayFully.value) {
const rowCount = Math.max(1, Math.ceil(props.items.length / columnCount.value))
return {
@@ -204,7 +212,7 @@ const calculatedViewportRange = computed<VirtualRange>(() => {
})
const calculatedVisibleRange = computed<VirtualRange>(() => {
if (isOverlayGrid.value || !props.items.length) {
if (shouldRenderOverlayFully.value || !props.items.length) {
return calculatedViewportRange.value
}
@@ -227,7 +235,7 @@ const visibleRange = computed(() => frozenVisibleRange.value ?? calculatedVisibl
const renderedVisibleRange = computed<VirtualRange>(() => {
const range = visibleRange.value
if (isOverlayGrid.value || frozenVisibleRange.value || range.endIndex <= range.startIndex) {
if (shouldRenderOverlayFully.value || frozenVisibleRange.value || range.endIndex <= range.startIndex) {
return range
}
@@ -273,7 +281,7 @@ const visibleCells = computed<VirtualCell[]>(() => {
})
const topSpacerHeight = computed(() => {
if (isOverlayGrid.value) {
if (shouldRenderOverlayFully.value) {
return 0
}
@@ -296,7 +304,7 @@ const visibleBlockHeight = computed(() => {
})
const bottomSpacerHeight = computed(() => {
if (isOverlayGrid.value) {
if (shouldRenderOverlayFully.value) {
return 0
}
@@ -381,6 +389,12 @@ function syncOverlayGridState() {
isOverlayGrid.value = isGridInsideOverlay()
}
function handleGridFocus() {
if (isOverlayGrid.value && props.virtualizeInOverlay) {
keyboardInteractionActive.value = true
}
}
function shouldPauseVirtualSync() {
return isDocumentOverlayLocked() && !isOverlayGrid.value
}
@@ -681,7 +695,7 @@ function queueLayoutSync() {
return
}
// 弹窗内容已经由 overlay 限定生命周期,直接完整渲染可避免弹窗内交互被虚拟回收打断
// 弹窗默认完整渲染;调用方显式开启虚拟化时,按视口窗口挂载条目以控制首开成本
syncOverlayGridState()
releaseVisibleRange()
syncLayoutWidth()
@@ -719,7 +733,7 @@ function cancelProgressiveRender() {
}
function syncProgressiveWindow() {
if (isOverlayGrid.value || frozenVisibleRange.value) {
if (shouldRenderOverlayFully.value || frozenVisibleRange.value) {
return
}
@@ -755,7 +769,7 @@ function queueProgressiveRender() {
typeof window === 'undefined' ||
!mounted ||
progressiveFrameId !== null ||
isOverlayGrid.value ||
shouldRenderOverlayFully.value ||
frozenVisibleRange.value
) {
return
@@ -1152,7 +1166,7 @@ watch(
</script>
<template>
<div ref="containerRef" class="progressive-card-grid">
<div ref="containerRef" class="progressive-card-grid" @focusin="handleGridFocus">
<!-- 完整高度由虚拟占位与可见网格共同承载供上层自适应布局观察稳定的尺寸语义 -->
<div ref="trackRef" class="progressive-card-grid__track" data-layout-size-source>
<div
@@ -50,6 +50,40 @@ describe('ProgressiveCardGrid scroll target lifecycle', () => {
expect(container.querySelector('.progressive-card-grid__track')).toHaveAttribute('data-layout-size-source')
})
it('allows an overlay consumer to opt into the virtual window', async () => {
const items = Array.from({ length: 100 }, (_, id) => ({ id }))
const host = document.createElement('div')
host.className = 'v-overlay'
document.body.append(host)
const { container } = render(ProgressiveCardGrid, {
container: host,
props: {
columns: 1,
estimatedItemHeight: 220,
initialCount: 6,
batchSize: 6,
virtualizeInOverlay: true,
items,
getItemKey: (item: { id: number }) => item.id,
},
slots: {
default: '<div>item</div>',
},
})
await waitFor(() => {
const count = container.querySelectorAll('[data-progressive-grid-index]').length
expect(count).toBeGreaterThan(0)
expect(count).toBeLessThan(items.length)
})
container.querySelector('.progressive-card-grid')?.dispatchEvent(new FocusEvent('focusin', { bubbles: true }))
await waitFor(() => {
expect(container.querySelectorAll('[data-progressive-grid-index]')).toHaveLength(items.length)
})
host.remove()
})
it('reveals a target below the fixed navbar on the current viewport', async () => {
const navbar = document.createElement('header')
navbar.className = 'layout-navbar'