首页
/ TanStack Query 滚动恢复实战:Preact Query 如何让 SPA 返回页面时停留在原滚动位置

TanStack Query 滚动恢复实战:Preact Query 如何让 SPA 返回页面时停留在原滚动位置

2026-09-08 11:46:10作者:庞眉杨Will

在浏览器中点击"后退"回到之前访问过的页面时,浏览器通常会恢复你离开前的滚动位置,这一能力被称为滚动恢复(Scroll Restoration)。自从 Web 应用普遍转向客户端数据请求(Client Side Data Fetching)后,这项能力出现了明显退化。而基于 TanStack Query 构建的 Preact 数据请求层,是当前解决这一问题最有效的实践路径之一:它不亲自"滚动页面",却精准移除了 SPA 中破坏滚动恢复的最大元凶——由重新请求引发的界面重置(refetch-induced UI resets)。

本文以 Preact Query(@tanstack/preact-query)为核心,完整讲解:为什么传统 SPA 的滚动恢复容易失效、TanStack Query 的缓存机制如何从根因上修复它、如何配合路由器(或自研 history 方案)真正落地滚动恢复,以及分页、无限滚动、placeholderData 等进阶场景的注意事项。阅读完成后,你将能在 Preact 应用中实现"返回即回到原位置"的稳定体验。

滚动恢复是什么,为什么在 SPA 里坏掉了

传统多页应用(MPA)中,浏览器在页面导航时会记录每个页面实例的滚动位置。当你点击"后退"按钮重新加载同一 URL 时,浏览器会在新页面渲染完成后把滚动条恢复到记录的位置。这一套机制内置于浏览器历史会话中,工作多年,被称为原生滚动恢复。

但单页应用(SPA)彻底改变了页面生命周期:

  • 导航不触发整页刷新,而是由前端路由在同一个 HTML 文档内切换视图;
  • 页面视图依赖的数据大多来自客户端异步请求,首次进入时组件先处于加载态,数据到达后才渲染出完整高度;
  • 关键点在于:数据到达前,页面高度通常不足以支撑滚动。即便路由器把滚动位置恢复为旧值,此时页面还没有渲染出足够内容,"恢复"也就无从谈起。

TanStack Query 的出现改变了这个局面。它把"服务端数据"从组件内部临时状态中解放出来,放进一个独立、可同步读取的查询缓存里,这正是滚动恢复能够稳定工作的地基。

TanStack Query 的立场:不实现滚动,只消除破坏源

原文档明确指出一个容易被误解的事实:TanStack Query 本身并不实现滚动恢复。它没有接管 window.scrollTo,也没有监听路由变化。它的作用在于消除 SPA 中破坏滚动恢复的头号原因——重新请求导致的界面重置

它的工作方式分为两步:

  1. 把之前取到的数据保留在缓存中。当用户从列表页进入详情页,再从详情页返回列表页时,列表页的 useQuery 会首先命中缓存;
  2. (可选)通过 placeholderData 提供占位数据。尤其当列表的分页参数变化、或查询尚未具备真实数据时,先用上一份数据填住版面。

结果是:返回页面时数据可以立即、同步地渲染,页面布局保持稳定,不再经历"加载态 → 空高度 → 数据到达 → 布局突然长高"的抖动。此时由路由器负责的滚动恢复才能生效——无论是 React Router 的 ScrollRestoration、TanStack Router 自带的滚动恢复,还是基于 history API 手写的小型方案。

可以这样理解两者的分工:

环节 负责方 职责
记录/恢复滚动位置 路由器或自研 history 方案 保存与恢复 scrollY
保证返回时立即有数据渲染 TanStack Query 同步命中缓存,避免布局在恢复后被数据撑高
保证布局在后台刷新时不被重置 TanStack Query 默认"陈旧数据优先",新数据在后台静默更新

为什么 Preact Query 默认就"开箱即用":同步缓存是第一原理

原文档断言:TanStack Query 中,所有查询(包括分页查询和无限查询)的滚动恢复开箱即用(Just Works™️)。支撑这一结论的是缓存机制的核心特性——查询结果被缓存,且查询渲染时可以被同步取回

以 Preact Query 的调用链看,这一设计非常直接。useQuery 的实现 将所有选项交给内部的 useBaseQuery 与 QueryCore 层的 QueryObserverQueryObserver 构造时会先检查所属 QueryCache 中是否存在相同 queryKey 的查询实例;若存在,其 data(以及错误、更新时间等状态)会作为初始结果同步返回,而不是先抛出一个 pending 状态、等待异步请求完成。

也就是说,第二次挂载同一个查询时,Preact 组件渲染的是缓存里现成的数据。列表的 DOM 结构、item 数量、整体高度与离开前完全一致,浏览器恢复滚动位置后,用户看到的正是自己离开时的那一屏内容。

这里有一个容易被忽略的前置条件:缓存必须还活着。缓存条目在失去所有观察者后并不会立刻被销毁,而是进入"可回收"状态,由垃圾回收调度器倒计时。相关默认值在 packages/query-core/src/removable.ts#L28 中有清晰体现:

newGcTime ?? (isServerEnvironment() ? Infinity : 5 * 60 * 1000)
  • 在客户端,查询的默认 gcTime(垃圾回收时间)为 5 分钟
  • 在服务端(SSR 环境)为 Infinity,即不回收。

这与文档中"默认缓存 5 分钟"的说法完全对应。因此,只要查询在 5 分钟窗口内没有被回收(且期间没有被手动 removeQueries / clear),从 A 页切到 B 页再返回,滚动恢复就一直有效。

稳定布局的三个实操要点

滚动恢复是否成功,最终取决于返回瞬间页面高度是否稳定。以下是让 Preact Query 与路由器配合默契的三个关键实践。

1. 后台刷新时不要渲染加载占位,直接复用陈旧数据

在旧式写法中,开发者常在每次取数时根据 isPending 渲染一个加载指示器替代真实内容。这会让"重新聚焦窗口触发的后台刷新"直接清空列表、折叠高度,滚动位置随即失效。

TanStack Query 的默认行为恰好相反:只要缓存中有数据,组件就先用它渲染,同时在后台上重新请求并更新数据。相关机制可参考 window-focus-refetching 指南background-fetching-indicators 指南——UI 上应通过 isFetching/isPlaceholderData 显示轻量的"后台更新中"提示,而不是替换掉列表本体。例如:

import { useQuery } from '@tanstack/preact-query'

function Posts() {
  const { status, data, error, isFetching } = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  })

  if (status === 'pending') return 'Loading...' // 仅首次、无缓存时才渲染
  if (status === 'error') return <span>Error: {error.message}</span>

  return (
    <div>
      <ul>
        {data?.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
      <div>{isFetching ? 'Background Updating...' : ''}</div>
    </div>
  )
}

关键分支是 pending:它只在"缓存中确实没有数据"时成立。返回一个已缓存页面时永远走不到这个分支,列表高度不会塌缩。

2. 分页 / 翻页场景使用 keepPreviousData 保住版面

列表翻页(页码切换)在传统写法中同样会清空当前页数据。此时应启用 placeholderData: keepPreviousData,让旧页数据在下一页请求期间继续渲染,只在 isPlaceholderDatatrue 时对按钮等交互元素做视觉/可用性降级。这属于 placeholder-query-data 指南 中的标准用法,Preact 版 useQuery 的 JSDoc 示例也完整演示了这一模式(useQuery.ts):

import { keepPreviousData, useQuery } from '@tanstack/preact-query'
import { useState } from 'preact/hooks'

function Posts() {
  const [page, setPage] = useState(0)

  const { data, isPlaceholderData, isError, error } = useQuery({
    queryKey: ['posts', page],
    queryFn: () => fetchPosts(page),
    placeholderData: keepPreviousData, // 请求下一页期间继续显示旧页
  })

  if (isError) return <span>Error: {error.message}</span>

  return (
    <div>
      <ul>{data?.map((post) => <li key={post.id}>{post.title}</li>)}</ul>
      <button
        disabled={isPlaceholderData}
        onClick={() => setPage((old) => old + 1)}
      >
        Next Page
      </button>
    </div>
  )
}

3. 列表 → 详情 → 返回,别为详情页改变列表的查询键

列表页的 useQuery 若在导航过程中卸载,只要其查询键(queryKey)保持稳定,缓存条目就会留存到 gcTime 到期。返回时组件会以相同查询键重新挂载并同步命中旧数据。请勿在组件卸载时调用 removeQueries 或修改列表的 queryKey 结构——这是最隐蔽的"滚动恢复莫名失效"来源。查询键规范可参考 query-keys 指南

与路由器配合:在 Preact 项目中落地恢复动作

需要再次强调的是,上面的一切只保证了"返回瞬间布局稳定"。真正把滚动条移到旧位置的"最后一公里",由路由器完成:

  • 若使用 React Router,可用其 <ScrollRestoration /> 组件(此组件面向 React Router 用户,Preact 应用需选择 Preact 生态或自研方案);
  • 若使用 TanStack Router,其内置的 scroll restoration 开箱可用;
  • 也可基于浏览器原生 history 状态实现一个小型自定义方案:路由切换前把 window.scrollY 写入 history.state,返回对应路由时在 DOM 更新完成后执行 window.scrollTo(0, saved)。此类逻辑可放在 Preact 路由的导航回调中,与 TanStack Query 的缓存配合时,只需确保恢复动作发生在"缓存数据已同步渲染、页面高度已定型"之后。

社区与项目文档普遍推荐的第一选择是交给成熟路由器的内置能力,因为滚动位置记录与浏览器前进/后退语义的同步是一件容易被写错的琐碎工作;TanStack Query 负责把"数据从服务器异步到达"的时序问题彻底移出这条链路。

无限查询场景与边界情况

无限查询:默认同样生效

无限列表(useInfiniteQuery)由多页数据拼接而成,其缓存形态为 pages 数组。返回页面时,整份 pages 会同步恢复,列表中已加载的全部内容(含滚动容器内已经展开的高度)立即还原,因此滚动恢复默认成立。若希望无限列表在返回后仍处于较靠前的位置,仅加载少量页,可借助"最大页数"类配置控制初始展开量(Preact 版 API 细节参见 useInfiniteQuery 参考文档)。真正的"滚动到底部自动加载"属于另一层 UI 逻辑(如 IntersectionObserver),它不应以清空 pages 为代价换取分页加载状态。

缓存已被回收时的降级行为

若返回间隔超过默认 gcTime(5 分钟),或期间进程内存压力触发回收,查询会回到"无缓存"状态。此时组件会正常进入 pending 并触发新请求——滚动恢复随之退化为不可用。这是符合预期的取舍:为了内存可控,TanStack Query 只保证"缓存生命周期内的滚动恢复"。若某些页面需要更长窗口,可在 缓存与生命周期指南 中介绍的 gcTime 配置基础上按需调大(例如资讯详情页设 gcTime: 30 * 60 * 1000),或结合持久化方案在 App 重启后水合缓存。

关于默认值的两个提醒

TanStack Query 默认 staleTime: 0,即数据一旦取回便视为陈旧(详见 important-defaults 指南)。这意味着重新聚焦窗口或重新挂载时通常伴随一次后台刷新——但这不影响滚动恢复:陈旧的数据仍会先被同步渲染用于稳定布局,新数据随后在后台静默替换。切忌把"刷新"误解为"需要先清空旧 UI"。若刷新频率造成干扰,可针对查询适度调大 staleTime 减少无谓的后台请求。

排查清单:返回后滚动位置不对怎么办

当"返回页面"滚动恢复失效时,按以下顺序排查,绝大多数问题都落在 TanStack Query 之外:

  1. 数据是否在返回瞬间同步可用?检查组件是否错误地因 isFetching(而非仅 isPending)切换了主渲染分支。
  2. 查询是否已被回收?返回间隔是否超过 gcTime,或是否调用了 removeQueries / clear / 变更了 queryKey
  3. 页面高度在恢复动作执行时是否已定型?恢复应发生在数据渲染完成之后;若路由器的恢复时机过早,可让恢复逻辑延后到布局计算完成(如 requestAnimationFrame 之后)。
  4. 路由器/自研 history 方案是否正确记录了滚动位置,并区分了"push 进入"与"pop 返回"两种导航方向。
  5. 若容器不是 window 而是某个可滚动 div,滚动位置应记录在容器元素上,浏览器原生机制不会代劳。

小结

滚动恢复在 SPA 中失效的本质,是"数据异步到达"与"恢复滚动位置需要稳定高度"之间的矛盾。TanStack Query(含其 Preact 绑定 @tanstack/preact-query)通过同步可读的查询缓存、默认 5 分钟的 gcTime 以及 placeholderData 机制,让页面返回瞬间即可用陈旧或占位数据渲染出与离开前一致的布局,从而把矛盾从根上消解。滚动位置的记录与还原仍交给路由器或自研 history 方案完成——在 Preact 项目中,二者分工清晰:TanStack Query 保证你回来时页面还在,路由器保证你把视线停回原处。

想进一步深入,可继续阅读本仓库内同主题在 React 框架侧的规范表述 docs/framework/react/guides/scroll-restoration.md,以及 Preact 侧相关的 caching.mdplaceholder-query-data.mdimportant-defaults.md 指南。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.81 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
596
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
920
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.79 K
1.02 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
519
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
390