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-router1.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="..."> 的路径、params、search 都会被编译期检查:路径写错、动态参数漏传、search 字段拼错,都是 TypeScript 报错,而不是运行时的白屏。
2. Search Params 是一等状态容器。 大多数路由库把 query string 当字符串处理,读写要自己 URLSearchParams 加手动解析。TanStack Router 要求每条路由用 validateSearch 声明 search 的 schema,之后 search 就是一个带类型、可校验、有默认值、可结构化序列化的状态源。分页、筛选、排序、弹窗开关这类"应该出现在 URL 里"的状态终于有了正经归宿。
3. 数据加载属于路由,而不是组件。 每条路由可以声明 loader,路由匹配时就开始取数,配合 preload 可以在用户点击之前就把数据取好,避免了"组件挂载 → useEffect → 才开始请求"的瀑布。
代价也要说清楚:它要求你接受一套约定(生成的路由树文件、declare module 注册、按路由组织 loader),初次配置比 react-router 的 createBrowserRouter 繁琐一点;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 | - | 以此为前缀的文件/目录不进路由树 |
quoteStyle | single | 生成代码的引号风格 |
autoCodeSplitting | false | 自动对非关键路由配置项做代码分割;官方说明 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 |
index | 以 index 结尾的路由段在 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>
)
}在 beforeLoad 或 loader 里做跳转时用 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-adapter的zodValidator(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,而是通过 Link 或 navigate 的 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 拿到的参数对象包含 params、deps、context、location、route、abortController、preload(本次是否为预加载)、cause('enter' | 'preload' | 'stay')、parentMatchPromise。
loaderDeps 是这一节最重要的概念。 它决定 loader 何时重新执行。官方文档明确指出一个常见错误:把整个 search 对象原样返回,会导致任何无关 search 字段变化都让缓存失效、loader 重跑。只提取真正用到的字段。
反过来,依赖了 search 却不写 loaderDeps,会让 loader 在 search 变化时不重跑,页面显示旧数据。两个方向都会出问题,写的时候对照一遍 loader 函数体即可。
缓存相关的默认值(1.170.x):
| 场景 | 选项 | 默认值 |
|---|---|---|
| 正常导航 | staleTime | 0(即刻过期,每次导航都重新取) |
| 正常导航 | gcTime | 5 分钟 |
| 预加载 | preloadStaleTime | 30 秒 |
| 预加载 | preloadGcTime | 5 分钟 |
可以在 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 之后,插件会自动把路由中非关键的配置项(组件等)拆包,loader、beforeLoad 这类需要在匹配阶段执行的部分留在主 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 的那套机制。
关于 useQuery 与 useSuspenseQuery 的取舍,站内有更详细的讨论,见下方相关阅读。
十一、新手最容易踩的坑#
- 忘记
declare module '@tanstack/react-router'注册 router 类型——所有类型安全直接失效,而且不会报错,只是悄悄变宽松。 - Vite 插件顺序放反——
tanstackRouter()必须在react()之前。 - loader 依赖 search 却不写
loaderDeps——数据不刷新;反过来把整个search塞进loaderDeps则会过度失效。 - 更新 search 时没有
...prev——其他 query 参数被静默清空。 - 在组件里做鉴权判断而不是
beforeLoad——受保护内容一闪而过。 - 手改
routeTree.gen.ts——下一次生成就被覆盖。 - 继续用
TanStackRouterVite()或旧包名——能跑,但已不是推荐写法。 - 沿用
useEffect取数——会同时失去 preload、pending/error 边界和路由级缓存。 - 认为
staleTime默认不为 0——默认每次导航都重取,需要按数据特性显式调整。
十二、下一步#
- 打开
TanStackRouterDevtools,观察路由匹配、loader 状态和缓存生命周期,比读文档更快建立直觉。 - 把项目里目前存在
useState里、但其实应该出现在 URL 中的状态(分页、筛选、Tab、详情弹窗)逐步迁移到 Search Params。 - 需要 SSR、Server Function 和全栈能力时,再往上走一层到 TanStack Start——它就是以 TanStack Router 为路由基础的。
站内相关阅读#
- TanStack Query v5 基础:从 Server State 到 Query、Mutation 与缓存:Router 负责导航与取数时机,Query 负责 server state 的缓存与同步,两者职责互补。
- Convex + TanStack Query:
useQuery与useSuspenseQuery的选择、原理与场景:搭配 loader 预热时选哪一个 Hook 的判断标准。 - TanStack Start Static Prerendering、经典 SSR 与 Cloudflare Workers Cache:从纯客户端路由走向 SSR 与边缘缓存时的职责边界。
参考资料#
以下链接核验于 2026-08-17,对应 @tanstack/react-router 1.170.x。