跳到主要内容

Router 进阶

如果说文件路由只是入口,那 search paramsloaderpreloading 才是 TanStack Router 真正开始拉开差距的地方。

1. Search Params 在这里不是配角

官方 Overview 里有一句很有代表性的话:在 TanStack Router 里,search params 是一等公民。

这不是宣传口号,背后有几层真实能力:

  • Search params 可以类型安全
  • 可以做 schema validation
  • 可以做自定义解析和序列化
  • 可以支持更复杂的数据结构,不只是平铺字符串

很多项目里,筛选条件、分页、排序、tab、视图模式,本来就应该在 URL 里。放在本地状态里虽然快,但一刷新、复制链接、回退前进就容易出问题。

一个带校验的查询参数例子

import { z } from 'zod'
import { createFileRoute } from '@tanstack/react-router'

const searchSchema = z.object({
page: z.number().catch(1),
sort: z.enum(['newest', 'oldest']).catch('newest'),
keyword: z.string().catch(''),
})

export const Route = createFileRoute('/posts')({
validateSearch: (search) => searchSchema.parse(search),
component: PostsPage,
})

function PostsPage() {
const search = Route.useSearch()

return (
<div>
<p>page: {search.page}</p>
<p>sort: {search.sort}</p>
<p>keyword: {search.keyword}</p>
</div>
)
}

这类写法放在后台筛选页里特别值。你把筛选项复制给同事,大家看到的是同一份状态,不是“我这边点过了但链接里看不出来”。

2. Loader:不只是“发个请求”

Router 的 Loader 不是单纯把 fetch 挂在路由上。

它更接近一种“路由级数据准备”机制:

  • 页面切换前可以先准备数据
  • 数据和路由天然对齐
  • 能和预加载、SSR、缓存策略挂上钩

官方文档还特别强调了 built-in route loaders w/ SWR caching。这表示它不是“每进一次路由就傻跑一次请求”,而是带有 stale-while-revalidate 风格的数据心智。

一个更完整的 loader 配置

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

async function fetchPost(postId: string) {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`)

if (!res.ok) {
throw new Error('文章不存在')
}

return res.json() as Promise<{ id: number; title: string; body: string }>
}

export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
return {
post: await fetchPost(params.postId),
}
},
staleTime: 10_000,
gcTime: 5 * 60_000,
component: PostPage,
})

function PostPage() {
const { post } = Route.useLoaderData()

return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
)
}

3. 这和 TanStack Query 冲突吗

不冲突,官方反而明确写了它是为 TanStack QuerySWR 这类客户端缓存工具设计过的。

更实用的理解是:

  • 路由层的“进入页面前要准备什么”交给 Router
  • 更复杂的异步状态生命周期和跨组件共享,继续交给 Query

一个 Router + Query 的组合写法

import { queryOptions } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'

const postQuery = (postId: string) =>
queryOptions({
queryKey: ['post', postId],
queryFn: () => fetchPost(postId),
})

export const Route = createFileRoute('/posts/$postId')({
loader: async ({ context, params }) => {
await context.queryClient.ensureQueryData(postQuery(params.postId))
},
component: PostPage,
})

这种写法很适合已经把 Query 作为主缓存层的团队。

4. 预加载为什么在真实项目里很值

Router 对预加载这件事做得挺认真,官方把它列成核心能力,不是偶然。

页面切换的体感,很多时候就输赢在这里:

  • 用户鼠标刚悬停时就先拉下一页资源
  • 导航前先准备组件和数据
  • 真点进去时更像“秒开”

Router 级预加载配置

const router = createRouter({
routeTree,
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
})

手动预加载

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

export function PostLink() {
return (
<Link
to="/posts/$postId"
params={{ postId: '42' }}
preload="intent"
>
打开文章
</Link>
)
}

这种体验,放在后台系统、文档站、管理台都很明显。

5. 错误、等待态、阻塞导航

Router 的进阶能力里还有几个项目里很实用的点:

  • error elements
  • pending elements
  • navigation blocking
  • scroll restoration
  • document head management

一个错误边界例子

export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
return { post: await fetchPost(params.postId) }
},
errorComponent: ({ error }) => (
<div>
<h2>文章加载失败</h2>
<pre>{error.message}</pre>
</div>
),
pendingComponent: () => <p>加载中...</p>,
component: PostPage,
})

这说明它不是把自己局限在“URL 到组件映射器”这个老角色里,而是在认真接管页面切换体验。

6. 类型安全真正落在什么地方

很多人一提类型安全,脑子里只有 params.id: string 这种基础场景。

TanStack Router 更有价值的,其实是下面这些地方:

  • <Link />to
  • navigate
  • params
  • search
  • 路由上下文
  • 校验后的参数结果

当你页面越来越多、路由越来越深时,这类能力比“少写几个 if”更值钱。

7. 哪些业务场景特别适合它

后台列表页

因为这类页面天然有:

  • 搜索
  • 排序
  • 分页
  • tab
  • 多视图切换

这些信息都适合进 URL。

文档/知识库

因为预加载、搜索参数和路由树组织都很重要。

大型应用工作台

因为它往往不只是从 A 页面跳到 B 页面,而是要在 URL 上承载大量界面状态。

8. 一个很现实的取舍

Router 这套能力越强,意味着你越应该把“路由是应用状态结构”当真。

如果团队本身并不想在 URL 上承载这么多语义,或者大家更习惯把状态塞进全局 store,那么它的优势就没那么明显。

但如果你刚好已经在跟“分享链接状态不一致”“刷新后丢筛选条件”“导航后状态回不去”这些问题纠缠,那 TanStack Router 会很有说服力。