TanStack Router 的类型安全路由:把跳转和参数错误挪到编译期
让我下决心换掉 React Router 的,是一个线上 bug。有人把 /orders/:id 的链接拼成了 /order/${id},少了个 s,跳过去 404。代码评审没看出来,TypeScript 没报错,测试也没覆盖到那个分支,就这么上线了。事后复盘,结论很无力——"以后大家注意一点"。
问题是路由这东西在 React Router 里就是字符串。<Link to={...}> 接受任意字符串,useParams() 返回的永远是 Record<string, string | undefined>,你拿到的每个参数都得自己判空、自己 Number() 转换。整个路由层在类型系统眼里几乎是个黑洞。我想要的很简单:跳错路径编译期就报错,参数类型是确定的。这就是我开始用 TanStack Router 的起点。
先看它解决了什么
TanStack Router 的核心卖点是全链路类型推断。注册过的路由,会被收进一棵类型化的路由树,之后所有跟路由相关的 API 都基于这棵树做推断。
最直观的体验是 Link:
1import { Link } from '@tanstack/react-router' 2 3// to 会自动补全所有已注册的路径,拼错直接红波浪线 4<Link to="/orders/$orderId" params={{ orderId: order.id }}> 5 查看订单 6</Link>
to 的值不是随便填的字符串,编辑器会把所有合法路径列出来给你选。一旦这个路由需要 params,你不传 params 或者传错 key,TS 直接报错。我那个少个 s 的 bug,在这套体系里根本写不出来——/order/$orderId 这个路径不存在,编译就过不去。
useParams 也是有类型的:
1const { orderId } = Route.useParams() // orderId: string,不是 string | undefined
不再需要到处判空。老项目改造时,这一步能省掉一大堆判空的样板代码,顺手不少。
文件式路由:routeTree.gen.ts 自动生成
TanStack Router 有两种组织方式,文件式和代码式。我两种都用过,最后团队统一走文件式。
文件式靠 @tanstack/router-plugin 在构建时扫描 routes 目录,自动生成一个 routeTree.gen.ts。文件名按约定映射到路径:
src/routes/
__root.tsx -> 根布局
index.tsx -> /
orders/
index.tsx -> /orders
$orderId.tsx -> /orders/$orderId
_auth/ -> 布局路由(不产生 URL 段)
settings.tsx -> /settings
Vite 里挂上插件:
1import { defineConfig } from 'vite' 2import react from '@vitejs/plugin-react' 3import { tanstackRouter } from '@tanstack/router-plugin/vite' 4 5export default defineConfig({ 6 plugins: [ 7 tanstackRouter({ target: 'react', autoCodeSplitting: true }), 8 react(), 9 ], 10})
注意 tanstackRouter 要放在 react() 前面。autoCodeSplitting 打开后,每个路由的组件会自动按需分包,不用自己写 lazy。
单个路由文件长这样:
1// src/routes/orders/$orderId.tsx 2import { createFileRoute } from '@tanstack/react-router' 3 4export const Route = createFileRoute('/orders/$orderId')({ 5 component: OrderDetail, 6}) 7 8function OrderDetail() { 9 const { orderId } = Route.useParams() 10 return <div>订单 {orderId}</div> 11}
createFileRoute('/orders/$orderId') 里那个路径字符串,插件会校验它和文件位置是否一致,对不上会报错。routeTree.gen.ts 是生成产物,我们直接 gitignore 掉,让它在 dev/build 时生成;也有团队选择提交它,看偏好。我的建议是 gitignore,省得每次都有一堆 diff 噪音。
代码式路由(手写 createRootRoute / createRoute 再 addChildren 拼树)我只在一个不方便上构建插件的场景用过,类型一样安全,但路由一多手动拼树很啰嗦,能上文件式就别手写。
search params:这才是真正惊艳的地方
params 类型安全很多框架都在做,但 search params(也就是 URL 上 ?key=value 那部分)的类型安全,TanStack Router 是我见过做得最彻底的。
在 React Router 里,query 就是 URLSearchParams,一切都是字符串,分页页码、筛选条件全得手动解析和判空。TanStack Router 让你用 validateSearch 声明并校验 search params,我直接配 zod:
1// src/routes/orders/index.tsx 2import { createFileRoute } from '@tanstack/react-router' 3import { z } from 'zod' 4 5const searchSchema = z.object({ 6 page: z.number().int().min(1).catch(1), 7 status: z.enum(['all', 'paid', 'shipped']).catch('all'), 8 keyword: z.string().optional(), 9}) 10 11export const Route = createFileRoute('/orders/')({ 12 validateSearch: searchSchema, 13 component: OrderList, 14}) 15 16function OrderList() { 17 const { page, status, keyword } = Route.useSearch() 18 // page: number,status: 'all' | 'paid' | 'shipped',类型都精确 19 const navigate = Route.useNavigate() 20 21 return ( 22 <button 23 onClick={() => 24 navigate({ search: (prev) => ({ ...prev, page: prev.page + 1 }) }) 25 } 26 > 27 下一页 28 </button> 29 ) 30}
几个关键点:
useSearch()返回的对象类型完全由 schema 推断出来,page就是number,不用Number(searchParams.get('page'))这套。catch(1)很重要:用户手动改 URL 把page=abc填进去,zod 校验失败时回退到默认值,不会让页面崩。这种健壮性以前要手写一堆防御代码。navigate({ search: (prev) => ... })里prev也是带类型的,更新 search 像更新 state 一样。- 序列化/反序列化是框架处理的。默认是标准 query string,你也能换成 JSON 序列化器来支持嵌套对象、数组这类复杂结构,URL 里存复杂筛选条件不用自己拼。
我们一个复杂的报表页,筛选条件有十几个,全靠 search params 驱动。改成这套之后,所有筛选状态天然可分享、可刷新、可前进后退,而且每个字段都有类型,也都留了默认值撑着。这是我用下来最舍不得放手的能力。
loader 预取数据,配合 TanStack Query
路由级的数据预取,TanStack Router 用 loader。它在路由匹配时就触发,不用等组件挂载,能消掉那种"组件先渲染再 loading 再请求"的瀑布。
1export const Route = createFileRoute('/orders/$orderId')({ 2 loader: async ({ params }) => { 3 return fetchOrder(params.orderId) 4 }, 5 staleTime: 30_000, // 30 秒内重复进入这个路由不重新请求 6 component: OrderDetail, 7}) 8 9function OrderDetail() { 10 const order = Route.useLoaderData() // 类型由 loader 返回值推断 11 return <div>{order.title}</div> 12}
staleTime 控制 loader 数据的新鲜度,避免来回切换路由时反复打接口。
不过 loader 本身的缓存能力比较基础,真正复杂的服务端状态我还是交给 TanStack Query。两者搭配的标准姿势是:在 loader 里 ensureQueryData 预取,组件里用 useQuery 消费同一个 key,享受 Query 的缓存、重试、失效一整套:
1export const Route = createFileRoute('/orders/$orderId')({ 2 loader: ({ context: { queryClient }, params }) => 3 queryClient.ensureQueryData(orderQueryOptions(params.orderId)), 4 component: OrderDetail, 5}) 6 7function OrderDetail() { 8 const { orderId } = Route.useParams() 9 const { data } = useSuspenseQuery(orderQueryOptions(orderId)) 10 return <div>{data.title}</div> 11}
queryClient 是通过 router 的 context 注入的,整个 context 也是带类型的。这套组合下来,路由负责"进页面前把数据备好",Query 负责"数据本身的生命周期",职责很清晰。
beforeLoad 做鉴权重定向
鉴权这种需要在进入路由前拦截的逻辑,放在 beforeLoad:
1import { createFileRoute, redirect } from '@tanstack/react-router' 2 3export const Route = createFileRoute('/_auth')({ 4 beforeLoad: ({ context, location }) => { 5 if (!context.auth.isAuthenticated) { 6 throw redirect({ 7 to: '/login', 8 search: { redirect: location.href }, 9 }) 10 } 11 }, 12})
beforeLoad 在 loader 之前跑,throw redirect(...) 直接重定向,整条路由链不会继续往下加载。把它放在 _auth 布局路由上,所有子路由自动受保护,不用每个页面单独写。登录成功后读 redirect search 再跳回原页面,体验很顺。
嵌套布局和 Outlet:把公共 UI 抽出来
后台系统的页面大多是"侧边栏 + 顶栏 + 内容区"这种结构,公共布局要复用。TanStack Router 用根路由和布局路由 + Outlet 来组织,思路和 React Router 类似,但因为路由树有类型,子路由能不能渲染在某个布局下,结构上是确定的。
根布局放在 __root.tsx:
1// src/routes/__root.tsx 2import { createRootRouteWithContext, Outlet } from '@tanstack/react-router' 3import type { QueryClient } from '@tanstack/react-query' 4 5interface RouterContext { 6 queryClient: QueryClient 7 auth: { isAuthenticated: boolean } 8} 9 10export const Route = createRootRouteWithContext<RouterContext>()({ 11 component: () => ( 12 <div className="app-shell"> 13 <Sidebar /> 14 <main> 15 <Outlet /> 16 </main> 17 </div> 18 ), 19})
createRootRouteWithContext 在根上声明了整棵树共享的 context 类型,前面 loader 里能拿到带类型的 queryClient 就是靠这个。Outlet 是子路由的渲染位。
下划线前缀的路由(_auth)是布局路由——它提供布局和 beforeLoad 这类逻辑,但自身不在 URL 里产生路径段。所以 _auth/settings.tsx 的实际 URL 是 /settings 而不是 /_auth/settings。这个约定一开始容易让人愣一下——没想到布局路由会不出现在 URL 里,但用熟了很顺手:你可以纯粹为了"共享一段鉴权逻辑或一层布局"而建一个布局路由,不用污染 URL。
错误和 pending 状态也能就近声明
每个路由可以单独声明 errorComponent 和 pendingComponent,loader 报错或加载中时分别渲染,不用在组件内部到处写 if (error) / if (loading):
1export const Route = createFileRoute('/orders/$orderId')({ 2 loader: ({ params }) => fetchOrder(params.orderId), 3 pendingComponent: () => <Skeleton />, 4 errorComponent: ({ error }) => <ErrorPanel message={error.message} />, 5 component: OrderDetail, 6})
这套就近声明的方式,让"加载中显示骨架屏、出错显示错误面板"变成了路由配置的一部分,而不是组件里的样板代码。配合 React 的 Suspense 用,加载状态的处理干净了很多。我们项目里以前每个详情页组件头部都有十几行 loading/error 判断,迁移后这部分基本清空了。
和 React Router 7 / Next.js 怎么取舍
这部分得说实话,TanStack Router 不是万能的。
它最强的地方是类型安全,没有之一。 路径、params、search、loader 数据、context 全程有类型,这是 React Router 7 和 Next.js 都达不到的程度。如果你的项目路由复杂、search params 多、团队踩过路由参数的坑,它的价值非常直接。
它的定位是纯客户端路由(SPA)。 虽然现在有 TanStack Start 在做全栈/SSR,但 TanStack Router 本身的主场是客户端渲染。如果你需要的是开箱即用的 SSR、服务端组件、文件式 API 路由、图片优化这一整套,Next.js 仍然是更省心的选择,它的 App Router 在服务端能力上是另一个维度。
生态规模上它明显小于前两者。 遇到问题时能搜到的现成答案少,第三方集成也没那么多,有时候得自己读文档读源码。React Router 历史久、用户基数大,Next.js 背后有 Vercel 和庞大社区,这两点 TanStack Router 暂时比不了。
我的选型逻辑:
- 纯 SPA、重交互的后台/管理类应用,路由和参数复杂 —— TanStack Router,类型安全的收益碾压生态劣势。
- 需要 SSR/SEO、内容型站点、想要全栈一体 —— Next.js。
- 已有 React Router 项目、想平滑升级、不想引入太多新概念 —— 留在 React Router 7,它的 framework mode 也补了 loader 这些能力,只是类型不如 TanStack 强。
迁移成本和踩过的坑
我们从 React Router 迁过来,几个真实的成本和坑:
-
路由定义要整体重写。 从
<Routes><Route>的 JSX 声明,改成文件式或createRoute,这部分没法自动转,得手动搬。路由不多还好,几十上百个路由是个体力活。 -
useParams/useSearchParams全要替换。 调用点散落各处,得逐个换成Route.useParams()/Route.useSearch(),并且把原来手动的字符串解析、判空逻辑删掉。删代码是爽的,但要小心别删错。 -
routeTree.gen.ts没生成时 IDE 一片红。 第一次拉代码或者插件没跑起来时,所有路由类型都报错,新人会懵。要在 README 里写清楚必须先dev一次生成路由树。 -
search 的默认值和
catch一定要配。 早期我没加catch,用户改了 URL 导致 zod 抛错、整页白屏,被测试逮到。现在我的规矩是:search schema 的每个字段要么 optional,要么用catch留个退路。 -
类型推断重,编辑器偶尔卡。 路由特别多的时候,TS 的推断负担不小,老机器上自动补全会有延迟。把路由按模块拆开、开
autoCodeSplitting能缓解。
迁移做完之后再看,最大的收获不是性能,是那种"路由相关的低级错误从此进不了生产"的踏实感。当初那个少个 s 的 bug,现在编译器会替我们盯着。对一个改动频繁、人多手杂的项目来说,把错误挪到编译期,比什么都值。