mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-10 02:06:44 +08:00
perf(site-resource): virtualize mobile overlay cards (#732)
This commit is contained in:
@@ -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]')
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user