perf(list): prefetch infinite scroll pages (#622)

This commit is contained in:
InfinityPacer
2026-08-01 07:27:36 +08:00
committed by GitHub
parent 9e35378ab4
commit 88f81ecce4
10 changed files with 251 additions and 21 deletions

View File

@@ -147,7 +147,14 @@ async function fetchData({ done }: { done: (status: 'empty' | 'error' | 'loading
<template>
<LoadingBanner v-if="!isRefreshed && !loadFailed" class="mt-12" />
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible pt-3 px-2" @load="fetchData">
<VInfiniteScroll
mode="intersect"
side="end"
:items="dataList"
:margin="dataList.length > 0 ? 600 : 0"
class="overflow-visible pt-3 px-2"
@load="fetchData"
>
<template #loading />
<template #empty />
<template #error="{ props: retryProps }">

View File

@@ -29,6 +29,9 @@ const loading = ref(false)
// 是否加载完成
const isRefreshed = ref(false)
// 首次成功响应前,请求失败只展示错误和重试入口。
const loadFailed = ref(false)
// 使用 shallowRef 避免长列表中的深层代理开销
const dataList = shallowRef<Person[]>([])
@@ -56,6 +59,7 @@ function getParams() {
async function fetchData({ done }: { done: any }) {
try {
if (!props.apipath) return
loadFailed.value = false
// 如果正在加载中,直接返回
if (loading.value) {
@@ -71,8 +75,6 @@ async function fetchData({ done }: { done: any }) {
loading.value = true
// 请求API
const currentData = await loadPageData()
// 取消加载中
loading.value = false
// 标计为已请求完成
isRefreshed.value = true
if (currentData.length === 0) {
@@ -108,22 +110,38 @@ async function fetchData({ done }: { done: any }) {
// 返回加载成功
done('ok')
}
// 取消加载中
loading.value = false
}
} catch (error) {
console.error(error)
loadFailed.value = true
// 返回加载失败
done('error')
} finally {
loading.value = false
}
}
</script>
<template>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible px-3" @load="fetchData">
<LoadingBanner v-if="!isRefreshed && !loadFailed" class="mt-12" />
<VInfiniteScroll
mode="intersect"
side="end"
:items="dataList"
:margin="dataList.length > 0 ? 600 : 0"
class="overflow-visible px-3"
@load="fetchData"
>
<template #loading />
<template #empty />
<template #error="{ props: retryProps }">
<div class="d-flex flex-column align-center ga-2 py-4" role="alert">
<span class="text-body-2 text-medium-emphasis">{{ t('error.networkError') }}</span>
<VBtn v-bind="retryProps" prepend-icon="mdi-refresh" size="small" variant="tonal">
{{ t('common.retry') }}
</VBtn>
</div>
</template>
<ProgressiveCardGrid
v-if="dataList.length > 0"
:items="dataList"

View File

@@ -14,6 +14,7 @@ const LIST_PATH = 'test/discover/list'
const LIST_URL = new URL(LIST_PATH, API_BASE_URL).href
type InfiniteScrollStatus = 'empty' | 'error' | 'ok'
const initialLoadMargins: number[] = []
const InfiniteScrollStub = defineComponent({
name: 'VInfiniteScroll',
@@ -22,12 +23,17 @@ const InfiniteScrollStub = defineComponent({
type: Array as PropType<MediaInfo[]>,
default: () => [],
},
margin: {
type: Number,
required: true,
},
},
emits: ['load'],
setup(_props, { emit, slots }) {
setup(props, { emit, slots }) {
const status = ref<'empty' | 'error' | 'idle' | 'loading'>('idle')
function load() {
initialLoadMargins.push(props.margin)
status.value = 'loading'
emit('load', {
done(nextStatus: InfiniteScrollStatus) {
@@ -39,7 +45,7 @@ const InfiniteScrollStub = defineComponent({
onMounted(load)
return () =>
h('section', { 'aria-label': '媒体无限列表' }, [
h('section', { 'aria-label': '媒体无限列表', 'data-margin': String(props.margin) }, [
h('output', { 'aria-label': '媒体无限列表状态' }, status.value),
status.value === 'error'
? slots.error?.({
@@ -162,6 +168,7 @@ function gridKeys() {
describe('MediaCardListView', () => {
beforeEach(() => {
initialLoadMargins.length = 0
vi.spyOn(console, 'error').mockImplementation(() => {})
})
@@ -178,6 +185,8 @@ describe('MediaCardListView', () => {
await renderList({ params: { genre: '科幻', page: '99' } })
expect(await screen.findByRole('article', { name: '媒体卡片 内部页码结果' })).toBeInTheDocument()
expect(screen.getByLabelText('媒体无限列表')).toHaveAttribute('data-margin', '600')
expect(initialLoadMargins[0]).toBe(0)
expect(requests).toHaveLength(1)
expect(requests[0].searchParams.get('page')).toBe('1')
expect(requests[0].searchParams.get('genre')).toBe('科幻')

View File

@@ -0,0 +1,146 @@
import type { Person } from '@/api/types'
import PersonCardListView from '@/views/discover/PersonCardListView.vue'
import { screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http } from 'msw'
import { defineComponent, h, onMounted, ref, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const LIST_PATH = 'test/discover/people'
const LIST_URL = new URL(LIST_PATH, 'http://localhost/api/v1/').href
type InfiniteScrollStatus = 'empty' | 'error' | 'ok'
const initialLoadMargins: number[] = []
const InfiniteScrollStub = defineComponent({
name: 'VInfiniteScroll',
props: {
margin: {
type: Number,
required: true,
},
},
emits: ['load'],
setup(props, { emit, slots }) {
const status = ref<'empty' | 'error' | 'idle' | 'loading'>('idle')
function load() {
initialLoadMargins.push(props.margin)
status.value = 'loading'
emit('load', {
done(nextStatus: InfiniteScrollStatus) {
status.value = nextStatus === 'ok' ? 'idle' : nextStatus
},
})
}
onMounted(load)
return () =>
h('section', { 'aria-label': '人物无限列表', 'data-margin': String(props.margin) }, [
h('output', { 'aria-label': '人物无限列表状态' }, status.value),
status.value === 'error'
? slots.error?.({
side: 'end',
props: { color: undefined, onClick: load },
})
: null,
slots.default?.(),
])
},
})
const ProgressiveCardGridStub = defineComponent({
name: 'ProgressiveCardGrid',
props: {
items: {
type: Array as PropType<Person[]>,
required: true,
},
},
setup(props, { slots }) {
return () =>
h(
'section',
props.items.flatMap(item => slots.default?.({ item }) ?? []),
)
},
})
const PersonCardStub = defineComponent({
name: 'PersonCard',
props: {
person: {
type: Object as PropType<Person>,
required: true,
},
},
setup(props) {
return () => h('article', props.person.name)
},
})
const LoadingBannerStub = defineComponent({
name: 'LoadingBanner',
template: '<div role="status">正在加载人物列表</div>',
})
async function renderList() {
return renderWithProviders(PersonCardListView, {
props: { apipath: LIST_PATH },
global: {
stubs: {
LoadingBanner: LoadingBannerStub,
NoDataFound: true,
PersonCard: PersonCardStub,
ProgressiveCardGrid: ProgressiveCardGridStub,
VInfiniteScroll: InfiniteScrollStub,
},
},
})
}
describe('PersonCardListView', () => {
beforeEach(() => {
initialLoadMargins.length = 0
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(600)
vi.spyOn(document.body, 'scrollHeight', 'get').mockReturnValue(900)
})
it('loads people with the configured prefetch margin', async () => {
server.use(
http.get(LIST_URL, () =>
HttpResponse.json([{ id: 101, name: '探索人物', source: 'themoviedb' } satisfies Person]),
),
)
await renderList()
expect(await screen.findByText('探索人物')).toBeInTheDocument()
expect(screen.getByLabelText('人物无限列表')).toHaveAttribute('data-margin', '600')
expect(initialLoadMargins[0]).toBe(0)
})
it('shows an inline retry and retries the same page after a request failure', async () => {
let requests = 0
server.use(
http.get(LIST_URL, () => {
requests++
if (requests === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
return HttpResponse.json([{ id: 202, name: '人物重试结果', source: 'themoviedb' } satisfies Person])
}),
)
const user = userEvent.setup()
await renderList()
const retry = await screen.findByRole('button', { name: '重试' })
expect(screen.queryByText('正在加载人物列表')).not.toBeInTheDocument()
await user.click(retry)
expect(await screen.findByText('人物重试结果')).toBeInTheDocument()
expect(requests).toBe(2)
})
})

View File

@@ -1706,6 +1706,7 @@ onUnmounted(() => {
mode="intersect"
side="end"
:items="mobileDataList"
:margin="mobileDataList.length > 0 ? 280 : 0"
class="transfer-history-mobile-scroll"
@load="loadMobileHistory"
>
@@ -1715,6 +1716,14 @@ onUnmounted(() => {
</div>
</template>
<template #empty />
<template #error="{ props: retryProps }">
<div class="transfer-history-mobile-state d-flex flex-column ga-2" role="alert">
<span class="text-body-2 text-medium-emphasis">{{ t('common.serverConnectionFailed') }}</span>
<VBtn v-bind="retryProps" prepend-icon="mdi-refresh" size="small" variant="tonal">
{{ t('common.retry') }}
</VBtn>
</div>
</template>
<ProgressiveCardGrid
v-if="mobileDataList.length > 0"

View File

@@ -129,21 +129,36 @@ type InfiniteStatus = 'empty' | 'error' | 'ok'
const InfiniteScrollStub = defineComponent({
name: 'VInfiniteScroll',
props: {
margin: {
type: Number,
required: true,
},
},
emits: ['load'],
setup(_props, { emit, slots }) {
setup(props, { emit, slots }) {
const status = ref('idle')
function load() {
status.value = 'loading'
emit('load', {
done(nextStatus: InfiniteStatus) {
status.value = nextStatus
status.value = nextStatus === 'ok' ? 'idle' : nextStatus
},
})
}
return () =>
h('section', { 'aria-label': '整理历史无限列表' }, [
h('section', { 'aria-label': '整理历史无限列表', 'data-margin': String(props.margin) }, [
h('output', { 'aria-label': '整理历史无限列表状态' }, status.value),
status.value === 'loading' ? slots.loading?.({}) : null,
status.value === 'error'
? slots.error?.({
side: 'end',
props: { color: undefined, onClick: load },
})
: null,
status.value === 'empty' ? slots.empty?.({}) : null,
slots.default?.(),
h('button', { onClick: load, type: 'button' }, '加载下一页'),
status.value === 'idle' ? h('button', { onClick: load, type: 'button' }, '加载下一页') : null,
])
},
})
@@ -445,8 +460,10 @@ describe('TransferHistoryView', () => {
await renderHistory('/history?search=%E7%A7%BB%E5%8A%A8')
expect(screen.getByLabelText('整理历史无限列表')).toHaveAttribute('data-margin', '0')
await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
expect(await screen.findByText('记录 25')).toBeInTheDocument()
expect(screen.getByLabelText('整理历史无限列表')).toHaveAttribute('data-margin', '280')
await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
expect(await screen.findByText('追加 4')).toBeInTheDocument()
expect(document.querySelectorAll('[data-mobile-key="25"]')).toHaveLength(1)
@@ -467,7 +484,7 @@ describe('TransferHistoryView', () => {
await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
await waitFor(() => expect(screen.getByRole('status', { name: '整理历史无限列表状态' })).toHaveTextContent('error'))
await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
await fireEvent.click(screen.getByRole('button', { name: '重试' }))
expect(await screen.findByText('重试结果')).toBeInTheDocument()
})

View File

@@ -309,6 +309,7 @@ async function fetchData({ done }: { done: (status: 'empty' | 'error' | 'ok') =>
mode="intersect"
side="end"
:items="dataList"
:margin="dataList.length > 0 ? 480 : 0"
class="overflow-visible px-2"
@load="fetchData"
:key="currentKey"

View File

@@ -269,6 +269,7 @@ function removeData(id: number) {
mode="intersect"
side="end"
:items="dataList"
:margin="dataList.length > 0 ? 480 : 0"
class="overflow-visible px-2"
@load="fetchData"
:key="currentKey"

View File

@@ -12,14 +12,22 @@ import { defineComponent, h, onMounted, ref, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type InfiniteScrollStatus = 'empty' | 'error' | 'ok'
const initialLoadMargins: number[] = []
const InfiniteScrollStub = defineComponent({
name: 'VInfiniteScroll',
props: {
margin: {
type: Number,
required: true,
},
},
emits: ['load'],
setup(_props, { emit, slots }) {
setup(props, { emit, slots }) {
const status = ref<'empty' | 'error' | 'idle' | 'loading'>('idle')
function load() {
initialLoadMargins.push(props.margin)
status.value = 'loading'
emit('load', {
done(nextStatus: InfiniteScrollStatus) {
@@ -31,7 +39,7 @@ const InfiniteScrollStub = defineComponent({
onMounted(load)
return () =>
h('section', { 'aria-label': '热门订阅无限列表' }, [
h('section', { 'aria-label': '热门订阅无限列表', 'data-margin': String(props.margin) }, [
h('output', { 'aria-label': '热门订阅无限列表状态' }, status.value),
status.value === 'loading' ? slots.loading?.({}) : null,
status.value === 'error'
@@ -142,6 +150,7 @@ async function renderPopular(type: '电影' | '电视剧' = '电影') {
describe('SubscribePopularView', () => {
beforeEach(() => {
initialLoadMargins.length = 0
vi.spyOn(console, 'error').mockImplementation(() => {})
setHasScroll(true)
})
@@ -160,6 +169,8 @@ describe('SubscribePopularView', () => {
await renderPopular(type)
expect(await screen.findByText(type === '电影' ? '默认热门电影' : '默认热门剧集')).toBeInTheDocument()
expect(screen.getByLabelText('热门订阅无限列表')).toHaveAttribute('data-margin', '480')
expect(initialLoadMargins[0]).toBe(0)
expect(screen.getByText(type === '电影' ? '18' : '27')).toBeInTheDocument()
expect(requests).toHaveLength(1)
expect(requests[0].searchParams.get('stype')).toBe(type)

View File

@@ -12,14 +12,22 @@ import { defineComponent, h, onMounted, ref, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type InfiniteScrollStatus = 'empty' | 'error' | 'ok'
const initialLoadMargins: number[] = []
const InfiniteScrollStub = defineComponent({
name: 'VInfiniteScroll',
props: {
margin: {
type: Number,
required: true,
},
},
emits: ['load'],
setup(_props, { emit, slots }) {
setup(props, { emit, slots }) {
const status = ref<'empty' | 'error' | 'idle' | 'loading'>('idle')
function load() {
initialLoadMargins.push(props.margin)
status.value = 'loading'
emit('load', {
done(nextStatus: InfiniteScrollStatus) {
@@ -31,7 +39,7 @@ const InfiniteScrollStub = defineComponent({
onMounted(load)
return () =>
h('section', { 'aria-label': '订阅分享无限列表' }, [
h('section', { 'aria-label': '订阅分享无限列表', 'data-margin': String(props.margin) }, [
h('output', { 'aria-label': '订阅分享无限列表状态' }, status.value),
status.value === 'loading' ? slots.loading?.({}) : null,
status.value === 'error'
@@ -162,6 +170,7 @@ async function renderShare(keyword = '') {
describe('SubscribeShareView', () => {
beforeEach(() => {
initialLoadMargins.length = 0
vi.spyOn(console, 'error').mockImplementation(() => {})
setHasScroll(true)
})
@@ -178,6 +187,8 @@ describe('SubscribeShareView', () => {
await renderShare()
expect(await screen.findByText('默认分享卡片')).toBeInTheDocument()
expect(screen.getByLabelText('订阅分享无限列表')).toHaveAttribute('data-margin', '480')
expect(initialLoadMargins[0]).toBe(0)
expect(requests).toHaveLength(1)
expect(requests[0].searchParams.get('page')).toBe('1')
expect(requests[0].searchParams.get('count')).toBe('30')
@@ -262,9 +273,9 @@ describe('SubscribeShareView', () => {
const first = createSubscribeShare({ share_title: '未满屏分享第一页' })
const second = createSubscribeShare({ share_title: '未满屏分享第二页' })
const requestedPages: string[] = []
const scrollHeight = vi.spyOn(document.body, 'scrollHeight', 'get').mockImplementation(() =>
requestedPages.length >= 2 ? 900 : 500,
)
const scrollHeight = vi
.spyOn(document.body, 'scrollHeight', 'get')
.mockImplementation(() => (requestedPages.length >= 2 ? 900 : 500))
server.use(
http.get(subscribeApiUrls.shares, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''