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"
|
:gap="12"
|
||||||
:estimated-item-height="220"
|
:estimated-item-height="220"
|
||||||
:overscan-rows="5"
|
:overscan-rows="5"
|
||||||
|
virtualize-in-overlay
|
||||||
:get-item-key="getResourceItemKey"
|
:get-item-key="getResourceItemKey"
|
||||||
>
|
>
|
||||||
<template #default="{ item }">
|
<template #default="{ item }">
|
||||||
|
|||||||
@@ -39,12 +39,13 @@ const ProgressiveCardGridStub = defineComponent({
|
|||||||
props: {
|
props: {
|
||||||
getItemKey: { type: Function as PropType<(item: TorrentInfo, index: number) => string>, required: true },
|
getItemKey: { type: Function as PropType<(item: TorrentInfo, index: number) => string>, required: true },
|
||||||
items: { type: Array as PropType<TorrentInfo[]>, required: true },
|
items: { type: Array as PropType<TorrentInfo[]>, required: true },
|
||||||
|
virtualizeInOverlay: Boolean,
|
||||||
},
|
},
|
||||||
setup(props, { slots }) {
|
setup(props, { slots }) {
|
||||||
return () =>
|
return () =>
|
||||||
h(
|
h(
|
||||||
'section',
|
'section',
|
||||||
{ 'data-testid': 'progressive-grid' },
|
{ 'data-testid': 'progressive-grid', 'data-virtualize-in-overlay': String(props.virtualizeInOverlay) },
|
||||||
props.items.map((item, index) =>
|
props.items.map((item, index) =>
|
||||||
h('article', { 'data-resource-key': props.getItemKey(item, index) }, slots.default?.({ item })),
|
h('article', { 'data-resource-key': props.getItemKey(item, index) }, slots.default?.({ item })),
|
||||||
),
|
),
|
||||||
@@ -399,6 +400,7 @@ describe('SiteResourceDialog', () => {
|
|||||||
|
|
||||||
await renderDialog()
|
await renderDialog()
|
||||||
expect(await screen.findByText('详情资源')).toBeInTheDocument()
|
expect(await screen.findByText('详情资源')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('progressive-grid')).toHaveAttribute('data-virtualize-in-overlay', 'true')
|
||||||
expect(screen.getByText('移动端资源说明')).toBeInTheDocument()
|
expect(screen.getByText('移动端资源说明')).toBeInTheDocument()
|
||||||
expect(screen.getByText('移动标签')).toBeInTheDocument()
|
expect(screen.getByText('移动标签')).toBeInTheDocument()
|
||||||
const cards = screen.getByTestId('progressive-grid').querySelectorAll('[data-resource-key]')
|
const cards = screen.getByTestId('progressive-grid').querySelectorAll('[data-resource-key]')
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const props = withDefaults(
|
|||||||
initialCount?: number
|
initialCount?: number
|
||||||
batchSize?: number
|
batchSize?: number
|
||||||
overscanRows?: number
|
overscanRows?: number
|
||||||
|
/** overlay 默认完整挂载以保证弹窗交互;仅在调用方能接受虚拟回收时显式开启。 */
|
||||||
|
virtualizeInOverlay?: boolean
|
||||||
getItemKey?: (item: any, index: number) => string | number
|
getItemKey?: (item: any, index: number) => string | number
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
@@ -28,6 +30,7 @@ const props = withDefaults(
|
|||||||
initialCount: 6,
|
initialCount: 6,
|
||||||
batchSize: 6,
|
batchSize: 6,
|
||||||
overscanRows: 4,
|
overscanRows: 4,
|
||||||
|
virtualizeInOverlay: false,
|
||||||
getItemKey: undefined,
|
getItemKey: undefined,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -54,6 +57,8 @@ const viewportBottom = ref(0)
|
|||||||
const heightVersion = ref(0)
|
const heightVersion = ref(0)
|
||||||
const frozenVisibleRange = ref<VirtualRange | null>(null)
|
const frozenVisibleRange = ref<VirtualRange | null>(null)
|
||||||
const isOverlayGrid = ref(false)
|
const isOverlayGrid = ref(false)
|
||||||
|
// 焦点进入虚拟化弹窗后保留全部条目,确保键盘导航不会跨越未挂载节点。
|
||||||
|
const keyboardInteractionActive = ref(false)
|
||||||
const progressiveStartIndex = ref(0)
|
const progressiveStartIndex = ref(0)
|
||||||
const progressiveEndIndex = 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 safeBatchSize = computed(() => Math.max(1, Math.floor(props.batchSize)))
|
||||||
const safeMinItemWidth = computed(() => Math.max(1, props.minItemWidth))
|
const safeMinItemWidth = computed(() => Math.max(1, props.minItemWidth))
|
||||||
const safeOverscanRows = computed(() => Math.max(1, props.overscanRows))
|
const safeOverscanRows = computed(() => Math.max(1, props.overscanRows))
|
||||||
|
const shouldRenderOverlayFully = computed(
|
||||||
|
() => isOverlayGrid.value && (!props.virtualizeInOverlay || keyboardInteractionActive.value),
|
||||||
|
)
|
||||||
|
|
||||||
const columnCount = computed(() => {
|
const columnCount = computed(() => {
|
||||||
if (props.columns && props.columns > 0) {
|
if (props.columns && props.columns > 0) {
|
||||||
@@ -168,7 +176,7 @@ const rowMetrics = computed(() => {
|
|||||||
const totalHeight = computed(() => rowMetrics.value.totalHeight)
|
const totalHeight = computed(() => rowMetrics.value.totalHeight)
|
||||||
|
|
||||||
const calculatedViewportRange = computed<VirtualRange>(() => {
|
const calculatedViewportRange = computed<VirtualRange>(() => {
|
||||||
if (isOverlayGrid.value) {
|
if (shouldRenderOverlayFully.value) {
|
||||||
const rowCount = Math.max(1, Math.ceil(props.items.length / columnCount.value))
|
const rowCount = Math.max(1, Math.ceil(props.items.length / columnCount.value))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -204,7 +212,7 @@ const calculatedViewportRange = computed<VirtualRange>(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const calculatedVisibleRange = computed<VirtualRange>(() => {
|
const calculatedVisibleRange = computed<VirtualRange>(() => {
|
||||||
if (isOverlayGrid.value || !props.items.length) {
|
if (shouldRenderOverlayFully.value || !props.items.length) {
|
||||||
return calculatedViewportRange.value
|
return calculatedViewportRange.value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,7 +235,7 @@ const visibleRange = computed(() => frozenVisibleRange.value ?? calculatedVisibl
|
|||||||
const renderedVisibleRange = computed<VirtualRange>(() => {
|
const renderedVisibleRange = computed<VirtualRange>(() => {
|
||||||
const range = visibleRange.value
|
const range = visibleRange.value
|
||||||
|
|
||||||
if (isOverlayGrid.value || frozenVisibleRange.value || range.endIndex <= range.startIndex) {
|
if (shouldRenderOverlayFully.value || frozenVisibleRange.value || range.endIndex <= range.startIndex) {
|
||||||
return range
|
return range
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +281,7 @@ const visibleCells = computed<VirtualCell[]>(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const topSpacerHeight = computed(() => {
|
const topSpacerHeight = computed(() => {
|
||||||
if (isOverlayGrid.value) {
|
if (shouldRenderOverlayFully.value) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,7 +304,7 @@ const visibleBlockHeight = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const bottomSpacerHeight = computed(() => {
|
const bottomSpacerHeight = computed(() => {
|
||||||
if (isOverlayGrid.value) {
|
if (shouldRenderOverlayFully.value) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,6 +389,12 @@ function syncOverlayGridState() {
|
|||||||
isOverlayGrid.value = isGridInsideOverlay()
|
isOverlayGrid.value = isGridInsideOverlay()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleGridFocus() {
|
||||||
|
if (isOverlayGrid.value && props.virtualizeInOverlay) {
|
||||||
|
keyboardInteractionActive.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function shouldPauseVirtualSync() {
|
function shouldPauseVirtualSync() {
|
||||||
return isDocumentOverlayLocked() && !isOverlayGrid.value
|
return isDocumentOverlayLocked() && !isOverlayGrid.value
|
||||||
}
|
}
|
||||||
@@ -681,7 +695,7 @@ function queueLayoutSync() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 弹窗内容已经由 overlay 限定生命周期,直接完整渲染可避免弹窗内交互被虚拟回收打断。
|
// 弹窗默认完整渲染;调用方显式开启虚拟化时,按视口窗口挂载条目以控制首开成本。
|
||||||
syncOverlayGridState()
|
syncOverlayGridState()
|
||||||
releaseVisibleRange()
|
releaseVisibleRange()
|
||||||
syncLayoutWidth()
|
syncLayoutWidth()
|
||||||
@@ -719,7 +733,7 @@ function cancelProgressiveRender() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncProgressiveWindow() {
|
function syncProgressiveWindow() {
|
||||||
if (isOverlayGrid.value || frozenVisibleRange.value) {
|
if (shouldRenderOverlayFully.value || frozenVisibleRange.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -755,7 +769,7 @@ function queueProgressiveRender() {
|
|||||||
typeof window === 'undefined' ||
|
typeof window === 'undefined' ||
|
||||||
!mounted ||
|
!mounted ||
|
||||||
progressiveFrameId !== null ||
|
progressiveFrameId !== null ||
|
||||||
isOverlayGrid.value ||
|
shouldRenderOverlayFully.value ||
|
||||||
frozenVisibleRange.value
|
frozenVisibleRange.value
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
@@ -1152,7 +1166,7 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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 ref="trackRef" class="progressive-card-grid__track" data-layout-size-source>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -50,6 +50,40 @@ describe('ProgressiveCardGrid scroll target lifecycle', () => {
|
|||||||
expect(container.querySelector('.progressive-card-grid__track')).toHaveAttribute('data-layout-size-source')
|
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 () => {
|
it('reveals a target below the fixed navbar on the current viewport', async () => {
|
||||||
const navbar = document.createElement('header')
|
const navbar = document.createElement('header')
|
||||||
navbar.className = 'layout-navbar'
|
navbar.className = 'layout-navbar'
|
||||||
|
|||||||
Reference in New Issue
Block a user