TanStack Router 完全指南
相关文档:TypeScript完全指南(/typescript-wan-quan-zhi-nan/) TanStack Query 完全指南(/tanstack-query-wan-quan-zhi-nan/) Vue3入门(/vue-3-ru-men-zhi-nan/) TanStack Router 是一个完全类型安全的前端路由库,支持 React(稳定)和 Vue(实验性)。与 React Router / Vue Router 的核心区别在于: TanStack Router 的 Search Params 有类型系统和验证支持,这是与其他路由器
官方文档:https://tanstack.com/router/latest
适用版本:TanStack Router 1.x(2026-05-07 核实)
相关文档:TypeScript完全指南 TanStack Query 完全指南 Vue3入门
1. 基础概念
TanStack Router 是什么
TanStack Router 是一个完全类型安全的前端路由库,支持 React(稳定)和 Vue(实验性)。与 React Router / Vue Router 的核心区别在于:
- 路由定义、路径参数、Search Params 全部类型推断
- 内置 Loader(路由级数据预加载)
- 内置 Search Params 序列化和验证(支持 Zod)
- 与 TanStack Query 深度集成
两种路由模式
| 模式 | 说明 |
|---|---|
| 代码式路由 | 手动用 createRoute() 定义路由,适合中小项目 |
| 文件式路由 | 基于文件结构自动生成路由,约定优于配置,推荐大项目使用 |
安装
npm install @tanstack/react-router
# 文件式路由需要额外安装 Vite 插件
npm install -D @tanstack/router-plugin
# 配合 TanStack Query
npm install @tanstack/react-router @tanstack/react-query
2. 代码式路由
完整示例
// src/router.tsx
import {
createRouter,
createRoute,
createRootRoute,
Outlet,
Link,
} from "@tanstack/react-router";
// 1. 定义根路由(公共布局)
const rootRoute = createRootRoute({
component: () => (
<div>
<nav>
<Link to="/">首页</Link>
<Link to="/users">用户列表</Link>
</nav>
<Outlet /> {/* 子路由渲染位置 */}
</div>
),
});
// 2. 定义子路由
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
component: () => <h1>首页</h1>,
});
const usersRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/users",
component: UsersPage,
});
// 带动态参数的路由
const userDetailRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/users/$userId", // $userId 是路径参数
component: UserDetailPage,
});
// 3. 组合路由树
const routeTree = rootRoute.addChildren([
indexRoute,
usersRoute,
userDetailRoute,
]);
// 4. 创建路由器
export const router = createRouter({ routeTree });
// 5. TypeScript 声明(路由类型注册)
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
// src/main.tsx
import { RouterProvider } from "@tanstack/react-router";
import { router } from "./router";
function App() {
return <RouterProvider router={router} />;
}
3. 文件式路由(推荐)
Vite 配置
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
export default defineConfig({
plugins: [
TanStackRouterVite(), // 自动扫描 src/routes/ 生成路由
react(),
],
});
文件结构约定
src/routes/
__root.tsx → 根布局(必须)
index.tsx → /
about.tsx → /about
users/
index.tsx → /users
$userId.tsx → /users/:userId(动态参数)
_layout.tsx → 布局路由(不影响 URL)
_auth/
login.tsx → /login(带 _auth 布局)
(admin)/
dashboard.tsx → /dashboard(括号内不影响 URL)
根布局
// src/routes/__root.tsx
import { createRootRoute, Link, Outlet } from "@tanstack/react-router";
export const Route = createRootRoute({
component: () => (
<>
<nav>
<Link to="/" activeProps={{ style: { fontWeight: "bold" } }}>
首页
</Link>
<Link to="/users">用户</Link>
</nav>
<Outlet />
</>
),
notFoundComponent: () => <div>404 页面不存在</div>,
});
普通页面路由
// src/routes/users/index.tsx
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/users/")({
component: UsersPage,
});
function UsersPage() {
return <div>用户列表</div>;
}
4. 路径参数
// src/routes/users/$userId.tsx
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/users/$userId")({
component: UserDetailPage,
});
function UserDetailPage() {
// 完全类型安全,userId 的类型是 string
const { userId } = Route.useParams();
return <div>用户 ID:{userId}</div>;
}
5. Search Params(查询参数)
TanStack Router 的 Search Params 有类型系统和验证支持,这是与其他路由器的重要区别。
基础使用
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
// 定义 Search Params 的结构和验证
const searchSchema = z.object({
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(20),
keyword: z.string().optional(),
status: z.enum(["active", "inactive"]).optional(),
});
export const Route = createFileRoute("/users/")({
validateSearch: searchSchema, // 传入 zod schema 自动验证
component: UsersPage,
});
function UsersPage() {
// search 完全类型安全
const { page, pageSize, keyword } = Route.useSearch();
const navigate = Route.useNavigate();
const setPage = (newPage: number) => {
navigate({ search: (prev) => ({ ...prev, page: newPage }) });
};
return (
<div>
<p>第 {page} 页</p>
<button onClick={() => setPage(page + 1)}>下一页</button>
</div>
);
}
不使用 Zod 的写法
export const Route = createFileRoute("/users/")({
validateSearch: (search: Record<string, unknown>) => ({
page: Number(search.page ?? 1),
keyword: String(search.keyword ?? ""),
}),
component: UsersPage,
});
6. Loader — 路由级数据预加载
Loader 在组件渲染前执行,确保数据准备好后再渲染页面,避免加载状态闪烁。
基础 Loader
// src/routes/users/$userId.tsx
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/users/$userId")({
loader: async ({ params }) => {
// params.userId 类型安全
const user = await fetchUser(params.userId);
return { user }; // 返回的数据可在组件中通过 Route.useLoaderData() 获取
},
component: UserDetailPage,
pendingComponent: () => <div>加载中...</div>, // loader 执行期间显示
errorComponent: ({ error }) => <div>{error.message}</div>,
});
function UserDetailPage() {
const { user } = Route.useLoaderData();
return <div>{user.name}</div>;
}
与 TanStack Query 配合(推荐)
// src/routes/users/$userId.tsx
import { createFileRoute } from "@tanstack/react-router";
import { queryClient } from "@/lib/queryClient";
import { userQueryOptions } from "@/hooks/useUser";
export const Route = createFileRoute("/users/$userId")({
// 在路由进入时预取数据,存入 QueryClient 缓存
loader: ({ params }) =>
queryClient.ensureQueryData(userQueryOptions(Number(params.userId))),
component: UserDetailPage,
});
function UserDetailPage() {
const { userId } = Route.useParams();
// 因为 loader 已经预取,这里立即有数据,不会 pending
const { data: user } = useQuery(userQueryOptions(Number(userId)));
return <div>{user?.name}</div>;
}
// src/hooks/useUser.ts
export const userQueryOptions = (id: number) =>
queryOptions({
queryKey: ["users", id],
queryFn: () => fetchUser(id),
});
7. 导航
Link 组件
import { Link } from "@tanstack/react-router";
// 基础导航
<Link to="/users">用户列表</Link>
// 带路径参数
<Link to="/users/$userId" params={{ userId: "42" }}>
用户详情
</Link>
// 带 Search Params
<Link to="/users" search={{ page: 2, keyword: "alice" }}>
第 2 页
</Link>
// 激活状态样式
<Link
to="/users"
activeProps={{ className: "text-blue-500 font-bold" }}
inactiveProps={{ className: "text-gray-500" }}
>
用户
</Link>
// 精确匹配(避免父路由也被激活)
<Link to="/" activeOptions={{ exact: true }}>首页</Link>
useNavigate — 编程式导航
import { useNavigate } from "@tanstack/react-router";
function LoginPage() {
const navigate = useNavigate();
const handleLogin = async () => {
await login();
navigate({ to: "/dashboard", replace: true }); // replace 不留历史记录
};
// 带参数
const goToUser = (id: number) => {
navigate({
to: "/users/$userId",
params: { userId: String(id) },
});
};
// 更新 Search Params(保留其他参数)
const setPage = (page: number) => {
navigate({
search: (prev) => ({ ...prev, page }),
});
};
}
8. 布局路由与嵌套路由
布局路由(不影响 URL)
// src/routes/_auth.tsx — 带认证检查的布局路由
import { createFileRoute, redirect, Outlet } from "@tanstack/react-router";
export const Route = createFileRoute("/_auth")({
beforeLoad: ({ context }) => {
if (!context.auth.isAuthenticated) {
throw redirect({ to: "/login" });
}
},
component: () => (
<div className="auth-layout">
<Sidebar />
<Outlet />
</div>
),
});
// src/routes/_auth/dashboard.tsx — 受保护的页面
export const Route = createFileRoute("/_auth/dashboard")({
component: DashboardPage,
});
嵌套路由
// src/routes/users/$userId.tsx — 父路由
export const Route = createFileRoute("/users/$userId")({
component: () => (
<div>
<UserHeader />
<Outlet /> {/* 渲染子路由 */}
</div>
),
});
// src/routes/users/$userId/posts.tsx — 子路由
export const Route = createFileRoute("/users/$userId/posts")({
component: UserPostsPage,
});
9. 路由 Context
Context 用于向路由树传递全局依赖(如 QueryClient、认证状态等),可在 Loader 和 beforeLoad 中访问。
// src/router.tsx
import { createRouter } from "@tanstack/react-router";
interface RouterContext {
queryClient: QueryClient;
auth: AuthState;
}
export const router = createRouter({
routeTree,
context: {
queryClient: undefined!, // 实际值在 App 中提供
auth: undefined!,
},
});
// src/App.tsx
function App() {
const auth = useAuth();
return (
<RouterProvider
router={router}
context={{ queryClient, auth }} // 注入 context
/>
);
}
// 在路由中使用 context
export const Route = createFileRoute("/_auth/dashboard")({
beforeLoad: ({ context }) => {
if (!context.auth.isAuthenticated) {
throw redirect({ to: "/login" });
}
},
loader: ({ context }) =>
context.queryClient.ensureQueryData(dashboardQueryOptions),
});
10. 常用代码段
认证守卫
// src/routes/__root.tsx
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootLayout,
});
// src/routes/_protected.tsx
export const Route = createFileRoute("/_protected")({
beforeLoad: ({ context, location }) => {
if (!context.auth.token) {
throw redirect({
to: "/login",
search: { redirect: location.href }, // 保存来源页面
});
}
},
component: Outlet,
});
登录后跳转原页面
// src/routes/login.tsx
const loginSearchSchema = z.object({
redirect: z.string().optional(),
});
export const Route = createFileRoute("/login")({
validateSearch: loginSearchSchema,
component: LoginPage,
});
function LoginPage() {
const { redirect } = Route.useSearch();
const navigate = useNavigate();
const handleLogin = async () => {
await login();
navigate({ to: redirect ?? "/dashboard", replace: true });
};
}
404 页面
// src/routes/__root.tsx
export const Route = createRootRoute({
component: RootLayout,
notFoundComponent: () => (
<div>
<h1>404 - 页面不存在</h1>
<Link to="/">回到首页</Link>
</div>
),
});
11. 最佳实践
将 queryOptions 与路由 Loader 配合使用
// 定义一次 queryOptions,在路由 loader 预取,在组件中使用,避免重复定义
export const userListQueryOptions = (params: UserListParams) =>
queryOptions({
queryKey: ["users", "list", params],
queryFn: () => fetchUsers(params),
staleTime: 1000 * 60,
});
// 路由 loader
loader: ({ deps }) =>
queryClient.ensureQueryData(userListQueryOptions(deps)),
// 组件内
const { data } = useQuery(userListQueryOptions(params));
Search Params 作为唯一状态来源
将筛选条件、分页、排序等状态都存到 Search Params 中,这样刷新页面、分享链接都能保持状态:
const { page, keyword, status } = Route.useSearch();
// 修改时用 navigate 更新 URL
navigate({ search: (prev) => ({ ...prev, page: newPage }) });
避免在组件内部做路由级数据获取
路由级数据(页面必须的初始数据)放到 Loader,组件级数据(用户交互触发的)用 useQuery:
// Loader:页面进入时必须有的数据
loader: ({ params }) => queryClient.ensureQueryData(userQueryOptions(params.userId)),
// 组件内:用户点击某个 tab 后才需要的数据
const { data: orders } = useQuery({ ...ordersQueryOptions, enabled: activeTab === "orders" });
12. 踩坑与注意事项
路径参数默认都是 string
路径参数($userId)总是字符串类型,需要手动转换为数字:
const { userId } = Route.useParams();
const numericId = Number(userId); // 手动转换
Search Params 使用对象传入时替换所有参数
// 替换所有 search 参数(原来的参数会丢失)
navigate({ search: { page: 2 } });
// 只更新 page,保留其他参数(推荐)
navigate({ search: (prev) => ({ ...prev, page: 2 }) });
文件式路由命名规则
| 文件名 | 路由路径 | 说明 |
|---|---|---|
index.tsx |
/ 或父路径 |
索引路由 |
about.tsx |
/about |
普通路由 |
$id.tsx |
/:id |
动态参数 |
_layout.tsx |
不影响 URL | 布局路由 |
(group) |
不影响 URL | 路由分组 |
_.tsx |
通配符 | 捕获未匹配路由 |
最佳实践
用 validateSearch 为 search params 定义 schema:未验证的 search params 在用户手动修改 URL 时可能传入意外值导致运行时错误。用 zod schema 声明后,TanStack Router 自动验证并提供类型安全。
import { z } from 'zod'
export const Route = createFileRoute('/products')({
validateSearch: z.object({
page: z.number().int().min(1).default(1),
q: z.string().optional(),
}),
})
在 loader 中预取数据,避免组件内 waterfall 请求:loader 在路由匹配时就开始执行,与渲染并行;组件内 useEffect 数据加载在组件挂载后才开始,产生 waterfall(先渲染空壳,再请求数据)。
export const Route = createFileRoute('/users/$id')({
loader: ({ params }) => fetchUser(params.id),
component: () => {
const user = Route.useLoaderData()
return <div>{user.name}</div>
},
})
用 Link 的 preload 属性预加载路由:鼠标悬停时预加载目标路由的 loader 数据,点击时路由切换几乎无感延迟。
<Link to="/users/$id" params={{ id: '1' }} preload="intent">
View Profile
</Link>
类型安全路由用 $inferInput/$inferOutput 获取类型:TanStack Router 的路由参数和 search params 都有完整的 TypeScript 类型,可以从路由定义中直接推导,无需重复定义。
常见陷阱
陷阱:navigate 覆盖 search params 而非更新
现象: 只想更新 page 参数,调用 navigate({ search: { page: 2 } }) 后,其他 search params(如 q)全部消失。
原因: navigate 的 search 参数默认替换所有 search params,不是 merge。
解决: 使用函数式写法保留现有参数。
// 错误:替换全部 search params
navigate({ search: { page: 2 } })
// 正确:保留现有参数
navigate({ search: (prev) => ({ ...prev, page: 2 }) })
陷阱:loader 中抛出的错误没有被 errorComponent 捕获
现象: loader 中 throw new Error("not found") 后,页面白屏而非显示错误 UI。
原因: TanStack Router 只捕获通过 redirect() 或 notFound() 抛出的特殊 throw,普通 Error 对象需要配置 errorComponent。
解决: 在路由定义中加 errorComponent,或统一用 notFound() 抛出 404。
import { notFound } from '@tanstack/react-router'
export const Route = createFileRoute('/users/$id')({
loader: async ({ params }) => {
const user = await fetchUser(params.id)
if (!user) throw notFound() // 正确:触发 notFoundComponent
return user
},
errorComponent: ({ error }) => <div>Error: {error.message}</div>,
notFoundComponent: () => <div>User not found</div>,
})
陷阱:文件式路由命名错误导致路由不生效
现象: 创建了 $userId.tsx 文件但路由没有匹配到 /:userId。
原因: TanStack Router 文件式路由对文件命名格式敏感,动态参数必须用 $ 前缀,不能用 : 或 [] 等其他框架的约定。
解决: 严格按照 TanStack Router 的命名规范:$paramName.tsx;使用 tsr generate 命令生成和验证路由树。