TanStack Router 入门:文件路由、类型安全导航、Loader 与 Search Params

This article is extracted from the chat log with AI. Please identify it with caution.

AI 参与说明(Agent:/root):本文由 Agent 根据 TanStack Router 官方文档(Quick Start、Installation with Vite、Manual Setup、File Naming Conventions、Data Loading、Search Params、Router Context、Deferred Data Loading)辅助整理并逐项核对配置项、默认值与 API 名称。资料核验于 2026-08-17,对应 @tanstack/react-router 1.170.x。TanStack Router 仍在快速迭代,实施前请以项目 lockfile 与官方文档为准。

适用范围:本文面向第一次使用 TanStack Router 的 React 开发者,环境假定为 React 18+(需要 createRoot)、TypeScript 5.3+、Vite 打包,并采用官方推荐的文件路由(file-based routing)。本文不覆盖 Solid 版本、TanStack Start 的 SSR/Server Function、以及 v1 → v2 的迁移。

需要特别说明的一点:TanStack Router 是纯前端路由库,它自己不提供后端。文中所有 fetch 示例都假定项目已有可用接口。

示例运行说明:本文围绕一个 Posts 应用逐步搭建,假定后端提供两个接口——GET /api/posts?page=<number>&filter=<string> 返回 { items: Post[]; total: number }GET /api/posts/:postId 返回 Post 或 404。把片段放进一个用 Vite 创建的 React + TypeScript 项目、并实现这两个接口后即可复现。示例中的 Post 类型统一为 { id: string; title: string; body: string }

一、先说清楚它和 React Router 的差别在哪#

如果只把 TanStack Router 当作"另一个 React Router",会错过它真正的设计取舍。它主要在三件事上和传统路由库不同:

1. 类型安全不是可选项,而是主线。 路由树被生成为一份 TypeScript 类型(routeTree.gen.ts),再通过 module augmentation 注册给全局。之后 <Link to="..."> 的路径、paramssearch 都会被编译期检查:路径写错、动态参数漏传、search 字段拼错,都是 TypeScript 报错,而不是运行时的白屏。

2. Search Params 是一等状态容器。 大多数路由库把 query string 当字符串处理,读写要自己 URLSearchParams 加手动解析。TanStack Router 要求每条路由用 validateSearch 声明 search 的 schema,之后 search 就是一个带类型、可校验、有默认值、可结构化序列化的状态源。分页、筛选、排序、弹窗开关这类"应该出现在 URL 里"的状态终于有了正经归宿。

3. 数据加载属于路由,而不是组件。 每条路由可以声明 loader,路由匹配时就开始取数,配合 preload 可以在用户点击之前就把数据取好,避免了"组件挂载 → useEffect → 才开始请求"的瀑布。

代价也要说清楚:它要求你接受一套约定(生成的路由树文件、declare module 注册、按路由组织 loader),初次配置比 react-routercreateBrowserRouter 繁琐一点;TypeScript 版本偏低或项目里类型体操较多时,类型推导的编辑器体验也会变慢。如果项目根本不用 TypeScript,TanStack Router 的核心卖点会丢掉一大半。

二、起步:两条路径#

2.1 用官方 CLI 脚手架(推荐)#

npx @tanstack/cli create --router-only

--router-only 表示只装 TanStack Router,不引入完整的 TanStack Start 全栈框架。CLI 会依次询问文件路由还是代码路由、是否用 TypeScript、是否接 Tailwind CSS、工具链选择、是否初始化 Git。

2.2 在已有 Vite 项目里手动接入#

npm install @tanstack/react-router @tanstack/react-router-devtools
npm install -D @tanstack/router-plugin
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { tanstackRouter } from '@tanstack/router-plugin/vite'

export default defineConfig({
  plugins: [
    // '@tanstack/router-plugin' 必须放在 '@vitejs/plugin-react' 之前
    tanstackRouter({
      target: 'react',
      autoCodeSplitting: true,
    }),
    react(),
  ],
})

两个容易踩的点:

  • 插件顺序有要求tanstackRouter() 必须排在 react() 之前,官方文档对此有明确说明。
  • 导出名是 tanstackRouter:早期文档和大量网络示例里的 TanStackRouterVite() 是旧名字。旧包 @tanstack/router-vite-plugin 目前仍被 alias 到 @tanstack/router-plugin/vite,但新项目应直接用后者。

插件的默认配置对多数项目够用,不需要显式传:

选项默认值含义
routesDirectory./src/routes扫描路由文件的目录
generatedRouteTree./src/routeTree.gen.ts生成的路由树文件位置
routeFileIgnorePrefix-以此为前缀的文件/目录不进路由树
quoteStylesingle生成代码的引号风格
autoCodeSplittingfalse自动对非关键路由配置项做代码分割;官方说明 v2 将默认改为 true

routeTree.gen.ts 由插件在开发和构建时自动生成、自动维护,不要手改,并建议在 Prettier / ESLint / Biome 里忽略它。

2.3 最小可运行骨架#

// src/routes/__root.tsx
import { createRootRoute, Link, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'

function RootLayout() {
  return (
    <>
      <nav style={{ display: 'flex', gap: 8, padding: 8 }}>
        <Link to="/">Home</Link>
        <Link to="/posts">Posts</Link>
      </nav>
      <hr />
      <Outlet />
      <TanStackRouterDevtools />
    </>
  )
}

export const Route = createRootRoute({ component: RootLayout })
// src/routes/index.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/')({
  component: Index,
})

function Index() {
  return <h3>Welcome Home!</h3>
}
// src/main.tsx
import { StrictMode } from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'

const router = createRouter({ routeTree })

// 关键:把 router 的类型注册给全局,Link / useSearch / useParams 才有精确类型
declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}

const rootElement = document.getElementById('root')!
if (!rootElement.innerHTML) {
  ReactDOM.createRoot(rootElement).render(
    <StrictMode>
      <RouterProvider router={router} />
    </StrictMode>,
  )
}

那段 declare module整个类型安全体系的开关。漏掉它,<Link to> 会退化成宽松的 string,所有"编译期发现路由写错"的收益都没了。这是新手最常见的第一个坑。

三、文件路由的命名约定#

文件名里的特殊 token 决定了路由结构。以下是官方 File Naming Conventions 的完整列表:

Token含义
__root.tsx根路由文件,必须放在 routesDirectory 根部,包裹全部路由
.(分隔符)表示嵌套。blog.post.tsx 会作为 blog 的子路由生成
$(前缀)参数化路径段,从 URL pathname 里提取为 route param
_(前缀)pathless layout route:只提供布局,不参与子路由与 URL 的匹配
_(后缀)把该路由排除在任何父路由的嵌套之外
-(前缀)文件和目录被排除出路由树,不写进 routeTree.gen.ts,用于在路由目录里就近放置辅助逻辑
(folder)route group:目录名不进入 URL 路径,仅用于组织
[x]转义。script[.]js.tsx 生成 /script.js
indexindex 结尾的路由段在 URL 完全等于父路由时匹配
route(后缀)用目录组织路由时,route.tsx 在该目录路径上创建路由文件

前缀 _ 和后缀 _ 只差一个位置、含义完全相反,值得在第一次用时特别留意:前缀是"我只当布局,不占 URL 段",后缀是"我不要被父路由套进去"。

一个混合写法的例子:

src/routes/
  __root.tsx                 // 全局布局
  index.tsx                  // /
  posts.tsx                  // /posts 的布局(有 <Outlet />)
  posts.index.tsx            // /posts
  posts.$postId.tsx          // /posts/:postId
  _authenticated.tsx         // pathless layout:鉴权,不占 URL 段
  _authenticated/
    dashboard.tsx            // /dashboard(受鉴权保护)
  -utils/
    format.ts                // 不进路由树的就近工具函数

-utils/ 那一条很实用:以往为了避免工具文件被当成路由,只能把它们挪出 routes/ 目录;有了 - 前缀就可以和路由放在一起。

四、类型安全导航#

import { Link } from '@tanstack/react-router'

// 静态路由
<Link to="/about">About</Link>

// 动态参数:漏传或写错 params key 会是 TypeScript 错误
<Link to="/posts/$postId" params={{ postId: post.id }}>
  {post.title}
</Link>

// 激活态样式
<Link
  to="/posts"
  activeProps={{ className: 'font-bold' }}
  activeOptions={{ exact: true }}
>
  Posts
</Link>

// 预加载:鼠标悬停/聚焦意图触发
<Link to="/posts" preload="intent">Posts</Link>

编程式导航:

import { useNavigate } from '@tanstack/react-router'

function CreatePostButton() {
  const navigate = useNavigate()
  return (
    <button onClick={() => navigate({ to: '/posts/$postId', params: { postId: 'new' } })}>
      新建
    </button>
  )
}

beforeLoadloader 里做跳转时用 redirect,注意它是 throw 出去的:

import { redirect } from '@tanstack/react-router'

throw redirect({ to: '/login', search: { redirect: location.href } })

五、Search Params:把 URL 当作类型化的状态源#

这是 TanStack Router 最值得单独学的部分。先声明 schema:

// src/routes/posts.index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'

const postSearchSchema = z.object({
  page: z.number().default(1),
  filter: z.string().default(''),
  sort: z.enum(['newest', 'oldest']).default('newest'),
})

export const Route = createFileRoute('/posts/')({
  validateSearch: postSearchSchema,
  component: PostList,
})

关于校验库有一个版本差异必须记住:

  • Zod v4:schema 直接传给 validateSearch
  • Zod v3:需要 @tanstack/zod-adapterzodValidator(schema) 包一层。
  • 其他支持 Standard Schema 的库(如 Valibot)无需 adapter,直接传。

不想引入校验库时,手写一个纯函数也完全可以:

type ProductSearch = { page: number; filter: string }

export const Route = createFileRoute('/shop/products')({
  validateSearch: (search: Record<string, unknown>): ProductSearch => ({
    page: Number(search?.page ?? 1),
    filter: (search.filter as string) || '',
  }),
})

读取:

function PostList() {
  const { page, filter, sort } = Route.useSearch() // 已校验、已带默认值、有精确类型
  // ...
}

在非路由组件里读,用 getRouteApi 或给 useSearch 指定 from

import { getRouteApi, useSearch } from '@tanstack/react-router'

const routeApi = getRouteApi('/posts/')

function Pagination() {
  const { page } = routeApi.useSearch()
  // 等价写法:useSearch({ from: '/posts/' })
}

更新时不要直接改 URL,而是通过 Linknavigate 的 updater 函数:

// 用 Link
<Link to="." search={(prev) => ({ ...prev, page: prev.page + 1 })}>
  下一页
</Link>

// 用 navigate
const navigate = useNavigate({ from: Route.fullPath })
navigate({ search: (prev) => ({ ...prev, page: prev.page + 1 }) })

to="." 表示"停在当前路由、只改 search",写通用分页组件时特别有用。这里也有一个高频错误:search: { page: 2 }替换整个 search 对象、丢掉其他字段;要保留就得显式 ...prev

六、Loader:让取数发生在路由层#

// src/routes/posts.index.tsx
export const Route = createFileRoute('/posts/')({
  validateSearch: postSearchSchema,
  // 只声明 loader 真正用到的依赖
  loaderDeps: ({ search: { page, filter } }) => ({ page, filter }),
  loader: ({ deps: { page, filter } }) => fetchPosts({ page, filter }),
  component: PostList,
})

function PostList() {
  const { items, total } = Route.useLoaderData()
  // ...
}

loader 拿到的参数对象包含 paramsdepscontextlocationrouteabortControllerpreload(本次是否为预加载)、cause'enter' | 'preload' | 'stay')、parentMatchPromise

loaderDeps 是这一节最重要的概念。 它决定 loader 何时重新执行。官方文档明确指出一个常见错误:把整个 search 对象原样返回,会导致任何无关 search 字段变化都让缓存失效、loader 重跑。只提取真正用到的字段。

反过来,依赖了 search 却不写 loaderDeps,会让 loader 在 search 变化时不重跑,页面显示旧数据。两个方向都会出问题,写的时候对照一遍 loader 函数体即可。

缓存相关的默认值(1.170.x):

场景选项默认值
正常导航staleTime0(即刻过期,每次导航都重新取)
正常导航gcTime5 分钟
预加载preloadStaleTime30 秒
预加载preloadGcTime5 分钟

可以在 router 层设默认,也可以逐路由覆盖:

const router = createRouter({
  routeTree,
  defaultPreload: 'intent',
  defaultStaleTime: 0,
  defaultPreloadStaleTime: 30_000,
  defaultGcTime: 5 * 60 * 1000,
})

staleTime 默认为 0 意味着默认行为是每次进入路由都重新请求。这符合"数据要新"的直觉,但在返回上一页这类场景下会造成多余请求,需要按数据的变化频率显式调大。

部分数据慢:流式返回#

不必让整页等最慢的那个接口。loader 里返回一个未 await 的 Promise,组件侧用 Await 消费:

export const Route = createFileRoute('/dashboard')({
  loader: async () => {
    const fastData = await fetchFast()
    const deferredSlowData = fetchSlow() // 注意:不 await
    return { fastData, deferredSlowData }
  },
})

function Dashboard() {
  const { fastData, deferredSlowData } = Route.useLoaderData()
  return (
    <>
      <Fast data={fastData} />
      <Await promise={deferredSlowData} fallback={<div>Loading...</div>}>
        {(data) => <Slow data={data} />}
      </Await>
    </>
  )
}

React 19 下也可以用 use() hook 代替 Await。老资料里那个显式的 defer() 包装函数在当前文档的推荐写法中已经不需要了。

七、Pending、Error 与 404#

import { createFileRoute, notFound } from '@tanstack/react-router'

export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params }) => {
    const post = await fetchPost(params.postId)
    if (!post) throw notFound()
    return post
  },
  pendingComponent: () => <div>加载中…</div>,
  errorComponent: ({ error }) => <div>出错了:{String(error)}</div>,
  notFoundComponent: () => <div>这篇文章不存在</div>,
  component: PostDetail,
})

throw notFound() 而不是在组件里 if (!post) return <NotFound />:前者让路由层接管,notFoundComponent、SSR 状态码、devtools 都能正确反映;后者只是渲染了一段像 404 的 UI。

八、Router Context 与鉴权#

Context 是 TanStack Router 的依赖注入机制,适合放 queryClient、auth 状态这类全局依赖。

// src/routes/__root.tsx
import { createRootRouteWithContext } from '@tanstack/react-router'
import type { QueryClient } from '@tanstack/react-query'

interface MyRouterContext {
  queryClient: QueryClient
  auth: AuthState
}

export const Route = createRootRouteWithContext<MyRouterContext>()({
  component: RootLayout,
})
// src/main.tsx
const router = createRouter({
  routeTree,
  context: { queryClient, auth: undefined! }, // auth 在 RouterProvider 处补齐
})

function App() {
  const auth = useAuth()
  return <RouterProvider router={router} context={{ auth }} />
}

注意泛型参数只需要包含直接传给 createRouter 的那部分;在 beforeLoad 里追加的 context 会被自动推导,不用写进接口。

鉴权用 pathless layout route 集中处理:

// src/routes/_authenticated.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated')({
  beforeLoad: ({ context, location }) => {
    if (!context.auth.isAuthenticated) {
      throw redirect({ to: '/login', search: { redirect: location.href } })
    }
  },
})

之后 src/routes/_authenticated/ 下的所有路由自动受保护,URL 里不会出现 _authenticated 这一段。

把鉴权放在 beforeLoad 而不是组件里是这里的关键。组件里判断意味着受保护内容已经开始渲染,会出现一闪而过的泄露;beforeLoad 在匹配阶段就完成跳转。

九、代码分割与预加载#

开启了 autoCodeSplitting: true 之后,插件会自动把路由中非关键的配置项(组件等)拆包,loaderbeforeLoad 这类需要在匹配阶段执行的部分留在主 chunk 里。多数项目到这一步就够了,不需要手动写 .lazy.tsx

预加载的几档策略:

const router = createRouter({
  routeTree,
  defaultPreload: 'intent', // 'intent' | 'viewport' | 'render' | false
})

<Link to="/posts" preload="intent" preloadDelay={100}>Posts</Link>

intent(悬停或聚焦)是性价比最高的默认值:用户产生点击意图时开始取数,配合 30 秒的 preloadStaleTime,真正点进去时通常已经有数据了。

十、和 TanStack Query 搭配#

Router 的 loader 和 TanStack Query 的缓存不是二选一,标准做法是用 loader 触发预热、用 Query 负责组件里的响应式订阅

import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'

const postsQueryOptions = (page: number) =>
  queryOptions({
    queryKey: ['posts', page],
    queryFn: () => fetchPosts({ page }),
  })

export const Route = createFileRoute('/posts/')({
  validateSearch: postSearchSchema,
  loaderDeps: ({ search: { page } }) => ({ page }),
  loader: ({ context: { queryClient }, deps: { page } }) =>
    queryClient.ensureQueryData(postsQueryOptions(page)),
  component: PostList,
})

function PostList() {
  const { page } = Route.useSearch()
  const { data } = useSuspenseQuery(postsQueryOptions(page)) // 命中 loader 预热的缓存
  return <List items={data.items} />
}

ensureQueryData 的语义是"确保缓存里有数据":新鲜就直接返回、不发请求。这样导航阶段完成取数,组件挂载时不再有 loading 瀑布,同时后续的失效、重试、mutation 后同步仍然走 TanStack Query 的那套机制。

关于 useQueryuseSuspenseQuery 的取舍,站内有更详细的讨论,见下方相关阅读。

十一、新手最容易踩的坑#

  1. 忘记 declare module '@tanstack/react-router' 注册 router 类型——所有类型安全直接失效,而且不会报错,只是悄悄变宽松。
  2. Vite 插件顺序放反——tanstackRouter() 必须在 react() 之前。
  3. loader 依赖 search 却不写 loaderDeps——数据不刷新;反过来把整个 search 塞进 loaderDeps 则会过度失效。
  4. 更新 search 时没有 ...prev——其他 query 参数被静默清空。
  5. 在组件里做鉴权判断而不是 beforeLoad——受保护内容一闪而过。
  6. 手改 routeTree.gen.ts——下一次生成就被覆盖。
  7. 继续用 TanStackRouterVite() 或旧包名——能跑,但已不是推荐写法。
  8. 沿用 useEffect 取数——会同时失去 preload、pending/error 边界和路由级缓存。
  9. 认为 staleTime 默认不为 0——默认每次导航都重取,需要按数据特性显式调整。

十二、下一步#

  • 打开 TanStackRouterDevtools,观察路由匹配、loader 状态和缓存生命周期,比读文档更快建立直觉。
  • 把项目里目前存在 useState 里、但其实应该出现在 URL 中的状态(分页、筛选、Tab、详情弹窗)逐步迁移到 Search Params。
  • 需要 SSR、Server Function 和全栈能力时,再往上走一层到 TanStack Start——它就是以 TanStack Router 为路由基础的。

站内相关阅读#

参考资料#

以下链接核验于 2026-08-17,对应 @tanstack/react-router 1.170.x。

本文共 5578 字,创建于 Aug 17, 2026

相关标签: React, Frontend, TypeScript, TanStack, ByAI