TanStack Router(Vite / SPA):Route Tree / Search / Loader / Prefetch(落地篇)
一句话结论:TanStack Router 的价值不在于“能跳转”,而在于它把路由变成强类型的 route tree(信息架构)+ 可校验的 search params(URL State)+ 可组合的 loader/beforeLoad(进入页面前的副作用边界);当你把它与 TanStack Query 的
queryKey对齐,就能得到一套可预取、可回放、可观测的导航与数据系统。
0. 你应该先确定的 3 个架构问题
在写代码前,先用架构语言把问题定清:
- 哪些状态要进 URL(search params)?(可分享/可回退/可重放)
- 进入页面前要做什么?(鉴权 / 预取 / 参数校验 / 埋点)这些副作用放在哪一层?
- 数据缓存系统是谁?(通常是 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
推荐做法:
- route loader 里:
queryClient.prefetchQuery(queryOptions) - 页面组件里:
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 常见预取点位
Linkhover / intent:预取下一个路由的 loader- 进入列表页:预取首屏数据
- 列表渲染时:对“首屏可见区域”的详情做轻量预取(谨慎)
4.2 预取与体验:配合 Transition/Suspense
- 预取解决“更快拿到数据”
- Transition 解决“切换时保持交互响应、尽量保留旧 UI”
- Loading/Skeleton 解决“拿不到数据时的视觉反馈”
你应该把它们当成一套组合拳,而不是单点优化。
5. 常见坑(强烈建议对照自检)
- search params 不做 schema
- 后果:默认值散落各处、类型错乱、URL 可回放性崩溃
- loader 里直接 setState / 操作 UI
- 后果:路由层与视图层耦合,难以测试与复用
- 建议:loader 只做“准备与约束”,视图由组件渲染
- prefetch 的 queryKey 与 useQuery 不一致
- 后果:明明预取了却没命中,性能优化白做
- 把 URL state 复制到全局 store
- 后果:back/forward 不可回放,状态同步复杂度爆炸
6. Checklist
- 是否为所有 search params 定义 schema(类型 + 默认值 + 兜底)?
- URL state 是否是筛选/分页/排序的唯一真相?
- loader/beforeLoad 是否只承载“进入页面前的约束与准备”(鉴权/预取/埋点)?
- prefetch 是否有预算(命中率、网络、设备)?
- queryKey 是否稳定、可序列化,并且与 URL state 对齐?
关联阅读
- 路由与 URL state 心智模型:
./01-routing-and-url-state-mental-model.md - 状态分层(URL/Server state 边界):
../11-data-layer/01-state-layering-ui-server-url-global.md - TanStack Query 缓存与失效:
../11-data-layer/02-tanstack-query-caching-and-invalidation.md - Transition(导航体验治理):
../09-react-18-in-practice/03-transition-useTransition-useDeferredValue.md