首页 / TanStack 生态入门教程 / Loader 与数据预取

TanStack 生态入门教程

Loader 与数据预取

本教程共 38 篇 · 第 20 篇 · 更新于 2026-07-27 · 约 15 分钟阅读

TanStackTanStack 生态入门教程TanStack RouterLoader数据预取preloaddeferred路由上下文

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 里的 postId
  • depsloaderDeps 返回的搜索参数依赖
  • 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>
  )
}

pagelimit 变了,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 返回的数据会被缓存,下次再访问同一路由时:

  1. 先返回缓存数据(瞬间显示)
  2. 如果数据过期了,后台重新加载(用户无感知)

缓存的 key 由两部分组成:

  • 路由的完整路径名(/posts/1/posts/2 是不同的缓存)
  • loaderDeps 返回的依赖对象(不同分页参数是不同的缓存)

控制缓存新鲜度:staleTime

默认 staleTime0,意味着数据一拿到就过期了。下次进入路由会立即后台刷新。

如果某些数据比较稳定,可以延长新鲜期:

// 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>
  )
}

执行流程:

  1. 路由匹配 → 执行 loader
  2. fetchComments() 发出请求但不等
  3. fetchPost() 发出请求并等待
  4. post 回来 → loader 返回 → 组件渲染
  5. 文章内容立刻显示,评论区显示 “加载评论中…”
  6. 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,
})

beforeLoadthrow 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 扩展子路由上下文
  • pendingComponenterrorComponent 处理加载和错误状态

下一章我们把这些和 TanStack Query 结合起来——在 loader 里用 prefetchQuery 预热 Query 缓存,让 Router 和 Query 协同工作。