Notes

TanStack Router(Vite / SPA):Route Tree / Search / Loader / Prefetch(落地篇)

一句话结论:TanStack Router 的价值不在于“能跳转”,而在于它把路由变成强类型的 route tree(信息架构)+ 可校验的 search params(URL State)+ 可组合的 loader/beforeLoad(进入页面前的副作用边界);当你把它与 TanStack Query 的 queryKey 对齐,就能得到一套可预取、可回放、可观测的导航与数据系统。

0. 你应该先确定的 3 个架构问题

在写代码前,先用架构语言把问题定清:

  1. 哪些状态要进 URL(search params)?(可分享/可回退/可重放)
  2. 进入页面前要做什么?(鉴权 / 预取 / 参数校验 / 埋点)这些副作用放在哪一层?
  3. 数据缓存系统是谁?(通常是 TanStack Query)路由层只负责“条件与边界”,不要重复造缓存。

这 3 个问题如果没先定清,路由会很快变成“到处 useEffect + 到处同步”的泥潭。

1. Route Tree:把信息架构做成“可维护的树”

1.1 Route Tree 的意义

Route Tree 不是形式主义,它直接决定:

  • layout/shell 如何复用(哪些页面共享导航栏/侧边栏/权限边界)
  • URL 结构是否稳定(SEO/分享/可读性)
  • 权限/埋点/错误边界能否在“路由级”统一

1.2 TanStack Router 的常见组织方式(概念)

  • rootRoute:应用壳(全局 layout、全局错误边界、全局 context)
  • indexRoute:默认子路由
  • segment routes:按业务域拆分(例如 /orders/*/users/*

工程建议:

  • 按业务域拆 route modules,不要把所有路由写一个文件
  • 在 route module 内维护该域的:
    • search schema(URL state)
    • loader(prefetch/鉴权)
    • 与 Query key 对齐的 queryOptions

2. Search Params:URL State 的“类型系统与默认值系统”

2.1 为什么 search params 必须 schema 化?

在真实项目里,如果 search params 不做 schema:

  • page 是 string 还是 number?
  • 缺省时默认是 1 还是 0?
  • sort 出现未知枚举值怎么办?
  • 用户手改 URL 导致页面崩溃怎么办?

结论:search params 必须是“可校验 + 可默认 + 可序列化”的。

2.2 推荐模式:用 schema 定义 URL state

伪代码示意(重点是模式,不强依赖某个 schema 库):

// 以 zod 为例(也可以 valibot / arkType / 自己写 parser)
import { z } from 'zod'

export const productsSearchSchema = z.object({
  keyword: z.string().optional().default(''),
  page: z.coerce.number().int().min(1).default(1),
  sort: z.enum(['relevance', 'price_asc', 'price_desc']).default('relevance'),
})

export type ProductsSearch = z.infer<typeof productsSearchSchema>

配合路由:

  • URL 缺字段 → 自动补默认值
  • URL 字段类型不对 → 统一纠正/兜底(而不是组件里到处 Number()

这一步做对了,你的“可分享/可回退/可回放”能力才算真正成立。

3. Loader / beforeLoad:进入页面前的“副作用边界”

3.1 为什么需要 loader?

loader(或 beforeLoad)用于承载:

  • 参数校验(search/schema)
  • 鉴权(没权限就 redirect)
  • 数据预取(prefetch)
  • 进入页面前的埋点(route view)

它的核心价值是:把这些逻辑从组件的 useEffect 中抽离出来,变成“路由生命周期的一部分”。

3.2 与 TanStack Query 的协作:loader 做 prefetch,组件做 useQuery

推荐做法:

  1. route loader 里:queryClient.prefetchQuery(queryOptions)
  2. 页面组件里:useQuery(queryOptions) 直接命中缓存

示意:

// products.route.ts
import { queryOptions } from '@tanstack/react-query'

export const productsQueryOptions = (search: ProductsSearch) =>
  queryOptions({
    queryKey: ['products', search],
    queryFn: () => api.products.list(search),
  })

export const route = new Route({
  // ...
  loader: async ({ context, search }) => {
    await context.queryClient.prefetchQuery(productsQueryOptions(search))
  },
})

页面:

function ProductsPage() {
  const search = Route.useSearch()
  const { data, isLoading } = useQuery(productsQueryOptions(search))
  // ...
}

关键约束:

  • prefetch 与 useQuery 必须复用同一套 queryKey,否则预取不命中
  • search(URL state)是 queryKey 的输入真相,避免多源同步

4. Prefetch:导航性能治理的“预算系统”

Prefetch 不是越多越好,它需要预算:

  • hover 预取:适合详情页、小数据
  • 进入页面前预取:适合主列表(命中率高)
  • 低网速/移动端:要降低预取量(避免把带宽打满)

4.1 常见预取点位

  • Link hover / intent:预取下一个路由的 loader
  • 进入列表页:预取首屏数据
  • 列表渲染时:对“首屏可见区域”的详情做轻量预取(谨慎)

4.2 预取与体验:配合 Transition/Suspense

  • 预取解决“更快拿到数据”
  • Transition 解决“切换时保持交互响应、尽量保留旧 UI”
  • Loading/Skeleton 解决“拿不到数据时的视觉反馈”

你应该把它们当成一套组合拳,而不是单点优化。

5. 常见坑(强烈建议对照自检)

  1. search params 不做 schema
  • 后果:默认值散落各处、类型错乱、URL 可回放性崩溃
  1. loader 里直接 setState / 操作 UI
  • 后果:路由层与视图层耦合,难以测试与复用
  • 建议:loader 只做“准备与约束”,视图由组件渲染
  1. prefetch 的 queryKey 与 useQuery 不一致
  • 后果:明明预取了却没命中,性能优化白做
  1. 把 URL state 复制到全局 store
  • 后果:back/forward 不可回放,状态同步复杂度爆炸

6. Checklist

  • 是否为所有 search params 定义 schema(类型 + 默认值 + 兜底)?
  • URL state 是否是筛选/分页/排序的唯一真相?
  • loader/beforeLoad 是否只承载“进入页面前的约束与准备”(鉴权/预取/埋点)?
  • prefetch 是否有预算(命中率、网络、设备)?
  • queryKey 是否稳定、可序列化,并且与 URL state 对齐?

关联阅读

cd ..