数据加载与 Loader
TanStack Router 的 Loader 机制在路由渲染前预加载数据,支持 SWR(Stale-While-Revalidate)缓存、依赖追踪和错误处理。
Loader 基础
// src/routes/posts/$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, context, location }) => {
// params: { postId: string } — 路径参数
// context: 路由上下文(auth 等)
const post = await fetchPost(params.postId)
return { post }
},
component: PostDetail,
})读取 Loader 数据
function PostDetail() {
// loader 的返回值类型自动推断
const { post } = Route.useLoaderData()
// post 类型完全安全 ✅
return (
<div>
<h1>{post.title}</h1>
<p>{post.content}</p>
</div>
)
}
// 从其他组件访问
import { useLoaderData, getRouteApi } from '@tanstack/react-router'
const routeApi = getRouteApi('/posts/$postId')
function PostTitle() {
const { post } = routeApi.useLoaderData()
return <h1>{post.title}</h1>
}SWR 缓存与 staleTime
Loader 的数据默认被缓存。staleTime 决定缓存何时过期:
export const Route = createFileRoute('/posts/$postId')({
staleTime: 30_000, // 30 秒内重复访问使用缓存,不重新请求
loader: async ({ params }) => {
return fetchPost(params.postId)
},
})SWR 行为:
| 场景 | 行为 |
|---|---|
首次访问 /posts/1 |
执行 loader,缓存结果 |
15 秒后再次访问 /posts/1 |
使用缓存(staleTime 内),同时后台重新获取 |
45 秒后再次访问 /posts/1 |
缓存已过期,重新执行 loader |
访问 /posts/2 |
不同参数,始终执行 loader |
💡 最佳实践:对频繁变化的数据设置较短的
staleTime(5-15 秒),对相对稳定的数据设置较长的staleTime(30 秒到几分钟)。也可以设置为Infinity表示永远不自动过期。
全局默认 staleTime
// router.tsx
const router = createRouter({
routeTree,
defaultStaleTime: 10_000, // 全局默认 10 秒
})预加载缓存
预加载后的数据有独立的过期时间:
const router = createRouter({
routeTree,
defaultPreloadStaleTime: 60_000, // 预加载的数据缓存 1 分钟
})loaderDeps — 精准依赖追踪
默认任何参数变化都会重新执行 loader。用 loaderDeps 限制触发条件:
export const Route = createFileRoute('/posts/')({
validateSearch: z.object({
page: fallback(z.number(), 1).default(1),
sort: z.enum(['newest', 'oldest']).default('newest'),
}),
// 只有 page 变化时才重新加载,sort 变化不触发
loaderDeps: ({ search }) => ({
page: search.page,
}),
loader: async ({ deps }) => {
// deps: { page: number }
return fetchPosts({ page: deps.page })
},
})⚡ 性能提示:
loaderDeps是重要的性能优化手段。如果一个搜索参数只影响客户端排序(如sort),就不应该触发 loader 重新请求。
beforeLoad — 守卫与数据预检查
beforeLoad 在 loader 之前执行,适合做认证检查、重定向和 context 注入:
export const Route = createFileRoute('/dashboard')({
beforeLoad: async ({ context, location }) => {
// 1. 认证守卫
if (!context.auth.isAuthenticated) {
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
// 2. 预加载权限数据到 context
const permissions = await fetchPermissions(context.auth.userId)
return { permissions } // 返回的数据会合并到 context
},
loader: async ({ context }) => {
// context 中已有 permissions
return fetchDashboardData(context.permissions)
},
})beforeLoad 的返回值
beforeLoad 返回的对象会合并到 context 中,供当前路由及所有子路由的 loader 和 beforeLoad 使用:
// 父路由
beforeLoad: async () => {
const org = await fetchOrganization()
return { org } // context.org 对子路由可用
}
// 子路由
loader: async ({ context }) => {
// context.org ✅ 可用
return fetchProjects(context.org.id)
}加载状态与错误处理
pendingComponent
Loader 执行期间可显示加载状态:
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
// 模拟慢速加载
await new Promise((r) => setTimeout(r, 2000))
return fetchPost(params.postId)
},
component: PostDetail,
pendingComponent: PostSkeleton, // loader 执行中显示
})errorComponent
Loader 失败时的错误 UI:
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const res = await fetch(`/api/posts/${params.postId}`)
if (res.status === 404) throw notFound()
if (!res.ok) throw new Error('加载失败')
return res.json()
},
component: PostDetail,
errorComponent: ({ error, reset }) => (
<div className="error-state">
<h2>加载失败</h2>
<p>{error.message}</p>
<button onClick={reset}>重试</button>
</div>
),
})🚨 陷阱:loader 中的错误不会被 React Error Boundary 捕获。必须通过路由的
errorComponent来处理 loader 错误。组件渲染时的错误仍然走 Error Boundary。
notFoundComponent
import { notFound } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId)
if (!post) throw notFound() // 触发 notFoundComponent
return { post }
},
component: PostDetail,
notFoundComponent: () => (
<div>
<h2>文章不存在</h2>
<Link to="/posts">返回列表</Link>
</div>
),
})💡 最佳实践:
notFound()是 TanStack Router 提供的特殊抛出,与redirect()类似。它触发的是路由级notFoundComponent,而非根路由的全局 404。
Loader 中访问其他路由数据
// 子路由访问父路由的 loader 数据
export const Route = createFileRoute('/posts/$postId/comments')({
loader: async ({ params, context }) => {
// 不能直接访问父 loader 数据!
// 需要通过 context 或独立请求
return fetchComments(params.postId)
},
})🚨 陷阱:子路由的 loader 不能直接访问父路由 loader 的返回值。如果子路由需要父路由的数据,有以下选择:
- 在父路由的
beforeLoad中获取并放入 context- 子路由的 loader 中独立请求(配合 HTTP 缓存层)
- 使用全局状态管理(Zustand、Jotai 等)
手动刷新 Loader
function PostDetail() {
const { post } = Route.useLoaderData()
const navigate = useNavigate()
const refresh = () => {
// 方式一:重新导航到当前路由(触发 loader 重新执行)
navigate({ to: '.', replace: true })
}
return (
<div>
<h1>{post.title}</h1>
<button onClick={refresh}>刷新</button>
</div>
)
}
// 方式二:使用 useRouter
import { useRouter } from '@tanstack/react-router'
function RefreshButton() {
const router = useRouter()
return (
<button onClick={() => router.invalidate()}>
清除所有缓存并刷新
</button>
)
}Loader 完整类型参考
type LoaderFn<TContext, TParams, TSearch, TDeps> = (opts: {
params: TParams // 路径参数(解析后)
context: TContext // 路由上下文
location: ParsedLocation // 当前 URL 信息
search: TSearch // 验证后的搜索参数
deps: TDeps // loaderDeps 返回值
cause: 'enter' | 'stay' // 触发原因
preload: boolean // 是否为预加载
navigate: NavigateFn // 导航函数
abortController: AbortController // 导航取消时的信号
}) => MaybePromise<unknown>💡 最佳实践:使用
abortController.signal取消 fetch 请求,避免组件卸载后的无效请求:loader: async ({ params, abortController }) => { const res = await fetch(`/api/posts/${params.postId}`, { signal: abortController.signal, }) }