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 / createRouteaddChildren 拼树)我只在一个不方便上构建插件的场景用过,类型一样安全,但路由一多手动拼树很啰嗦,能上文件式就别手写。

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 状态也能就近声明

每个路由可以单独声明 errorComponentpendingComponent,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 迁过来,几个真实的成本和坑:

  1. 路由定义要整体重写。<Routes><Route> 的 JSX 声明,改成文件式或 createRoute,这部分没法自动转,得手动搬。路由不多还好,几十上百个路由是个体力活。

  2. useParams / useSearchParams 全要替换。 调用点散落各处,得逐个换成 Route.useParams() / Route.useSearch(),并且把原来手动的字符串解析、判空逻辑删掉。删代码是爽的,但要小心别删错。

  3. routeTree.gen.ts 没生成时 IDE 一片红。 第一次拉代码或者插件没跑起来时,所有路由类型都报错,新人会懵。要在 README 里写清楚必须先 dev 一次生成路由树。

  4. search 的默认值和 catch 一定要配。 早期我没加 catch,用户改了 URL 导致 zod 抛错、整页白屏,被测试逮到。现在我的规矩是:search schema 的每个字段要么 optional,要么用 catch 留个退路。

  5. 类型推断重,编辑器偶尔卡。 路由特别多的时候,TS 的推断负担不小,老机器上自动补全会有延迟。把路由按模块拆开、开 autoCodeSplitting 能缓解。

迁移做完之后再看,最大的收获不是性能,是那种"路由相关的低级错误从此进不了生产"的踏实感。当初那个少个 s 的 bug,现在编译器会替我们盯着。对一个改动频繁、人多手杂的项目来说,把错误挪到编译期,比什么都值。