跳到正文

无限滚动为什么会跳到新底部:一次滚动快照恢复的复盘

这次无限滚动出了一个容易误判的问题:第一页正常展示,滚到底触发第二页请求,接口返回了新数据,合并后的列表数量也对。但页面在第二页回来的一瞬间猛地往下跳,直接贴到新的底部。视觉上,用户会以为第二页没有插到当前视口下面,而是列表直接进入了「没有更多」。

根因是分页追加误触发了滚动快照恢复,接口、去重和虚拟列表本身都没有造成这次跳动。

列表长度变化误触发恢复

出问题的代码可以简化成下面这种形态:

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

跳到底的真正链路

这次的时间线是这样的:

本例:分页追加怎样误触发旧快照恢复

所以用户看到的是:接口明明追加了第二页,但页面直接跳到更下面,好像新增内容被吞了。

这里最容易误判的点是,scrollSnapshot 本身没有错。用户滚动时保存快照是必要的,因为离开列表再回来确实要恢复位置。错的是恢复逻辑没有区分「历史恢复」和「当前会话分页追加」。

排查先从数据链路开始:两个分页接口返回合并后,列表总数是对的;去重逻辑没有删掉第二页整页数据。页面「看起来没有翻页」,实际是滚动位置在数据进来后被错误改写了。

接着再看虚拟列表。业务里已经给虚拟列表配置过关闭尺寸补偿:

const rowVirtualizer = useVirtualizer({
  count: rows.value.length,
  getScrollElement: () => scrollElement.value,
  estimateSize: () => rowHeight.value,
  gap: rowGap,
  shouldAdjustScrollPositionOnItemSizeChange: () => false,
})

这项配置控制虚拟列表的尺寸补偿。当 item 的真实尺寸与预估尺寸不一致时,虚拟列表可能主动写回 scrollTop 以保持锚点位置;关闭补偿能避免「用户停下滚轮后,列表因为重新测量继续动一下」。

但它解决不了业务自己的滚动恢复。虚拟列表库不知道当前是「用户刚进入页面,需要恢复历史位置」,还是「用户正在当前页面里加载下一页,新内容应该接在下面」。如果业务 watcher 自己在列表长度变化时恢复快照,库不会替业务拦住。

第一页底部快照的比例为一,追加后按新最大滚动距离恢复会跳到新底部

图中数值是教学假设。普通 feed 追加后保持原 scrollTop,新内容留在视口下方。

两种滚动语义必须拆开

滚动恢复发生在用户离开列表、切换 tab、进入详情页再返回,或 KeepAlive 组件重新激活时。它的目标是让用户回到离开前的位置。

分页追加发生在当前列表仍然打开、用户滚到底、下一页数据回来时。它的目标是保持当前视口不动,把新内容自然接在下面。

它们都可能发生在同一个滚动容器上,也都可能依赖同一份 scrollTop 快照。但触发窗口不同,写 scrollTop 的时机也应该不同。

普通 feed 流的分页追加语义很简单:保持当前 scrollTop。用户如果想看新内容,继续往下滚就行。

只有聊天窗口、日志窗口、直播消息这类明确需要「贴底」的列表,才应该在追加后主动滚到底。那也应该写成显式业务状态:

const shouldStickToBottom = computed(() => userIsNearBottom.value && listMode.value === 'chat')

watch(() => messages.value.length, () => {
  if (!shouldStickToBottom.value) return
  scrollToBottom()
}, { flush: 'post' })

这里的 userIsNearBottom 记录用户滚动时、追加前的贴底意图,不在追加后按新的列表高度重新推导;flush: 'post' 则让滚动发生在 DOM 更新以后。异步恢复的 RAF 还要在离开页面时取消,或在回调里核对当前激活窗口与容器。

不要把「贴底」借用页面恢复逻辑来做。滚动恢复是历史状态,贴底是当前交互状态;两者共用实现时,后续很难看出页面跳动到底是哪个行为触发的。

没有快照也要标记恢复已处理

快照继续用于离开列表后的恢复,但恢复只在每个 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 是这个滚动根的项目选择,用来避免原生锚点调整和业务恢复逻辑同时改变位置。它不是这次 bug 的根因,也不必给所有无限滚动列表默认加上:关闭后,上方内容插入或尺寸变化引起的位移,需要业务自己承担。

快照存储负责提供位置数据,恢复闸门决定何时允许写回 scrollTop,触底加载只发起下一页请求;滚动根样式则控制浏览器原生锚点是否参与位置调整。

回归需要同时验证两条相反路径:当前会话追加数据时不重新恢复,真正离开再激活时仍能恢复历史快照。完整内容见滚动恢复源码与回归弹窗。

自定义滚动条抖动要另起一条链路

同一个页面如果还接了自定义滚动条,右侧 thumb 抖动需要单独排查,不能直接归给滚动快照恢复。

虚拟列表滚动过程中会频繁复用可见行节点、更新 transform、替换子节点。内容总高度不一定变,但 MutationObserver 会看到很多 DOM 变化。如果自定义滚动条库对每次行节点变化都重新计算,桌面端 thumb 就可能轻微跳动。

先确认哪些 DOM 变化需要重算,再考虑是否节流。虚拟列表 owner 可以告诉滚动条:哪些 mutation 只是虚拟行内部变化,不影响滚动尺寸,可以忽略;承载总高度的 virtualizer 自身高度变化则仍应触发更新。

滚动条是否需要重算,取决于 mutation 有没有改变滚动尺寸:

  • 行节点 mount / unmount、transform 变化和行内部更新,只有确认没有改变滚动尺寸时才可忽略;可变高度内容仍需由测量 owner 通知更新。
  • virtualizer 总高度、滚动容器尺寸、真实列表长度导致的高度变化,必须让滚动条重算。

这也是为什么滚动条问题和分页跳底问题要分开看。一个是滚动位置被业务恢复逻辑写错,另一个是滚动条视觉层对虚拟列表 mutation 太敏感。它们都发生在滚动时,但根因不是同一个。

总结

这次第二页数据和合并数量都正确,跳到新底部是列表长度 watcher 误用了滚动恢复:当前会话在第一页底部保存的比例快照,被套到追加后的新高度上。普通 feed 的分页追加应保持当前 scrollTop,历史快照只在重新进入或激活列表时恢复;聊天等贴底场景则需显式记录追加前的贴底意图。

恢复闸门按 cacheKey 限制每个激活窗口只处理一次,即使没有历史快照也要标记已处理,避免随后保存的会话快照被误用。快照存储、恢复闸门和触底加载分别维护,离开时还要取消或校验待执行的 RAF。回归既要覆盖首次无快照后追加不跳底,也要确认离开再返回仍能恢复。

虚拟列表尺寸补偿、原生滚动锚点和自定义滚动条重算都有各自的影响范围,不能代替这层恢复语义。overflow-anchor: none 只是本例滚动根的选择;thumb 抖动仍需按 mutation 是否改变滚动尺寸另行判断。

滚动恢复源码与回归

源码包和回归测试

下面这个源码包是脱敏后的完整版本,包含问题示例、可复制 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 的回归测试。

源码包里最值得迁移的是测试时序。watcher 写法会随项目变化,但这类问题不能只测「调用了 loadMore」。测试要守住下面这条错误时序:

  1. 首次进入列表时没有历史快照。
  2. 用户滚到底,当前会话保存了新的快照。
  3. 下一页数据追加,列表长度和高度变化。
  4. 断言 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 不恢复」,后者守住「真正重新激活要恢复」。只测其中一个,都可能把另一个行为改坏。