Solid Query 中的滚动位置恢复(Scroll Restoration):缓存、placeholderData 与路由协作完整指南
【免费下载链接】query🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query.项目地址: https://gitcode.com/GitHub_Trending/qu/query
在浏览器中回到一个此前访问过的页面时,页面会精确地恢复到离开前的滚动位置,这一能力被称为滚动位置恢复(Scroll Restoration)。然而当 SPA 全面转向客户端数据获取之后,这一体验出现了普遍退化。本文将基于 TanStack Query(Solid Query)官方文档中《Scroll Restoration》指南的内容,结合当前仓库GitHub_Trending/qu/query的源码与测试,说明 Solid Query 虽然不自带滚动位置恢复实现,却通过缓存与placeholderData移除了它在 SPA 中最主要的破坏者,从而让"回到上一页立即渲染、布局稳定、滚动位置可被可靠恢复"成为开箱即用的事实。
背景:客户端数据获取时代,滚动恢复为何失灵
传统浏览器行为里,用户点击链接离开、再通过"后退"按钮返回一个已访问页面时,浏览器会依据历史记录中的滚动锚点,将页面滚动回上次停留的位置。这是浏览器层面对文档滚动状态的原生记忆能力。
但当应用转为客户端渲染(SPA)后,页面的内容不再由浏览器从网络重新加载,而是由 JavaScript 在路由切换时动态渲染。这时滚动位置能否恢复,取决于页面在返回瞬间是否以与离开时一致的布局与高度渲染出来。如果页面在渲染数据前先显示 loading,随后数据到达、DOM 高度发生变化,浏览器保存的滚动锚点与恢复时机就对不上——表现为"回到列表页却停在顶部"或"跳到错误位置"。
TanStack Query 文档对这一问题的定性非常明确:数据获取逻辑本身并不会导致滚动位置丢失,真正破坏恢复的是"因重新获取数据而引发的 UI 重置"(refetch-induced UI resets)。相关内容可参见 Scroll Restoration 指南源文档,其内容经由ref机制被 Solid 框架版本 复用(两者由同一份正文与少量框架化替换生成)。
TanStack Query 的定位:不是恢复的实现者,而是"障碍清除者"
需要特别澄清一个易混淆点:TanStack Query 并不自己实现滚动位置恢复。它不读取也不写回window.scrollY,不监听 history 的 popstate。它的价值在于消除了恢复机制失效的最大诱因。
具体机制链条如下:
- 缓存保留旧数据:当用户离开一个查询页面时,查询数据不会消失,而是留在缓存中(见下文 gcTime 规则)。
- 返回时同步渲染:再次渲染该查询时,Solid Query 能同步从缓存取回数据,页面即刻以与离开前相同的 DOM 结构与高度渲染出来。
- 可选
placeholderData:即使新页面需要新的查询参数,也可以借助placeholderData先展示上一份数据作为占位,避免出现空白或 loading 引发的布局抖动。 - 交由路由层恢复滚动:由于布局在返回瞬间即稳定,路由框架自带的 ScrollRestoration 能力,或基于 history 记录滚动位置的轻量自定义方案,都可以在正确的时机把页面滚回原处。
也就是说,TanStack Query 负责"页面以正确形态就位",滚动恢复本身由路由/浏览器历史层负责,两者分工协作而非重叠竞争。
为什么"开箱即用":缓存数据可以同步读取
文档原文强调:开箱即用下,滚动位置恢复对所有查询类型——包括分页查询与无限滚动查询——都能正常工作。其根本原因只有一个:查询结果是带缓存的,且查询渲染时能够同步读取到缓存。
这背后是 TanStack Query 在缓存设计上的一个关键取舍:查询结果在 JavaScript 引擎内部以内存对象形式保存(而非异步从持久层读取),因此当查询 Observer 重新挂载并命中缓存时,第一次渲染就能拿到完整数据,根本不需要等待一个异步 tick。UI 因此不会经历"先空/loading、后填充"的切换过程。
这个行为在 Solid Query 的测试中亦有体现,例如 QueryClientProvider.test.tsx 验证了 QueryClient 上gcTime: Infinity这类全局默认配置如何被持久化到查询的选项中。
决定成败的三项缓存配置
要让"返回页面立即以稳定布局渲染"成立,需要保证数据在返回时仍存在于缓存中且未被当作垃圾回收。以下三项默认值直接决定这一点。
gcTime:默认 5 分钟的垃圾回收窗口
TanStack Query 对已无活动观察者(active observer)的查询标记为 inactive,并在默认5 分钟(1000 * 60 * 5毫秒)后将其从缓存中垃圾回收。因此,只要用户在 5 分钟内执行"离开 → 返回"操作,且查询未被回收,滚动恢复就一直成立。该默认值及全部"aggressive but sane"默认约定,详见 Important Defaults 指南(Solid 框架版)。
若你的业务场景需要更长的恢复窗口,可在 QueryClient 上全局调整,也可在单个查询上局部覆盖:
import { QueryClient } from '@tanstack/solid-query' const queryClient = new QueryClient({ defaultOptions: { queries: { // 默认 1000 * 60 * 5;按需延长,例如半小时 gcTime: 1000 * 60 * 30, }, }, })注意gcTime: 0意味着查询一旦失去活动订阅就立即回收,这会直接让滚动恢复失效。Solid Query 的测试中对这一边界也有覆盖,例如 useQuery.test.tsx 验证了gcTime: 0时重新挂载后缓存中的查询如何被重新拾起。
staleTime:区分"新鲜"与"过期"的刷新节流阀
默认情况下,缓存中的数据一经读取就被视为stale(过期),导致挂载、窗口重新聚焦、网络重连时自动触发后台重新获取。后台重取并不清空当前 UI 数据(页面仍显示旧数据、布局不变),但如果你希望返回时连后台闪烁都不发生,可以为查询设置合理的staleTime:
staleTime: 2 * 60 * 1000:2 分钟内读缓存、不触发任何重取;staleTime: Infinity:永不因过期触发重取,直到手动 invalidate;staleTime: 'static':更强约束,连手动invalidateQueries也无法让其重取。
值得强调的是:后台重取与滚动恢复并不冲突——因为重取发生在页面已渲染、布局已稳定之后,属于"就地更新",不会把 DOM 推倒重建。
structuralSharing:防止不必要的引用变化引发重渲染
默认开启的结构共享(structural sharing)会逐层比较新旧响应,若内容未真正变化则保留原数据引用。这进一步保证了返回页面时不会因无关的状态跳变引发组件重渲染与布局偏移,为滚动恢复提供稳定执行环境。
placeholderData:让"新页面也能瞬间呈现"成为可能
纯靠 gcTime 命中缓存只适用于"回到同一个查询键"。对于进入新参数页面(如切换分页页码、进入详情页再返回)的场景,文档推荐使用placeholderData提供占位数据,使页面在真实数据到达前就拥有一份可渲染内容。完整用法见 Placeholder Query Data 指南(Solid 框架版),常见三种形态如下。
保持上一份数据作为占位(翻页/筛选场景最常用),对应 Solid Query 导出的keepPreviousData:
import { useQuery, keepPreviousData } from '@tanstack/solid-query' function Posts() { const postsQuery = useQuery(() => ({ queryKey: ['posts', page], queryFn: () => fetchPosts(page), placeholderData: keepPreviousData, })) }这一行为在 Solid Query 的测试中被专门验证: useQuery.test.tsx 与 useInfiniteQuery.test.tsx 均断言设置了placeholderData: keepPreviousData后,新查询会保留上一份数据。
从其他查询的缓存中派生占位数据:
const queryClient = useQueryClient() const blogPostQuery = useQuery(() => ({ queryKey: ['blogPost', id], queryFn: () => fetch(`/blogPosts/${id}`), placeholderData: () => queryClient.getQueryData(['blogPosts'])?.find((p) => p.id === id), }))若占位数据计算较重,还可以用createMemo记忆化占位值,避免每次渲染重复执行。占位数据被标记为 placeholder,不会写入持久缓存,也不会造成真实查询结果的错乱。
分页与无限查询:跨页返回同样成立
文档特别点明滚动恢复对所有查询(含分页、无限查询)开箱即用,其含义是:每一页的查询结果都以独立查询键(如['posts', page])或无限查询的 pages 形式整体留在缓存中。返回列表页时,此前加载过的每一页数据都能同步取出,列表高度与滚动容器尺寸即刻还原,为浏览器与路由层的滚动恢复提供了正确前提。
无限滚动场景的更多细节可参考 Infinite Queries 指南源文档(Solid 框架版的对应文件为 infinite-queries.md),其中说明无限查询在渲染时即可同步取回所有已缓存 pages。
在 Solid 应用中的落地清单
总结一个在 Solid + Solid Query 应用中让滚动恢复稳定工作的可操作清单:
- 滚动位置恢复交给路由层:使用路由框架自带的历史滚动恢复能力,或在
history/路由切换事件中自行保存并恢复各路径对应的scrollY。TanStack Query 不提供也不应承担这部分职责。 - 不要为"返回后不闪烁"而过早清缓存:保持默认
gcTime(5 分钟)或按需延长;只有内存敏感的页面才考虑缩短或设gcTime: 0,但要接受滚动恢复随之失效的代价。 - 为列表页配置
placeholderData/keepPreviousData:让翻页、筛选、详情返回等场景在真实数据到达前即有稳定内容渲染,是消除"内容高度跳变"的关键手段。 - 利用
staleTime平滑刷新:对不常变化的数据设置较长staleTime,减少返回瞬间的后台重取噪音;注意区分Infinity与'static'在手动失效语义上的差异。 - 保持结构共享开启:避免自定义比较逻辑破坏引用稳定性;仅在 JSON 不兼容数据或极端大响应带来的性能问题出现时再考虑关闭或提供自定义共享函数。
结语与进一步阅读
滚动位置恢复是"路由层恢复机制 + 数据层稳定渲染"共同作用的结果。Solid Query 用同步可读的缓存、5 分钟默认回收窗口、placeholderData/keepPreviousData与结构共享,清除了 SPA 中导致恢复失效的根源——重取引发的 UI 重置,让应用在返回上一页时能立即以稳定的布局就位,从而把恢复动作交还给路由层并保证其可靠执行。
仓库内可继续深入阅读的相关材料:
- Scroll Restoration 官方指南(React/Solid 等共享源文档)
- Solid 框架版 Scroll Restoration 指南入口
- Important Defaults(缓存回收、stale 与重取默认约定)
- Placeholder Query Data(placeholder 三种形态与记忆化)
- Infinite Queries 指南
- 源码验证:useQuery 的 placeholderData 测试、useInfiniteQuery 的 keepPreviousData 测试
【免费下载链接】query🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query.项目地址: https://gitcode.com/GitHub_Trending/qu/query
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考