无限滚动为什么会跳到新底部:一次滚动快照恢复的复盘
无限滚动最容易让人误判的一类问题,是「数据明明追加了,但页面看起来像没有追加」。
这次遇到的现象就是这样:第一页正常展示,滚到底触发第二页请求,接口返回了新数据,合并后的列表数量也对。但页面在第二页回来的一瞬间猛地往下跳,直接贴到新的底部。视觉上,用户会以为第二页没有插到当前视口下面,而是列表直接进入了「没有更多」。
最后真正的问题不在接口、不在去重,也不在虚拟列表本身,而是滚动快照恢复被分页追加误触发了。
问题代码先长这样
出问题的代码可以简化成下面这种形态:
const cacheKey = ref('feed:all')
const items = ref<Array<{ id: string; title: string }>>([])
const scrollRoot = ref<HTMLElement | null>(null)
const scrollSnapshot = ref<ScrollSnapshot | null>(null)
function restoreScrollFromSnapshot() {
const scroller = scrollRoot.value
const snapshot = scrollSnapshot.value
if (!scroller || !snapshot) return
requestAnimationFrame(() => {
const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight)
const restoredTop =
snapshot.maxScrollTop > 0 && maxScrollTop > 0
? Math.round(maxScrollTop * (snapshot.scrollTop / snapshot.maxScrollTop))
: snapshot.scrollTop
scroller.scrollTop = Math.min(maxScrollTop, Math.max(0, restoredTop))
})
}
watch([cacheKey, () => items.value.length], () => {
restoreScrollFromSnapshot()
})这段代码看起来很合理:列表 key 变了要恢复,列表长度变了也可能说明缓存数据已经回来了,也要恢复。
真正的问题就藏在 items.value.length 里。它既可能来自「切回历史列表后,缓存数据重新渲染」,也可能来自「当前会话里滚到底,下一页刚追加回来」。这两个动作都改变列表长度,但语义完全不同。
完整的问题示例在源码包里也保留了一份:
https://shengsheng.fun/files/infinite-scroll-restore-vs-live-append/kits/restorable-infinite-scroll/examples/broken-feed-section.vue为什么第二页回来会跳到底
这次的时间线是这样的:
首次进入列表,没有历史滚动快照
↓
用户滚到第一页底部,滚动监听保存了当前会话快照
↓
第二页数据回来,items.length 变化,scrollHeight 变大
↓
watch([cacheKey, items.length]) 再次执行恢复逻辑
↓
恢复逻辑拿到“刚刚滚到底保存的快照”
↓
旧底部位置按新的 scrollHeight 比例换算
↓
scrollTop 被写到新底部所以用户看到的是:接口明明追加了第二页,但页面直接跳到更下面,好像新增内容被吞了。
这里最容易误判的点是,scrollSnapshot 本身没有错。用户滚动时保存快照是必要的,因为离开列表再回来确实要恢复位置。错的是恢复逻辑没有区分「历史恢复」和「当前会话分页追加」。
先别怪接口和虚拟列表
无限滚动出问题时,第一步应该先确认数据链路。
这次两个分页接口返回合并后,列表总数是对的;去重逻辑没有把第二页整页吃掉。页面「看起来没有翻页」的直接原因,是滚动位置在数据进来后被写坏了。
接着再看虚拟列表。业务里已经给虚拟列表配置过关闭尺寸补偿:
const rowVirtualizer = useVirtualizer({
count: rows.value.length,
getScrollElement: () => scrollElement.value,
estimateSize: () => rowHeight.value,
gap: rowGap,
shouldAdjustScrollPositionOnItemSizeChange: () => false,
})这类配置解决的是另一件事:虚拟列表在测量 item 真实尺寸和预估尺寸不一致时,是否主动写回 scrollTop 以保持锚点位置。它能避免「用户停下滚轮后,列表因为重新测量继续动一下」。
但它解决不了业务自己的滚动恢复。虚拟列表库不知道当前是「用户刚进入页面,需要恢复历史位置」,还是「用户正在当前页面里加载下一页,新内容应该接在下面」。如果业务 watcher 自己在列表长度变化时恢复快照,库不会替业务拦住。
两种滚动语义必须拆开
这里混在一起的是两种动作。
滚动恢复发生在用户离开列表、切换 tab、进入详情页再返回,或 KeepAlive 组件重新激活时。它的目标是让用户回到离开前的位置。
分页追加发生在当前列表仍然打开、用户滚到底、下一页数据回来时。它的目标是保持当前视口不动,把新内容自然接在下面。
它们都可能发生在同一个滚动容器上,也都可能依赖同一份 scrollTop 快照。但触发窗口不同,写 scrollTop 的时机也应该不同。
普通 feed 流的分页追加语义很简单:保持当前 scrollTop。用户如果想看新内容,继续往下滚就行。
只有聊天窗口、日志窗口、直播消息这类明确需要「贴底」的列表,才应该在追加后主动滚到底。那也应该写成显式业务状态:
const shouldStickToBottom = computed(() => userIsNearBottom.value && listMode.value === 'chat')
watch(messages, () => {
if (!shouldStickToBottom.value) return
scrollToBottom()
})不要把「贴底」借用页面恢复逻辑来做。滚动恢复是历史状态,贴底是当前交互状态;两者共用实现时,后续很难看出页面跳动到底是哪个行为触发的。
关键修法:没有快照也要关闸
解决这类问题的关键,不是简单删掉快照,也不是停止保存滚动位置。滚动保存仍然有价值。
真正要改的是:把恢复做成每个 cacheKey、每次激活窗口里的一次性动作。更反直觉的一点是,即使没有历史快照,也要标记这个 key 已经处理过。
const restoreGate = createScrollRestoreGate()
function restoreScrollFromSnapshot(scroller: HTMLElement | null) {
const key = cacheKey.value
if (!scroller || restoreGate.hasHandled(key)) return
const snapshot = snapshotStore.read(key)
if (!snapshot) {
restoreGate.markHandled(key)
return
}
restoreGate.markHandled(key)
requestAnimationFrame(() => {
scroller.scrollTop = getRestoredScrollTop(snapshot, {
scrollTop: scroller.scrollTop,
scrollHeight: scroller.scrollHeight,
clientHeight: scroller.clientHeight,
})
})
}「没有快照也标记已处理」看起来像多余逻辑,实际是在切断当前会话内的错误复用。
首次进入时没有历史快照,说明这不是一次恢复场景。之后用户滚动保存下来的新快照,只应该服务「离开后再回来」。如果不先把这个 key 标记掉,分页追加后 watcher 还会把这份当前会话快照当历史快照使用。
真正离开再返回时,再重置这个闸门:
onDeactivated(() => {
saveScrollSnapshot()
restoreGate.reset()
})
onActivated(() => {
restoreGate.reset()
restoreScrollFromSnapshot(scrollRoot.value)
})这样两个行为就分开了:
- 当前页面内分页追加,不再触发历史恢复。
- 离开页面后返回,仍然可以恢复历史位置。
完整方案拆成四层
这次我把可复用方案拆成四层。
第一层是 ScrollSnapshotStore。它只做一件事:按 cacheKey 读写快照。你可以用内存 Map、Pinia、Redux、URL state 或任意项目已有缓存,只要实现 read(cacheKey) 和 write(cacheKey, snapshot)。
第二层是恢复闸门。它记录每个 cacheKey 在当前激活窗口里是否已经处理过。这里必须覆盖「没有历史快照」的场景,否则首次进入后保存下来的当前会话快照仍会被分页追加误用。
第三层是触底加载。它用原生 IntersectionObserver 观察底部 sentinel,同时保留 scroll fallback。虚拟列表、短列表、首帧 sentinel 还是 null、数据回来后 sentinel 仍然停在视口附近,这些时序都不能只靠一次 observer 回调。
第四层是滚动根样式:
.feed-scroll {
overflow-y: auto;
overflow-anchor: none;
}overflow-anchor: none 用来避免浏览器原生滚动锚点和业务自己的恢复逻辑抢同一个 scrollTop。它不是这次 bug 的根因,但在虚拟列表和无限滚动混用时,提前关掉能少一条干扰链路。
可复制源码
下面这个源码包是脱敏后的完整版本,包含问题示例、可复制 composable、核心测试和接入示例。
请接入滚动快照恢复工具包。工具包根路径:https://shengsheng.fun/files/infinite-scroll-restore-vs-live-append/kits/restorable-infinite-scroll/
先读取 README.md、MANIFEST.json、FILES.json、CHANGELOG.md、AGENT_PROMPT.md;再按 FILES.json 读取 copy/app/composables/、copy/tests/ 和 examples/restorable-feed-section.vue,迁移到当前无限滚动列表。接入时重点看三个文件:
copy/app/composables/useRestorableInfiniteScroll/index.ts:完整 composable。copy/app/composables/useRestorableInfiniteScroll/core.ts:不依赖 Vue 的核心逻辑。copy/tests/unit/restorable-infinite-scroll-core.test.ts:守住这次 bug 的回归测试。
测试要守住错误时序
这类问题不能只测「调用了 loadMore」。真正要守的是错误时序:
- 首次进入列表时没有历史快照。
- 用户滚到底,当前会话保存了新的快照。
- 下一页数据追加,列表长度和高度变化。
- 断言
scrollTop没有被恢复逻辑改到新底部。
源码包里的测试就是按这个顺序写的:
it('首次进入没有历史快照时也标记 cacheKey,后续分页追加不会恢复到新底部', () => {
const gate = createScrollRestoreGate()
const scroller = createScroller({
scrollTop: 760,
scrollHeight: 1280,
clientHeight: 520,
})
restoreScrollSnapshotOnce({
cacheKey: 'feed:all',
gate,
scroller,
snapshot: null,
options: { mode: 'proportional' },
})
const currentSessionSnapshot = normalizeScrollSnapshot({
scrollTop: 760,
scrollHeight: 1280,
clientHeight: 520,
})
scroller.scrollHeight = 2600
const appendResult = restoreScrollSnapshotOnce({
cacheKey: 'feed:all',
gate,
scroller,
snapshot: currentSessionSnapshot,
options: { mode: 'proportional' },
})
expect(appendResult.reason).toBe('already-handled')
expect(scroller.scrollTop).toBe(760)
})还要补一类相反测试:离开页面后返回时,历史快照仍然能恢复。前者守住「当前会话 live append 不恢复」,后者守住「真正重新激活要恢复」。只测其中一个,都可能把另一个行为改坏。
滚动条抖动是另一条链路
同一个页面如果还接了自定义滚动条,滚动时右侧 thumb 抖动通常是另一条链路。
虚拟列表滚动过程中会频繁复用可见行节点、更新 transform、替换子节点。内容总高度不一定变,但 MutationObserver 会看到很多 DOM 变化。如果自定义滚动条库对每次行节点变化都重新计算,桌面端 thumb 就可能轻微跳动。
这类问题不应该靠节流蒙过去。更合理的边界是让虚拟列表 owner 明确告诉滚动条:哪些 mutation 只是虚拟行内部变化,不代表滚动尺寸变化,可以忽略;承载总高度的 virtualizer 自身高度变化则仍然应该触发滚动条更新。
判断口径可以拆成两句:
- 行节点 mount / unmount、transform 变化、行内部内容更新,通常不需要让滚动条重算。
- virtualizer 总高度、滚动容器尺寸、真实列表长度导致的高度变化,必须让滚动条重算。
这也是为什么滚动条问题和分页跳底问题要分开看。一个是滚动位置被业务恢复逻辑写错,另一个是滚动条视觉层对虚拟列表 mutation 太敏感。它们都发生在滚动时,但根因不是同一个。
总结
- 无限滚动排障先确认数据是否真的追加,避免把 UI 跳动误判成去重或接口问题。
- 虚拟列表库负责 range 和尺寸测量,不负责区分业务里的「历史恢复」和「当前追加」。
- 滚动恢复应该是每个
cacheKey/ 激活窗口的一次性动作;没有历史快照也要标记为已处理。 - 普通 feed 的分页追加应该保持当前绝对
scrollTop;聊天贴底是另一种显式业务行为。 - 触底加载适合抽成 composable,但分页游标、错误态、权限限制和请求去重仍归业务数据层。
- 自定义滚动条抖动要从 MutationObserver 和虚拟行复用边界排查,不要和分页跳底混成一个问题。
滚动问题最麻烦的地方,是几套机制会同时读写同一个容器:浏览器原生滚动、虚拟列表、无限滚动、页面缓存、自定义滚动条。排查时先把它们按职责拆开,问题通常会比截图里看起来简单很多。