Loader 与数据预取
本教程共 38 篇 · 第 20 篇 · 更新于 2026-07-27 · 约 15 分钟阅读
20. Loader 与数据预取
本节目标:搞懂 TanStack Router 的 loader 机制,学会在路由匹配时并行加载数据、控制缓存新鲜度、用预取策略让页面秒开、延迟加载慢数据。学完你能在用户点击链接之前就把数据准备好,页面跳转瞬间出内容。
20.1 为什么数据加载要放在路由层
传统 React 应用的数据加载方式是:组件挂载后发请求。点击链接 → 组件渲染 → 发起 fetch → 等待 → 显示数据。用户盯着白屏或 loading 转圈等。
这有个问题:路由器是唯一一个在渲染之前就知道用户要去哪里的地方。如果让路由器来协调数据加载,就能在组件渲染之前把数据准备好。
TanStack Router 的 loader 就是干这个的。每个路由可以定义一个 loader 函数,在路由匹配时并行执行,等所有 loader 完成后再渲染组件。
用户点击链接
↓
路由匹配(并行执行所有匹配路由的 loader)
↓
数据就绪 → 渲染组件(带数据)
Note如果你用过 Next.js 的
getServerSideProps或 Remix 的loader,概念类似。TanStack Router 在此基础上额外提供了内置 SWR 缓存。
20.2 第一个 loader
给路由加 loader 很简单,写个函数返回数据就行:
// src/routes/posts.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts')({
loader: () => fetchPosts(),
component: PostsComponent,
})
function PostsComponent() {
const posts = Route.useLoaderData()
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
loader 返回的数据,用 Route.useLoaderData() 读取。类型自动推导,不需要手动标注。
fetchPosts 是你自己的数据获取函数:
// src/utils/fetchPosts.ts
export async function fetchPosts() {
const res = await fetch('/api/posts')
if (!res.ok) {
throw new Error('获取文章列表失败')
}
return res.json()
}
Tip
loader返回的可以是 Promise(异步),也可以是普通值(同步)。异步的话路由会等它 resolve。
20.3 loader 的参数
loader 函数接收一个对象参数,里面有好多有用的东西:
export const Route = createFileRoute('/posts/$postId')({
loader: async ({
params, // 路径参数,如 { postId: "123" }
deps, // loaderDeps 返回的依赖
context, // 路由上下文(父级 + beforeLoad 注入的)
abortController, // 用于取消请求
preload, // 是否是预取(true 表示预取,false 表示正常加载)
cause, // 触发原因:'enter' | 'preload' | 'stay'
}) => {
return fetchPostById(params.postId)
},
})
最常用的几个:
params:路径参数,如/posts/$postId里的postIddeps:loaderDeps返回的搜索参数依赖context:路由上下文,做依赖注入用的abortController:用户快速切换路由时自动取消未完成的请求
用路径参数加载详情
// src/routes/posts.$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
loader: ({ params: { postId } }) => fetchPostById(postId),
component: PostDetail,
})
function PostDetail() {
const post = Route.useLoaderData()
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
)
}
用 abortController 取消请求
用户从 /posts/1 快速跳到 /posts/2,第一个请求还没回来。TanStack Router 会自动取消它:
export const Route = createFileRoute('/posts/$postId')({
loader: ({ params: { postId }, abortController }) =>
fetch(`/api/posts/${postId}`, {
signal: abortController.signal, // 传给 fetch,自动取消
}).then((res) => res.json()),
})
Tip
abortController.signal传给fetch或任何支持signal的 API,路由切换时自动 abort,不用自己管。
20.4 用 loaderDeps 读取搜索参数
这里有个设计决策:loader 函数里不能直接访问 search。
为什么不直接给?因为搜索参数变了,loader 要不要重新执行,路由器需要知道。如果你在 loader 里偷偷用了 search.page,但没告诉路由器,缓存就会错乱。
解决方案是 loaderDeps:显式声明 loader 依赖哪些搜索参数。
// src/routes/posts.tsx
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
export const Route = createFileRoute('/posts')({
// 1. 校验搜索参数
validateSearch: z.object({
page: z.number().int().nonnegative().catch(1),
limit: z.number().int().positive().catch(10),
}),
// 2. 声明 loader 依赖哪些搜索参数
loaderDeps: ({ search: { page, limit } }) => ({ page, limit }),
// 3. 在 loader 里通过 deps 使用
loader: ({ deps: { page, limit } }) =>
fetchPosts({ page, limit }),
component: PostsComponent,
})
function PostsComponent() {
const posts = Route.useLoaderData()
const { page } = Route.useSearch()
return (
<div>
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
<span>当前第 {page} 页</span>
</div>
)
}
page 或 limit 变了,loader 自动重新执行。没变的搜索参数(比如 viewMode)不会触发重载。
Warning别把整个
search对象都返回。 我踩过这个坑:loaderDeps: ({ search }) => search看着省事,但任何搜索参数变了都会触发 loader 重载,哪怕那个参数跟数据加载毫无关系。只返回你真正需要的字段。
// ❌ 别这么写
loaderDeps: ({ search }) => search,
// ✅ 只取需要的
loaderDeps: ({ search: { page, limit } }) => ({ page, limit }),
20.5 内置 SWR 缓存
TanStack Router 自带一个 Stale-While-Revalidate 缓存层。loader 返回的数据会被缓存,下次再访问同一路由时:
- 先返回缓存数据(瞬间显示)
- 如果数据过期了,后台重新加载(用户无感知)
缓存的 key 由两部分组成:
- 路由的完整路径名(
/posts/1和/posts/2是不同的缓存) loaderDeps返回的依赖对象(不同分页参数是不同的缓存)
控制缓存新鲜度:staleTime
默认 staleTime 是 0,意味着数据一拿到就过期了。下次进入路由会立即后台刷新。
如果某些数据比较稳定,可以延长新鲜期:
// src/routes/posts.tsx
export const Route = createFileRoute('/posts')({
loader: () => fetchPosts(),
// 10 秒内不再重新加载
staleTime: 10_000,
})
10 秒内反复进出 /posts 路由,loader 不会重新执行,直接用缓存。
也可以在路由器级别设置全局默认值:
const router = createRouter({
routeTree,
// 所有路由默认 30 秒新鲜
defaultStaleTime: 30_000,
})
Note如果想彻底关掉自动刷新(只加载一次),设
staleTime: Infinity。适合不常变化的数据,比如配置信息。
缓存回收:gcTime
路由卸载后,缓存数据还能留多久?默认 30 分钟。30 分钟内重新访问,直接用缓存。超过 30 分钟没访问,缓存被回收。
export const Route = createFileRoute('/posts')({
loader: () => fetchPosts(),
// 卸载后立即清缓存
gcTime: 0,
})
后台刷新 vs 阻塞刷新
默认情况下,数据过期后路由会用旧数据先渲染,后台刷新完再悄悄替换。这叫 staleReloadMode: 'background'。
如果你不想让用户看到旧数据,可以改成阻塞模式:
export const Route = createFileRoute('/posts')({
loader: {
handler: () => fetchPosts(),
// 等新数据加载完再渲染
staleReloadMode: 'blocking',
},
})
阻塞模式下,过期数据会等新数据回来才显示。用户会多等一会儿,但看到的一定是最新的。
Tip大多数场景用默认的
background就好。只有数据时效性要求高(比如股票价格、库存数量)才需要blocking。
20.6 预取:在用户点击之前就加载数据
loader 是在导航发生时执行的。预取(preloading)更进一步:在用户还没点击的时候就把数据加载好。
TanStack Router 支持三种预取策略:
| 策略 | 触发时机 | 适用场景 |
|---|---|---|
intent | 鼠标悬停/触摸链接 | 通用,最常用 |
viewport | 链接进入可视区域 | 长页面,下方链接 |
render | 链接渲染到 DOM | 确定会访问的链接 |
开启 intent 预取
最常用的策略是 intent:用户鼠标悬停在链接上时就开始预取。等用户真正点击时,数据可能已经回来了。
import { createRouter } from '@tanstack/react-router'
const router = createRouter({
routeTree,
// 全局开启 intent 预取
defaultPreload: 'intent',
// 悬停 100ms 后才开始预取(避免划过就触发)
defaultPreloadDelay: 100,
})
defaultPreloadDelay 默认 50ms。设成 100ms 可以减少误触发——用户只是鼠标划过,不一定要点。
Tip
intent预取对桌面端效果最好(鼠标悬停)。移动端靠触摸触发,效果差一些,可以配合viewport用。
viewport 预取
链接滚动到可视区域时预取。适合长列表页面,下方的链接在用户看到时就开始加载:
<Link to="/posts/$postId" params={{ postId: post.id }} preload="viewport">
{post.title}
</Link>
render 预取
链接一渲染就开始预取。最激进,数据量小、确定会访问时才用:
<Link to="/dashboard" preload="render">
仪表盘
</Link>
Warning
render策略会预取所有渲染出来的链接。如果列表里有 100 个链接,就是 100 个预取请求。慎用。
预取数据的新鲜度
预取来的数据在内存里能放多久?默认 30 秒。30 秒内真正导航到该路由,直接用预取的数据,不再重新加载。
const router = createRouter({
routeTree,
defaultPreload: 'intent',
// 预取数据 10 秒后过期
defaultPreloadStaleTime: 10_000,
})
手动预取
有时候你想在代码里主动预取,比如用户填完表单后预取下一步的页面:
import { useRouter } from '@tanstack/react-router'
import { useEffect } from 'react'
function CheckoutForm() {
const router = useRouter()
useEffect(() => {
// 用户进入结账页时,预取确认页的数据
router.preloadRoute({
to: '/checkout/confirm',
})
}, [router])
return <form>{/* 表单内容 */}</form>
}
router.preloadRoute 返回 Promise,预取完成后 resolve。预取失败不会报错给用户,只是静默失败。
20.7 延迟加载:先显示快数据,慢数据后补
有时候一个页面有多个数据源,有的快有的慢。等所有数据都回来才渲染,用户体验差。
TanStack Router 支持延迟加载(Deferred Data Loading):快数据先返回渲染,慢数据在后台继续加载。
用 Await 组件延迟加载
在 loader 里返回一个不 await 的 Promise:
// src/routes/posts.$postId.tsx
import { createFileRoute, Await } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
loader: async () => {
// 慢数据:不 await,直接返回 Promise
const slowDataPromise = fetchComments()
// 快数据:await,等它回来
const post = await fetchPost()
return {
post, // 已 resolve 的数据
comments: slowDataPromise, // 还在 pending 的 Promise
}
},
component: PostDetail,
})
function PostDetail() {
const { post, comments } = Route.useLoaderData()
return (
<article>
{/* 快数据立刻显示 */}
<h1>{post.title}</h1>
<p>{post.content}</p>
{/* 慢数据用 Await 包裹 */}
<Await promise={comments} fallback={<div>加载评论中...</div>}>
{(commentList) => (
<ul>
{commentList.map((comment) => (
<li key={comment.id}>{comment.text}</li>
))}
</ul>
)}
</Await>
</article>
)
}
执行流程:
- 路由匹配 → 执行 loader
fetchComments()发出请求但不等fetchPost()发出请求并等待- post 回来 → loader 返回 → 组件渲染
- 文章内容立刻显示,评论区显示 “加载评论中…”
- comments 回来 → Await 渲染评论列表
Tip
Await组件内部用 Suspense 实现。如果用 React 19,可以直接用use()hook 代替Await。
延迟加载 vs 外部缓存库
如果你用 TanStack Query 管理数据(下一章会讲),延迟加载的方式略有不同。不需要 Await 组件,而是用 prefetchQuery + ensureQueryData:
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ context: { queryClient } }) => {
// 慢数据:prefetchQuery 不 await
queryClient.prefetchQuery(commentsOptions())
// 快数据:ensureQueryData 会 await
await queryClient.ensureQueryData(postOptions())
},
component: PostDetail,
})
function PostDetail() {
// 快数据:useSuspenseQuery 立刻拿到
const post = useSuspenseQuery(postOptions())
return (
<article>
<h1>{post.data.title}</h1>
{/* 慢数据:用 Suspense 包裹 */}
<Suspense fallback={<div>加载评论中...</div>}>
<Comments />
</Suspense>
</article>
)
}
function Comments() {
const comments = useSuspenseQuery(commentsOptions())
return <ul>{comments.data.map(/* ... */)}</ul>
}
这种方式更灵活,因为 TanStack Query 自己管理缓存,loader 只负责触发。
20.8 路由上下文:依赖注入
loader 函数接收的 context 参数,是 TanStack Router 的依赖注入机制。
创建带上下文的根路由
用 createRootRouteWithContext 定义上下文类型:
// src/routes/__root.tsx
import { createRootRouteWithContext } from '@tanstack/react-router'
export const Route = createRootRouteWithContext<{
queryClient: QueryClient
fetchPosts: () => Promise<Post[]>
}>()() // 注意:双括号,这是工厂函数
在创建路由器时提供上下文:
// src/router.tsx
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
import { QueryClient } from '@tanstack/react-query'
const queryClient = new QueryClient()
const router = createRouter({
routeTree,
context: {
queryClient,
fetchPosts,
},
})
在 loader 里使用上下文
// src/routes/posts.tsx
export const Route = createFileRoute('/posts')({
// 从 context 拿 fetchPosts
loader: ({ context: { fetchPosts } }) => fetchPosts(),
component: PostsComponent,
})
用 beforeLoad 扩展子路由上下文
beforeLoad 在 loader 之前执行,可以返回对象合并到上下文里,子路由都能用:
// src/routes/posts.tsx
export const Route = createFileRoute('/posts')({
beforeLoad: () => ({
// 注入到子路由的 context 里
fetchPost: (id: string) => fetch(`/api/posts/${id}`).then((r) => r.json()),
}),
component: PostsLayout,
})
// src/routes/posts.$postId.tsx
export const Route = createFileRoute('/posts/$postId')({
// 子路由能拿到父路由 beforeLoad 注入的 fetchPost
loader: ({ context: { fetchPost }, params: { postId } }) =>
fetchPost(postId),
component: PostDetail,
})
Note上下文从根路由开始,每经过一个
beforeLoad就扩展一次。子路由拿到的是所有父级上下文的合并结果。类型也是自动推导的,context.fetchPost会有正确的类型提示。
beforeLoad 的其他用途
beforeLoad 不只能注入上下文,还能做权限控制:
// src/routes/admin.tsx
export const Route = createFileRoute('/admin')({
beforeLoad: ({ context, location }) => {
const user = context.user
if (!user || user.role !== 'admin') {
// 没权限,重定向到登录页
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
// 有权限,注入用户信息
return { user }
},
component: AdminPanel,
})
beforeLoad 里 throw redirect() 会让路由器跳转。这是做认证和授权的标准方式。
20.9 加载状态与错误处理
pending 组件
loader 执行期间显示什么?默认行为是用 Suspense 挂起。如果 loader 超过 1 秒还没完成,会显示 pendingComponent:
// src/routes/posts.tsx
export const Route = createFileRoute('/posts')({
loader: () => fetchPosts(),
// 1 秒后还没好,显示这个
pendingComponent: () => <div>加载中...</div>,
component: PostsComponent,
})
调整阈值:
export const Route = createFileRoute('/posts')({
loader: () => fetchPosts(),
// 500ms 后显示 pending
pendingMs: 500,
// 至少显示 300ms(避免一闪而过)
pendingMinMs: 300,
pendingComponent: () => <div>加载中...</div>,
})
Tip
pendingMinMs很有用。不加的话,如果数据刚好在阈值后 10ms 回来,用户会看到一个闪烁。设了最小显示时间,loading 动画更自然。
错误处理
loader 抛错怎么办?用 errorComponent:
// src/routes/posts.tsx
import { ErrorComponent } from '@tanstack/react-router'
export const Route = createFileRoute('/posts')({
loader: () => fetchPosts(),
errorComponent: ({ error, reset }) => (
<div>
<p>出错了:{error.message}</p>
<button onClick={reset}>重试</button>
</div>
),
component: PostsComponent,
})
reset 函数会重置错误边界。如果是 loader 报错,用 router.invalidate() 更好——它会重新执行 loader 并重置错误边界:
errorComponent: ({ error }) => {
const router = useRouter()
return (
<div>
<p>出错了:{error.message}</p>
<button onClick={() => router.invalidate()}>重试</button>
</div>
)
}
20.10 小结
这一章覆盖了 TanStack Router 数据加载的核心内容:
- loader 在路由匹配时并行执行,组件渲染前数据就绪
- useLoaderData 读取 loader 返回的数据,类型自动推导
- loaderDeps 声明搜索参数依赖,只取需要的字段
- staleTime 控制数据新鲜度,gcTime 控制缓存回收
- 预取 三种策略:intent(悬停)、viewport(可视区域)、render(渲染即预取)
- Await 延迟加载慢数据,快数据先显示
- context 做依赖注入,beforeLoad 扩展子路由上下文
- pendingComponent 和 errorComponent 处理加载和错误状态
下一章我们把这些和 TanStack Query 结合起来——在 loader 里用 prefetchQuery 预热 Query 缓存,让 Router 和 Query 协同工作。