perf(listings): 优化从详情页返回首页的滚动恢复体验,消除顶部闪跳

This commit is contained in:
yml2213
2026-09-07 10:12:41 +08:00
parent 23883d4be5
commit 41f70470ec
5 changed files with 228 additions and 43 deletions
@@ -122,14 +122,43 @@ export function useListingQuery(
}
)
function restoreSnapshot(snapshot: {
listings: Listing[]
totalListings: number
zoneCounts: Record<string, number>
currentPage: number
hasMoreListings: boolean
}) {
listings.value = snapshot.listings.slice()
totalListings.value = snapshot.totalListings
zoneCounts.value = { ...snapshot.zoneCounts }
currentPage.value = snapshot.currentPage
hasMoreListings.value = snapshot.hasMoreListings
loading.value = false
loadingMore.value = false
}
function getSnapshot() {
return {
listings: listings.value.slice(),
totalListings: totalListings.value,
zoneCounts: { ...zoneCounts.value },
currentPage: currentPage.value,
hasMoreListings: hasMoreListings.value,
}
}
return {
loading,
loadingMore,
listings,
totalListings,
zoneCounts,
currentPage,
hasMoreListings,
loadListingsPage,
zoneCount,
restoreSnapshot,
getSnapshot,
}
}
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { RouterLink, onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { usePcHomeCacheStore } from '@/stores/pcHomeCache'
import {
defaultAWRecycleConfig,
defaultCooperationFeedbackConfig,
@@ -83,6 +84,8 @@ const {
hasMoreListings,
loadListingsPage,
zoneCount,
restoreSnapshot,
getSnapshot,
} = useListingQuery(filters, sortBy, activeZone)
watch(
@@ -169,6 +172,7 @@ async function loadHome() {
publishOptions.value = emptyListingPublishOptions
} finally {
loading.value = false
saveToCache()
restorePcHomeScroll()
}
}
@@ -290,65 +294,118 @@ function buildDesktopHomeQueryValues(): Record<string, HomeQueryValue> {
}
}
// ========== PC 首页滚动位置持久化 ==========
// 详情页返回/浏览器后退时,首页列表异步加载导致页面高度不足,
// vue-router 的 savedPosition 恢复会被浏览器钳制到错误位置,
// 因此按「筛选签名」显式保存/恢复滚动位置,确保回到上次筛选的位置。
// ========== PC 首页状态与滚动持久化 ==========
// 详情页返回/浏览器后退时,利用 Pinia 缓存同步恢复全部已加载列表与高度,
// 使首帧渲染即可完成完整布局,结合 instant 滚动定位彻底解决「从顶部闪跳」问题。
const pcHomeScrollStorageKey = 'hfb.home.scroll.desktop'
const pcHomeCache = usePcHomeCacheStore()
function restoreFromCache(): boolean {
const signature = desktopHomeStateSignature()
if (!pcHomeCache.matchList(signature)) return false
restoreSnapshot({
listings: pcHomeCache.listings,
totalListings: pcHomeCache.totalListings,
zoneCounts: pcHomeCache.zoneCounts,
currentPage: pcHomeCache.currentPage,
hasMoreListings: pcHomeCache.hasMoreListings,
})
if (pcHomeCache.homeConfigLoaded) {
announcements.value = pcHomeCache.announcements.slice()
banners.value = pcHomeCache.banners.map(slide => ({ ...slide }))
awRecycle.value = { ...pcHomeCache.awRecycle }
cooperationFeedback.value = { ...pcHomeCache.cooperationFeedback }
publishOptions.value = pcHomeCache.publishOptions
}
return true
}
function saveToCache() {
const snapshot = getSnapshot()
if (snapshot.listings.length === 0) return
pcHomeCache.saveList({
signature: desktopHomeStateSignature(),
listings: snapshot.listings,
totalListings: snapshot.totalListings,
zoneCounts: snapshot.zoneCounts,
currentPage: snapshot.currentPage,
hasMoreListings: snapshot.hasMoreListings,
})
pcHomeCache.saveHomeConfig({
announcements: announcements.value,
banners: banners.value,
awRecycle: awRecycle.value,
cooperationFeedback: cooperationFeedback.value,
publishOptions: publishOptions.value,
})
pcHomeCache.saveScrollTop(window.scrollY)
}
function savePcHomeScroll() {
saveToCache()
try {
window.sessionStorage.setItem(
pcHomeScrollStorageKey,
JSON.stringify({ signature: desktopHomeStateSignature(), top: window.scrollY })
)
} catch {
// sessionStorage 不可用时忽略,仅损失滚动恢复
// sessionStorage 不可用时忽略
}
}
function restorePcHomeScroll() {
let savedTop = 0
let savedSignature = ''
try {
const raw = window.sessionStorage.getItem(pcHomeScrollStorageKey)
if (raw) {
const parsed = JSON.parse(raw) as { signature?: string; top?: number }
if (typeof parsed.signature === 'string' && typeof parsed.top === 'number') {
savedSignature = parsed.signature
savedTop = parsed.top
let savedTop = pcHomeCache.scrollTop
let savedSignature = pcHomeCache.listSignature
if (savedTop <= 0) {
try {
const raw = window.sessionStorage.getItem(pcHomeScrollStorageKey)
if (raw) {
const parsed = JSON.parse(raw) as { signature?: string; top?: number }
if (typeof parsed.signature === 'string' && typeof parsed.top === 'number') {
savedSignature = parsed.signature
savedTop = parsed.top
}
}
}
} catch {
return
}
// 仅当返回时的筛选条件与离开时一致才恢复,避免把滚动带到别的筛选结果上
if (savedTop <= 0 || savedSignature !== desktopHomeStateSignature()) return
let attempts = 0
const apply = () => {
const maxTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight)
if (savedTop <= maxTop) {
window.scrollTo({ top: savedTop })
} catch {
return
}
// 首页无限滚动分页:已渲染高度不足时继续加载一页后重试,尽量恢复到位
if (attempts < 5 && hasMoreListings.value && !loadingMore.value) {
attempts += 1
void loadListingsPage(false).then(() => requestAnimationFrame(apply))
} else {
window.scrollTo({ top: maxTop })
}
}
if (savedTop <= 0 || savedSignature !== desktopHomeStateSignature()) return
const apply = () => {
window.scrollTo({ top: savedTop, behavior: 'instant' })
}
apply()
requestAnimationFrame(apply)
}
function handleScroll() {
if (window.scrollY > 0) {
pcHomeCache.saveScrollTop(window.scrollY)
}
}
onMounted(() => {
window.addEventListener('scroll', handleScroll, { passive: true })
if (restored) {
restorePcHomeScroll()
}
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', handleScroll)
})
onBeforeRouteLeave(() => {
savePcHomeScroll()
})
syncHomeQuery()
loadHome()
const restored = restoreFromCache()
if (!restored) {
loadHome()
}
</script>
<template>
@@ -368,13 +368,6 @@ function restoreScrollPosition() {
}
onMounted(() => {
const restoredList = restoreFromCache()
if (!restoredList) {
loadListings()
}
if (!restoreHomeConfigFromCache()) {
loadHomeConfig()
}
if (restoredList) {
restoreScrollPosition()
}
@@ -811,6 +804,15 @@ function getItemResourcePreview(item: Listing) {
}
syncMobileHomeQuery()
// 同步恢复列表和配置快照,确保首帧 DOM 即包含完整卡片列表,避免高度塌陷和滚动闪跳
const restoredList = restoreFromCache()
if (!restoredList) {
loadListings()
}
if (!restoreHomeConfigFromCache()) {
loadHomeConfig()
}
</script>
<template>
@@ -3,6 +3,7 @@ import { readError } from '@/shared/utils/error'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast, showDialog } from 'vant'
import { goBackOrHome } from '@/shared/utils/routerBack'
import { fetchListing, type Listing } from '@/features/listings/api/listings'
import { useListingCollections } from '@/features/listings/composables/useListingCollections'
@@ -387,13 +388,17 @@ function handleToggleFavorite() {
const added = toggleFavorite(listing.value)
showToast({ message: added ? '已收藏账号' : '已取消收藏', icon: added ? 'star' : 'success' })
}
function goBack() {
goBackOrHome(router)
}
</script>
<template>
<main class="mobile-detail">
<!-- 顶部导航 -->
<header class="page-header">
<button class="back-btn" @click="router.back()">
<button class="back-btn" @click="goBack">
<van-icon name="arrow-left" :size="20" />
</button>
<h1>账号详情</h1>