Router 与 Query 集成
本教程共 38 篇 · 第 21 篇 · 更新于 2026-07-27 · 约 14 分钟阅读
21. Router 与 Query 集成
本节目标:搞懂 TanStack Router 和 TanStack Query 怎么配合。学会在 loader 里用
ensureQueryData预热缓存、组件里用useSuspenseQuery读取、通过路由上下文注入 QueryClient、用 SSR 集成包自动处理脱水/注水。学完你能让两个库各司其职,数据加载又快又可控。
21.1 为什么要集成
上一章我们学了 Router 自带的 loader 和缓存。Router 缓存够用,但有几个短板:
- 没有持久化存储(刷新页面缓存就没了)
- 路由之间不能共享缓存(A 路由加载的数据 B 路由用不了)
- 没有变更(mutation)API
- 没有乐观更新支持
TanStack Query 恰好擅长这些。但 Query 自己不知道用户要去哪个页面,没法提前加载数据。
Router 知道用户要去哪,Query 擅长管数据。把它们结合起来:
- Router 负责”什么时候加载”:在路由匹配时触发 loader
- Query 负责”怎么缓存和更新”:管理缓存、去重、后台刷新
NoteRouter 变成”协调者”(coordinator),不直接存数据,而是把加载任务委托给 Query。Router 的内置缓存在集成 Query 后基本就不用了。
21.2 集成的基本模式
核心思路:在 loader 里用 ensureQueryData 把数据塞进 Query 缓存,组件里用 useSuspenseQuery 读取。
定义查询选项
先用 queryOptions 定义查询,这样查询键和查询函数能复用:
// src/api/posts.ts
import { queryOptions } from '@tanstack/react-query'
export const postsQueryOptions = queryOptions({
queryKey: ['posts'],
queryFn: async () => {
const res = await fetch('/api/posts')
if (!res.ok) throw new Error('获取文章失败')
return res.json()
},
})
export const postQueryOptions = (postId: string) =>
queryOptions({
queryKey: ['posts', postId],
queryFn: async () => {
const res = await fetch(`/api/posts/${postId}`)
if (!res.ok) throw new Error('获取文章详情失败')
return res.json()
},
})
Tip
queryOptions是个工具函数,返回一个包含queryKey和queryFn的对象。好处是类型安全、可复用,loader 和组件用同一份定义。
在 loader 里预热缓存
// src/routes/posts.tsx
import { createFileRoute } from '@tanstack/react-router'
import { useSuspenseQuery } from '@tanstack/react-query'
import { postsQueryOptions } from '../api/posts'
export const Route = createFileRoute('/posts')({
// 确保数据在缓存里,没有就加载
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(postsQueryOptions),
component: PostsComponent,
})
function PostsComponent() {
// 从缓存读取,一定有数据(loader 保证过了)
const { data: posts } = useSuspenseQuery(postsQueryOptions)
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
执行流程:
- 用户导航到
/posts - Router 执行 loader ->
ensureQueryData检查缓存 - 缓存没有 -> 发起请求 -> 等待完成 -> 写入缓存
- loader 返回 -> 组件渲染
useSuspenseQuery从缓存读到数据 -> 直接显示
整个过程没有 loading 闪烁,因为组件渲染时数据已经就绪。
ensureQueryData vs prefetchQuery
这两个方法都能预热缓存,区别在于等不等待:
| 方法 | 行为 | 适用场景 |
|---|---|---|
ensureQueryData | 等待请求完成才返回 | 关键数据,必须先加载 |
prefetchQuery | 发出请求但不等待 | 非关键数据,可以延迟 |
// src/routes/posts.$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
import { useSuspenseQuery } from '@tanstack/react-query'
import { postQueryOptions, commentsQueryOptions } from '../api/posts'
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ context: { queryClient }, params: { postId } }) => {
// 关键数据:等它完成
await queryClient.ensureQueryData(postQueryOptions(postId))
// 非关键数据:发起但不等
queryClient.prefetchQuery(commentsQueryOptions(postId))
},
component: PostDetail,
})
function PostDetail() {
const { postId } = Route.useParams()
const { data: post } = useSuspenseQuery(
postQueryOptions(postId)
)
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
{/* 评论区用 Suspense 包裹,评论没好就显示 loading */}
<Suspense fallback={<div>加载评论中...</div>}>
<Comments postId={post.id} />
</Suspense>
</article>
)
}
Tip
ensureQueryData保证组件渲染时关键数据一定在缓存里,useSuspenseQuery就不会挂起。prefetchQuery提前发请求但不阻塞渲染,配合 Suspense 实现延迟加载。
21.3 通过路由上下文注入 QueryClient
组件和 loader 都需要访问 queryClient。最干净的方式是放进路由上下文。
创建带上下文的根路由
// src/routes/__root.tsx
import { createRootRouteWithContext } from '@tanstack/react-router'
import { QueryClient } from '@tanstack/react-query'
export const Route = createRootRouteWithContext<{
queryClient: QueryClient
}>()({
component: RootComponent,
})
创建路由器时提供 QueryClient
// src/router.tsx
import { createRouter } from '@tanstack/react-router'
import { QueryClient } from '@tanstack/react-query'
import { routeTree } from './routeTree.gen'
// 客户端:全局共用一个 QueryClient
const queryClient = new QueryClient()
export const router = createRouter({
routeTree,
context: {
queryClient,
},
// 关闭 Router 内置预取缓存(交给 Query 管)
defaultPreloadStaleTime: 0,
defaultPreload: 'intent',
})
Warning集成 Query 时一定要设
defaultPreloadStaleTime: 0。不然 Router 的内置缓存会和 Query 的缓存打架,导致预取数据不生效。设成 0 就是告诉 Router:预取的数据立即过期,每次都走 Query 的缓存逻辑。
在 loader 里使用
// src/routes/posts.tsx
export const Route = createFileRoute('/posts')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(postsQueryOptions),
component: PostsComponent,
})
类型自动推导,context.queryClient 有完整的 QueryClient 类型。
21.4 完整示例:文章列表与详情
把前面的知识点串起来,做一个完整的文章浏览功能。
查询选项定义
// src/api/posts.ts
import { queryOptions } from '@tanstack/react-query'
type Post = {
id: string
title: string
content: string
}
type Comment = {
id: string
postId: string
text: string
}
// 文章列表
export const postsQueryOptions = queryOptions({
queryKey: ['posts'],
queryFn: async (): Promise<Post[]> => {
const res = await fetch('/api/posts')
if (!res.ok) throw new Error('获取文章列表失败')
return res.json()
},
})
// 文章详情
export const postQueryOptions = (postId: string) =>
queryOptions({
queryKey: ['posts', postId],
queryFn: async (): Promise<Post> => {
const res = await fetch(`/api/posts/${postId}`)
if (!res.ok) throw new Error('获取文章详情失败')
return res.json()
},
})
// 评论
export const commentsQueryOptions = (postId: string) =>
queryOptions({
queryKey: ['posts', postId, 'comments'],
queryFn: async (): Promise<Comment[]> => {
const res = await fetch(`/api/posts/${postId}/comments`)
if (!res.ok) throw new Error('获取评论失败')
return res.json()
},
})
列表页
// src/routes/posts.tsx
import { createFileRoute, Link } from '@tanstack/react-router'
import { useSuspenseQuery } from '@tanstack/react-query'
import { postsQueryOptions } from '../api/posts'
export const Route = createFileRoute('/posts')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(postsQueryOptions),
component: PostsPage,
})
function PostsPage() {
const { data: posts } = useSuspenseQuery(postsQueryOptions)
return (
<div>
<h1>文章列表</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>
<Link to="/posts/$postId" params={{ postId: post.id }}>
{post.title}
</Link>
</li>
))}
</ul>
</div>
)
}
详情页
// src/routes/posts.$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
import { useSuspenseQuery } from '@tanstack/react-query'
import { Suspense } from 'react'
import { postQueryOptions, commentsQueryOptions } from '../api/posts'
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ context: { queryClient }, params: { postId } }) => {
// 关键数据:等待
await queryClient.ensureQueryData(postQueryOptions(postId))
// 非关键数据:不等待
queryClient.prefetchQuery(commentsQueryOptions(postId))
},
component: PostDetailPage,
})
function PostDetailPage() {
const { postId } = Route.useParams()
const { data: post } = useSuspenseQuery(postQueryOptions(postId))
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<Suspense fallback={<div>加载评论中...</div>}>
<CommentsSection postId={postId} />
</Suspense>
</article>
)
}
function CommentsSection({ postId }: { postId: string }) {
const { data: comments } = useSuspenseQuery(
commentsQueryOptions(postId)
)
return (
<section>
<h2>评论</h2>
<ul>
{comments.map((comment) => (
<li key={comment.id}>{comment.text}</li>
))}
</ul>
</section>
)
}
变更后刷新
用户发表评论后,用 queryClient.invalidateQueries 刷新:
import { useMutation, useQueryClient } from '@tanstack/react-query'
function CommentForm({ postId }: { postId: string }) {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: (text: string) =>
fetch(`/api/posts/${postId}/comments`, {
method: 'POST',
body: JSON.stringify({ text }),
}).then((r) => r.json()),
onSuccess: () => {
// 让评论缓存失效,自动重新加载
queryClient.invalidateQueries({
queryKey: ['posts', postId, 'comments'],
})
},
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
mutation.mutate(formData.get('text') as string)
}}
>
<input name="text" placeholder="写评论..." />
<button type="submit" disabled={mutation.isPending}>
发表
</button>
</form>
)
}
Note这是 Router + Query 集成最大的好处:变更后刷新数据特别简单。Router 自带缓存做这个要自己写一套 mutation 逻辑,Query 天然支持。
21.5 错误处理
用 useSuspenseQuery 时,查询出错会抛出错误,被 Router 的错误边界捕获。
// src/routes/posts.tsx
import { useRouter } from '@tanstack/react-router'
import { useQueryErrorResetBoundary } from '@tanstack/react-query'
import { useEffect } from 'react'
export const Route = createFileRoute('/posts')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(postsQueryOptions),
errorComponent: ({ error }) => {
const router = useRouter()
const resetBoundary = useQueryErrorResetBoundary()
useEffect(() => {
// 重置 Query 的错误状态,下次渲染会重新请求
resetBoundary.reset()
}, [resetBoundary])
return (
<div>
<p>加载失败:{error.message}</p>
<button onClick={() => router.invalidate()}>重试</button>
</div>
)
},
component: PostsPage,
})
关键点:
useQueryErrorResetBoundary重置 Query 的错误状态router.invalidate()重新执行 loader,同时重置 Router 的错误边界- 两个都要重置,否则只重试了一半
Tip如果用户不是点重试,而是直接导航走了再回来,
useEffect里的resetBoundary.reset()会确保回来时重新请求。
21.6 SSR 集成:自动脱水与注水
做服务端渲染(SSR)时,服务端加载的数据要传给客户端。这个过程叫脱水(dehydrate)和注水(hydrate)。
手动做很麻烦:服务端序列化 QueryClient 状态、嵌到 HTML 里、客户端读出来恢复。TanStack 提供了集成包自动处理。
安装
npm install @tanstack/react-router-ssr-query
配置
// src/router.tsx
import { QueryClient } from '@tanstack/react-query'
import { createRouter } from '@tanstack/react-router'
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
import { routeTree } from './routeTree.gen'
export function getRouter() {
// 每次请求创建新的 QueryClient(SSR 必须)
const queryClient = new QueryClient()
const router = createRouter({
routeTree,
context: { queryClient },
defaultPreload: 'intent',
})
// 一行代码搞定 SSR 集成
setupRouterSsrQueryIntegration({
router,
queryClient,
})
return router
}
setupRouterSsrQueryIntegration 自动做了这些事:
- 服务端渲染时,序列化 QueryClient 状态嵌到 HTML
- 客户端启动时,读取状态恢复 QueryClient
- 渲染期间 resolve 的查询会流式传输给客户端
- 自动包裹
QueryClientProvider
NoteSSR 环境下必须在
getRouter函数里创建QueryClient,不能全局共享。因为不同用户的请求不能共享缓存。每次调用getRouter()都创建新的实例。
自定义脱水选项
如果有些查询不想序列化到 HTML(比如包含敏感数据),可以过滤:
setupRouterSsrQueryIntegration({
router,
queryClient,
dehydrateOptions: {
// 标记了 meta.ssr === false 的查询不脱水
shouldDehydrateQuery: (query) => query.meta?.ssr !== false,
},
hydrateOptions: {
defaultOptions: {
queries: {
// 注水后的查询 5 分钟后回收
gcTime: 5 * 60 * 1000,
},
},
},
})
给查询打标记:
const secretQueryOptions = queryOptions({
queryKey: ['secret'],
queryFn: fetchSecret,
meta: { ssr: false }, // SSR 时不脱水
})
useSuspenseQuery vs useQuery 在 SSR 中的区别
// 参与服务端渲染,数据会流式传输
const { data } = useSuspenseQuery(postsQueryOptions)
// 不参与服务端渲染,客户端注水后才发请求
const { data, isLoading } = useQuery(postsQueryOptions)
| Hook | SSR 行为 | 适用场景 |
|---|---|---|
useSuspenseQuery | 服务端执行,流式传输 | 关键数据,首屏要显示 |
useQuery | 服务端不执行,客户端才发 | 非关键数据,后台加载 |
Tip简单记:首屏要看到的数据用
useSuspenseQuery,不着急的用useQuery。
21.7 流式传输:服务端 prefetchQuery
在 SSR 场景下,loader 里的 prefetchQuery 有特殊行为:服务端发出请求但不等待,渲染期间数据 resolve 了就流式传给客户端。
// src/routes/dashboard.tsx
export const Route = createFileRoute('/dashboard')({
loader: ({ context: { queryClient } }) => {
// 不 await 也不 return,服务端发出请求后继续渲染
// 数据 resolve 后流式传给客户端
queryClient.prefetchQuery(slowStatsQueryOptions)
},
component: Dashboard,
})
function Dashboard() {
const { data: stats } = useSuspenseQuery(slowStatsQueryOptions)
return <div>{/* 显示统计 */}</div>
}
执行流程:
- 服务端执行 loader ->
prefetchQuery发出请求 - loader 立即返回(没等请求)-> 开始渲染
- 组件用
useSuspenseQuery-> 数据还没好 -> 挂起 - 服务端把已渲染的 HTML 流式传给客户端
- 请求 resolve -> 服务端继续渲染剩余部分 -> 流式传给客户端
- 客户端逐步显示内容
Warning流式传输要求服务器支持(比如 Node.js 的流式响应)。具体配置在后面 SSR 章节详细讲。
21.8 小结
这一章把 Router 和 Query 串起来了:
- 集成模式:loader 用
ensureQueryData预热缓存,组件用useSuspenseQuery读取 - 路由上下文:通过
createRootRouteWithContext注入 QueryClient defaultPreloadStaleTime: 0:集成 Query 时必须设,避免 Router 缓存干扰- 延迟加载:
prefetchQuery发请求不等待,配合 Suspense 实现流式加载 - 错误处理:
useQueryErrorResetBoundary+router.invalidate()双重重置 - SSR 集成:
@tanstack/react-router-ssr-query一行代码搞定脱水注水 - 流式传输:SSR 中
prefetchQuery不 await,数据 resolve 后流式传给客户端
下一章开始进入 TanStack Start,这是基于 Router + Vite 的全栈框架。Router 和 Query 的集成知识在 Start 里同样适用。