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

无限滚动最容易让人误判的一类问题,是“数据明明追加了,但页面看起来像没有追加”。

这次遇到的现象就是这样:第一页正常展示,滚到底触发第二页请求,接口也返回了新数据,合并后列表数量也对。但页面在第二页回来的一瞬间猛地往下跳,直接贴到新的底部。视觉上,用户会以为第二页没有插到当前视口下面,而是列表直接进入了“没有更多”。

一开始很容易怀疑去重、分页游标或虚拟滚动库。最后真正的问题不在这些地方,而是滚动快照恢复被分页追加误触发了。

先排除数据和虚拟列表

无限滚动出问题时,第一步不要直接调 UI。先确认数据链路。

这次的两个接口返回合并后,列表总数是对的;去重逻辑没有把第二页整页吃掉。“看起来没有翻页”的直接原因,是滚动位置在数据进来后发生了跳变。

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

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

这类配置解决的是另一件事:虚拟列表在测量 item 真实尺寸和预估尺寸不一致时,是否主动写回 scrollTop 以保持锚点位置。它能避免“用户停下滚轮后,列表因为重新测量继续动一下”。

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

被混在一起的两种滚动语义

这次真正混在一起的是两种语义。

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

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

这两个动作都可能发生在同一个滚动容器上,也都可能依赖同一份 scrollTop 快照。但它们的触发条件完全不同。

问题代码的形态通常长这样:

watch([cacheKey, () => items.value.length], () => {
  restoreScrollFromSnapshot()
})

这段代码的问题是,items.length 变化既可能来自“切回历史列表后数据恢复”,也可能来自“当前会话里加载下一页”。如果不区分,分页追加也会触发恢复。

更隐蔽的是:首次进入页面时可能没有历史快照。用户滚到底后,滚动监听会持续保存当前会话的新快照。第二页回来时,items.length 变化触发 watcher,恢复逻辑拿到的已经是“刚刚滚到底保存的快照”。再按新的 scrollHeight 比例换算,页面就被补到了新底部。

简化成时间线就是:

首次进入列表,没有历史快照
    ↓
用户滚到第一页底部,滚动监听保存当前快照
    ↓
第二页数据回来,列表高度变大
    ↓
items.length watcher 再次执行恢复逻辑
    ↓
旧底部快照按新 scrollHeight 换算
    ↓
scrollTop 跳到新底部

这就是“第二页明明进来了,但用户看起来像没看到追加内容”的根因。

没有快照也要标记为已恢复

修法不是简单删掉快照,也不是关闭滚动保存。滚动保存仍然有价值,因为用户从详情页返回时确实需要回到之前的位置。

关键是把恢复做成每个 cache key、每次激活窗口里的“一次性动作”。更反直觉的一点是:即使没有历史快照,也要标记这次 cache key 已经处理过。

let restoredCacheKey = ''

function restoreScrollOnce(scroller: HTMLElement | null) {
  const key = cacheKey.value
  if (!scroller || restoredCacheKey === key) return

  const snapshot = scrollSnapshot.value
  if (!snapshot) {
    restoredCacheKey = key
    return
  }

  restoredCacheKey = key
  requestAnimationFrame(() => {
    const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight)
    scroller.scrollTop = Math.min(maxScrollTop, snapshot.scrollTop)
  })
}

“没有快照也标记已恢复”看起来像多余逻辑,实际是在切断当前会话内的错误复用。

首次进入时没有历史快照,说明这不是一次恢复场景。之后用户滚动保存下来的新快照,只应该服务“离开后再回来”。如果不先把这个 key 标记掉,分页追加后 watcher 还会把这份当前快照当历史快照使用。

真正离开再返回时,可以在 onActivated 或路由返回窗口里重置标记:

onActivated(() => {
  restoredCacheKey = ''
  restoreScrollOnce(scroller.value)
})

这样两个行为就分开了:

  • 当前页面内分页追加,不再触发历史恢复。
  • 离开页面后返回,仍然可以恢复历史位置。

分页追加应该保持绝对位置

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

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

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

watch(messages, () => {
  if (!shouldStickToBottom.value) return
  scrollToBottom()
})

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

触底加载也要单独抽出来

这次同时暴露出另一个问题:无限滚动触发也不应该散落在业务文件里手写。

一个稳定的触底触发器至少要处理三类时序:

  • sentinel 或滚动根首帧还是 null,后面才进 DOM。
  • 数据回来后 sentinel 已经在视口附近,但浏览器不会重新派发相交回调。
  • 虚拟列表高度变化后,需要等 DOM patch 和下一帧布局完成,再判断是否仍贴底。

所以触发器更适合抽成 composable,只负责“是否该尝试 load more”,分页语义仍留给业务数据 owner。

const canLoadMore = computed(() => hasMore.value && !loadingMore.value && !loadMoreError.value)

useLoadMoreTrigger({
  scrollElement,
  sentinelElement,
  canLoadMore,
  loadMore,
  distance: 160,
  recheckSources: [
    () => items.value.length,
    hasMore,
    loadingMore,
    nextIndex,
  ],
})

这里的 recheckSources 不是分页状态机。它只是告诉触发器:这些状态变化后,DOM 高度可能已经更新,请在下一帧重新检查是否仍接近底部。

分页游标、游客限制、错误重试、过滤后空页继续拉、去重和缓存,都不应该塞进触发器。触发器越懂业务,越容易变成第二个列表 store。

测试要守住错误时序

这类问题不能只测“调用了 loadMore”。真正要守的是错误时序:

  1. 首次进入列表时没有历史快照。
  2. 用户滚到底,当前会话保存了新的快照。
  3. 下一页数据追加,列表长度和高度变化。
  4. 断言 scrollTop 没有被恢复逻辑改到新底部。

伪代码大概这样:

it('分页追加时不把当前会话滚动快照按新高度恢复到新底部', async () => {
  const scroller = createScroller({
    scrollTop: 760,
    clientHeight: 520,
    scrollHeight: 1280,
  })

  const scrollSnapshot = ref(null)
  mountFeed({ scroller, scrollSnapshot })

  scrollSnapshot.value = {
    scrollTop: 760,
    scrollHeight: 1280,
    clientHeight: 520,
    maxScrollTop: 760,
  }

  setScrollHeight(scroller, 2600)
  appendNextPage()
  await flushPromises()

  expect(scroller.scrollTop).toBe(760)
})

再补一类相反测试:离开页面后返回时,历史快照仍然能恢复。前者守住“当前会话 live append 不恢复”,后者守住“真正重新激活要恢复”。只测其中一个,都可能把另一个行为改坏。

滚动条抖动是另一条链路

同一个页面如果还接了自定义滚动条,滚动时右侧 thumb 抖动通常是另一条链路。

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

这类问题不应该靠节流蒙过去。更合理的边界是让虚拟列表 owner 明确告诉滚动条:哪些 mutation 只是虚拟行内部变化,不代表滚动尺寸变化,可以忽略;承载总高度的 virtualizer 自身高度变化则仍然应该触发滚动条更新。

判断口径可以拆成两句:

  • 行节点 mount / unmount、transform 变化、行内部内容更新,通常不需要让滚动条重算。
  • virtualizer 总高度、滚动容器尺寸、真实列表长度导致的高度变化,必须让滚动条重算。

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

总结

  • 无限滚动排障先确认数据是否真的追加,避免把 UI 跳动误判成去重或接口问题。
  • 虚拟列表库负责 range 和尺寸测量,不负责区分业务里的“历史恢复”和“当前追加”。
  • 滚动恢复应该是每个 cache key / 激活窗口的一次性动作;没有历史快照也要标记为已处理。
  • 普通 feed 的分页追加应该保持当前绝对 scrollTop;聊天贴底是另一种显式业务行为。
  • 触底加载适合抽成 composable,但分页语义仍归业务数据 owner。
  • 自定义滚动条抖动要从 MutationObserver 和虚拟行复用边界排查,不要和分页跳底混成一个问题。

滚动问题最麻烦的地方,是几套机制会同时读写同一个容器:浏览器原生滚动、虚拟列表、无限滚动、页面缓存、自定义滚动条。排查时先把它们按职责拆开,问题通常会比截图里看起来简单很多。