This commit is contained in:
truewhile
2026-10-31 12:48:50 +08:00
parent d91ded9d55
commit 34e61a5994
4 changed files with 226 additions and 39 deletions
+59
View File
@@ -1,6 +1,7 @@
import {
isJumpIntentKey,
isScrollIntentKey,
shouldReapplyGuardedScroll,
shouldPersistClampedSample,
shouldPersistScrollSample,
shouldRememberScroll,
@@ -100,4 +101,62 @@ check('home allows a long jump', isJumpIntentKey('Home'))
check('arrow key keeps smooth-only intent', !isJumpIntentKey('ArrowDown'))
check('space keeps smooth-only intent', !isJumpIntentKey(' '))
// 恢复完成后的看守期:列表重排(普通网格换成虚拟列表)会把容器高度压塌,
// scrollTop 被浏览器夹小;没有用户滚动意图时要把记忆位置贴回去。
check(
'collapsed position inside the guard window is re-applied',
shouldReapplyGuardedScroll({
current: 0,
target: 2600,
maxScroll: 30000,
userDriven: false,
now: 1000,
guardUntil: 3000,
}),
)
check(
'user scroll during the guard window wins',
!shouldReapplyGuardedScroll({
current: 0,
target: 2600,
maxScroll: 30000,
userDriven: true,
now: 1000,
guardUntil: 3000,
}),
)
check(
'no re-apply after the guard window',
!shouldReapplyGuardedScroll({
current: 0,
target: 2600,
maxScroll: 30000,
userDriven: false,
now: 4000,
guardUntil: 3000,
}),
)
check(
'no re-apply while the content is still too short',
!shouldReapplyGuardedScroll({
current: 0,
target: 0,
maxScroll: 0,
userDriven: false,
now: 1000,
guardUntil: 3000,
}),
)
check(
'position already at the target is left alone',
!shouldReapplyGuardedScroll({
current: 2600,
target: 2600,
maxScroll: 30000,
userDriven: false,
now: 1000,
guardUntil: 3000,
}),
)
console.log('useScrollMemory.test.ts ok')
+122 -20
View File
@@ -109,6 +109,35 @@ export function shouldPersistClampedSample(input: {
return true
}
/**
* 恢复完成后的看守时长:列表在这一小段时间里仍可能重排(分页补页让普通网格
* 切换成虚拟列表、海报量高……),内容高度瞬间被压塌时浏览器会把 scrollTop
* 夹小,表现就是“返回列表后跳到顶部”。这段时间内没有用户滚动意图就把记忆
* 位置贴回去。
*/
const RESTORE_GUARD_MS = 3000
/**
* 看守期里哪些 scrollTop 跌落要贴回记忆位置。
*
* 只处理“内容还够长、用户没有滚动意图、位置却掉到记忆位置上方”的跌落;
* 用户自己滚上去(有滚动意图)不干预,内容还没长回原来的高度也不强行回填。
*/
export function shouldReapplyGuardedScroll(input: {
current: number
target: number
maxScroll: number
userDriven: boolean
now: number
guardUntil: number
}): boolean {
const { current, target, maxScroll, userDriven, now, guardUntil } = input
if (now > guardUntil) return false
if (userDriven) return false
if (maxScroll <= 0 || target <= 0) return false
return current < target - 1
}
/**
* 记住列表页的滚动位置。页面内容会异步长高,因此恢复期间会监听内容高度,
* 直到目标位置可达;期间用户主动滚动会立即接管,避免和恢复逻辑抢滚动条。
@@ -141,8 +170,15 @@ export function useScrollMemory(routeKey: string, userKey = 'anonymous'): void {
let restoreTimer: number | null = null
let restoreFrame = 0
let restorePumpUntil = 0
// 恢复完成后的看守截止时间:见 RESTORE_GUARD_MS。
let guardUntil = 0
// 看守期里被“用户正在滚动”挡下的那次回填,等滚动意图过期后补做一次。
let guardRetryTimer: number | null = null
let lastSaved = saved
let lastHeight = el.scrollHeight
// 内容容器(`<main>` 里那层宽度约束 div):分页补页、列表切换渲染方式都会
// 改变它的高度,恢复回填与看守都靠它驱动。
const content = el.firstElementChild instanceof HTMLElement ? el.firstElementChild : null
// 最近一次“用户主动滚动”的时间戳与类型(滚轮 / 触摸 / 翻页键 / 拖滚动条)。
// 只有用户自己滚出来的位置才写回存储:恢复逻辑、虚拟列表挂载量高、浏览器
// 钳制造成的 scrollTop 变化都不代表用户想要的位置,写进去就会把记忆抹掉。
@@ -183,25 +219,91 @@ export function useScrollMemory(routeKey: string, userKey = 'anonymous'): void {
window.clearTimeout(restoreTimer)
restoreTimer = null
}
if (guardRetryTimer != null) {
window.clearTimeout(guardRetryTimer)
guardRetryTimer = null
}
observer?.disconnect()
observer = null
guardUntil = 0
}
/** 恢复完成后继续看守一小段时间,见 RESTORE_GUARD_MS。 */
const startGuard = (target: number) => {
if (target <= 0) return
guardUntil = performance.now() + RESTORE_GUARD_MS
if (restoreFrame) {
window.cancelAnimationFrame(restoreFrame)
restoreFrame = 0
}
if (restoreTimer != null) window.clearTimeout(restoreTimer)
restoreTimer = window.setTimeout(() => {
restoreTimer = null
stopRestore()
}, RESTORE_GUARD_MS)
}
/**
* 看守期里的塌陷回填:列表重排(普通网格换成虚拟列表、海报重新量高)会让
* 内容瞬间变矮,浏览器随之把 scrollTop 夹小,表现就是“返回列表后跳到顶部”。
* 用户没有滚动意图时把记忆位置贴回去。
*/
const reapplyGuardedScroll = () => {
if (restoring) return
const max = currentMaxScroll()
const target = Math.min(lastSaved, max)
if (
shouldReapplyGuardedScroll({
current: el.scrollTop,
target,
maxScroll: max,
userDriven: userScrolling(),
now: performance.now(),
guardUntil,
})
) {
el.scrollTop = target
return
}
// 这次塌陷正好撞上用户还在滚动:等滚动意图过期后再补做一次,别把它漏掉
// (否则用户滚完停手,列表就停在被夹出来的顶部不动了)。
if (userScrolling() && performance.now() <= guardUntil && guardRetryTimer == null) {
guardRetryTimer = window.setTimeout(() => {
guardRetryTimer = null
reapplyGuardedScroll()
}, USER_SCROLL_INTENT_TTL_MS)
}
}
const onContentSizeChange = () => {
if (restoring) {
tryRestore()
return
}
reapplyGuardedScroll()
}
const finishRestore = () => {
if (!restoring) return
restoring = false
stopRestore()
const current = Math.round(el.scrollTop)
if (!sampleIsTrustworthy(current)) {
// 目标一直不可达(内容仍比记忆位置矮 / 还在加载):保留原记忆值,
// 不要把钳制出来的位置写回去。
// 恢复了半天位置却掉到记忆位置上方很远,而且不是用户自己滚上去的:
// 这是浏览器把 scrollTop 夹出来的值(内容被压塌、还在加载占位)。
// 记忆值必须保留,否则下次进来就真的从顶部开始了。
const droppedFar =
saved - current > maxSmoothJumpUp(el.clientHeight) && !userScrolling()
if (droppedFar || !sampleIsTrustworthy(current)) {
stopRestore()
return
}
adoptScrollPosition(current)
startGuard(current)
}
const tryRestore = () => {
if (!restoring) return
// 用户正在滚动:本帧不要回填,把控制权交给他(交给 saveNow 的接管分支)。
if (userScrolling()) return
const max = currentMaxScroll()
const target = Math.min(saved, max)
if (target > 0 && Math.abs(el.scrollTop - target) > 1) {
@@ -230,19 +332,20 @@ export function useScrollMemory(routeKey: string, userKey = 'anonymous'): void {
// 内容还没长回来时读到的 scrollTop 不是用户选的位置,既不能写存储,
// 也不能当成“用户接管”。
if (!sampleIsTrustworthy(current)) return
// 一瞬间大幅向上跳(恢复回填、虚拟列表挂载量高、浏览器钳制)只有按键翻页 /
// 拖滚动条这类意图才可能是用户行为,否则直接丢弃。
const upJump = lastSaved > current ? lastSaved - current : 0
if (upJump > maxSmoothJumpUp(el.clientHeight) && !(userDriven && userScrollAllowsJump)) {
if (restoring) {
// 恢复途中用户自己滚了:一律以用户为准(哪怕一次跳得很远,也是他真实的
// 滚轮 / 触摸 / 翻页操作)。位置仍要看住:列表随后切换渲染方式时容器
// 高度还是会塌,塌了要贴回“用户自己选的位置”。
if (!userDriven || Math.abs(current - saved) <= 1) return
restoring = false
adoptScrollPosition(current)
startGuard(current)
return
}
if (restoring) {
if (!userDriven || Math.abs(current - saved) <= 1) return
// 恢复途中用户真的滚到了别处:交还控制权并记录这个位置,避免恢复
// 逻辑继续和用户抢滚动条。
restoring = false
stopRestore()
adoptScrollPosition(current)
// 非恢复状态下,一瞬间大幅向上跳(虚拟列表挂载量高、浏览器钳制)只有按键
// 翻页 / 拖滚动条这类意图才可能是用户行为,否则直接丢弃。
const upJump = lastSaved > current ? lastSaved - current : 0
if (upJump > maxSmoothJumpUp(el.clientHeight) && !(userDriven && userScrollAllowsJump)) {
return
}
// 非用户滚动(恢复逻辑回填、虚拟列表挂载/量高、浏览器钳制)不写存储。
@@ -276,13 +379,12 @@ export function useScrollMemory(routeKey: string, userKey = 'anonymous'): void {
if (saved <= 0) {
el.scrollTop = 0
} else {
if (typeof ResizeObserver !== 'undefined' && content) {
observer = new ResizeObserver(onContentSizeChange)
observer.observe(content)
}
tryRestore()
if (restoring) {
const content = el.firstElementChild
if (typeof ResizeObserver !== 'undefined' && content instanceof HTMLElement) {
observer = new ResizeObserver(() => tryRestore())
observer.observe(content)
}
restorePumpUntil = performance.now() + 1000
restoreFrame = window.requestAnimationFrame(pumpRestore)
restoreTimer = window.setTimeout(finishRestore, 10000)
+44 -19
View File
@@ -8,10 +8,15 @@ import type { Media } from '../types'
import type { SeriesCard } from '../utils/groupSeries'
// 超过该数量后切换为虚拟滚动:DOM 只挂载视口内的卡片,大库不再线性劣化。
// 判定必须用“库的总量”而不是“已加载条数”:分页补页会把已加载条数推过阈值,
// 若此时才切换渲染方式,整块网格的 DOM 子树会被替换、容器高度瞬间塌陷,
// 浏览器随之把 scrollTop 夹回顶部,滚动记忆恢复得再好也会当场失效。
const VIRTUALIZE_THRESHOLD = 200
type LibraryMediaSectionsProps = {
isSeries: boolean
/** 当前列表的总条数(媒体库总量),用于稳定地决定是否走虚拟滚动。 */
total: number
items: Media[]
seriesCards: SeriesCard[]
selectedSeries: SeriesCard | null
@@ -25,6 +30,7 @@ type LibraryMediaSectionsProps = {
export function LibraryMediaSections({
isSeries,
total,
items,
seriesCards,
selectedSeries,
@@ -35,15 +41,22 @@ export function LibraryMediaSections({
cardActions,
onSeriesClick,
}: LibraryMediaSectionsProps) {
// 渲染方式在整次浏览里保持不变:总量超过阈值就一开始就用虚拟列表。
const virtualizeByTotal = total > VIRTUALIZE_THRESHOLD
return (
<>
{!isSeries && items.length > 0 && (
<MediaGrid count={items.length} renderItem={(index) => {
const media = items[index]
// renderActions 传函数引用:MediaCard 重渲染时才构建操作按钮,
// 配合 memo,父级无关状态变化不再级联重渲染全部卡片。
return <MediaCard key={media.id} media={media} renderActions={cardActions} />
}} />
<MediaGrid
count={items.length}
virtualize={virtualizeByTotal}
renderItem={(index) => {
const media = items[index]
// renderActions 传函数引用:MediaCard 重渲染时才构建操作按钮,
// 配合 memo,父级无关状态变化不再级联重渲染全部卡片。
return <MediaCard key={media.id} media={media} renderActions={cardActions} />
}}
/>
)}
{!isSeries && items.length === 0 && (
@@ -51,17 +64,21 @@ export function LibraryMediaSections({
)}
{isSeries && seriesCards.length > 0 && !selectedSeries && (
<MediaGrid count={seriesCards.length} renderItem={(index) => {
const series = seriesCards[index]
return (
<SeriesCardItem
key={series.key}
series={series}
cardActions={cardActions}
onSeriesClick={onSeriesClick}
/>
)
}} />
<MediaGrid
count={seriesCards.length}
virtualize={virtualizeByTotal}
renderItem={(index) => {
const series = seriesCards[index]
return (
<SeriesCardItem
key={series.key}
series={series}
cardActions={cardActions}
onSeriesClick={onSeriesClick}
/>
)
}}
/>
)}
{isSeries && seriesCards.length === 0 && !loading && (
@@ -94,8 +111,16 @@ const SeriesCardItem = memo(function SeriesCardItem({
)
})
function MediaGrid({ count, renderItem }: { count: number; renderItem: (index: number) => ReactNode }) {
if (count <= VIRTUALIZE_THRESHOLD) {
function MediaGrid({
count,
virtualize,
renderItem,
}: {
count: number
virtualize: boolean
renderItem: (index: number) => ReactNode
}) {
if (!virtualize) {
return <div className={MEDIA_GRID_CLASS}>{Array.from({ length: count }, (_, index) => renderItem(index))}</div>
}
return <VirtualMediaGrid totalCount={count} renderItem={renderItem} />
+1
View File
@@ -332,6 +332,7 @@ export function LibraryPage() {
<LibraryMediaSections
isSeries={isSeries}
total={total}
items={displayedItems}
seriesCards={displayedSeriesCards}
selectedSeries={selectedSeries}