路由参数与搜索参数
TanStack Router 将 URL 参数分为两类并提供完整的类型安全:路径参数(:param)和搜索参数(?key=value)。搜索参数是 TanStack Router 的一大亮点——它们被视为"JSON 序列化的应用状态",而非简单的字符串。
路径参数(Path Params)
定义
使用 $ 前缀在文件名中定义动态路径段:
// src/routes/users/$userId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/users/$userId')({
component: UserProfile,
})读取路径参数
function UserProfile() {
// 类型自动推断:{ userId: string }
const { userId } = Route.useParams()
return <div>用户 ID: {userId}</div>
}
// 或者从其他组件访问
import { useParams } from '@tanstack/react-router'
function UserAvatar() {
const { userId } = useParams({ from: '/users/$userId' })
}参数解析与序列化
默认情况下路径参数都是 string。你可以通过 parse/stringify 进行类型转换:
export const Route = createFileRoute('/users/$userId')({
params: {
parse: (params) => ({
userId: Number(params.userId), // string → number
}),
stringify: ({ userId }) => ({
userId: String(userId), // number → string
}),
},
component: UserProfile,
})
function UserProfile() {
const { userId } = Route.useParams()
// userId 现在类型为 number ✅
}💡 最佳实践:
parse和stringify必须是双向可逆的——parse(stringify(x))应等于x。如果parse失败(如NaN),路由会跳转到notFoundComponent。
搜索参数(Search Params)
搜索参数在 TanStack Router 中是一等公民——它们被自动 JSON 解析,支持类型验证,并且可以从父路由继承。
基础验证
// src/routes/posts/index.tsx
export const Route = createFileRoute('/posts/')({
validateSearch: (search: Record<string, unknown>) => {
return {
page: Number(search.page) || 1,
filter: String(search.filter || ''),
sort: (search.sort as 'newest' | 'oldest') || 'newest',
}
},
component: PostsPage,
})
function PostsPage() {
// 类型完全推断,无需手动标注
const { page, filter, sort } = Route.useSearch()
// page: number
// filter: string
// sort: 'newest' | 'oldest'
}Zod 验证(推荐)
使用 @tanstack/zod-adapter 获得更强大的验证:
npm install zod @tanstack/zod-adapterimport { z } from 'zod'
import { fallback, zodValidator } from '@tanstack/zod-adapter'
const searchSchema = z.object({
page: fallback(z.number(), 1).default(1),
filter: z.string().default(''),
sort: z.enum(['newest', 'oldest', 'price']).default('newest'),
})
export const Route = createFileRoute('/products')({
validateSearch: zodValidator(searchSchema),
component: ProductsPage,
})🚨 陷阱(Zod v3):务必使用
fallback()而非.catch()。Zod v3 的.catch()会使输出类型变为unknown,破坏类型推断。Zod v4 已修复此问题。
读取搜索参数
function ProductsPage() {
const { page, filter, sort } = Route.useSearch()
// 全部类型安全,带默认值
return (
<div>
<span>当前页: {page}</span>
<span>排序: {sort}</span>
</div>
)
}多种访问方式
// 方式一:Route.useSearch()(最推荐,最类型安全)
const search = Route.useSearch()
// 方式二:getRouteApi(跨组件访问)
import { getRouteApi } from '@tanstack/react-router'
const routeApi = getRouteApi('/products')
const search = routeApi.useSearch()
// 方式三:通用 Hook
import { useSearch } from '@tanstack/react-router'
const search = useSearch({ from: '/products' })
// 方式四:宽松模式(跨路由共享搜索参数)
const search = useSearch({ strict: false })
// 返回值类型是宽松的,key 对应的类型是各路由的联合
搜索参数的继承
子路由自动继承所有祖先路由的搜索参数:
// __root.tsx — 定义全局搜索参数
export const Route = createRootRoute({
validateSearch: z.object({
theme: z.enum(['light', 'dark']).default('light'),
utm_source: z.string().optional(),
}),
})
// posts/index.tsx — 自动继承 theme 和 utm_source
export const Route = createFileRoute('/posts/')({
validateSearch: z.object({
page: fallback(z.number(), 1).default(1),
// theme 自动可用!无需重复定义
}),
})
function PostsPage() {
const search = Route.useSearch()
// search: { page: number; theme: 'light' | 'dark'; utm_source?: string }
search.theme // ✅ 可用
}🔬 深入原理:搜索参数的继承是在构建时由 Vite 插件计算的。路由树生成时会合并祖先链上所有的
validateSearch定义。如果祖先路由没有定义validateSearch,子路由就无法继承任何参数。
搜索参数中间件(Middlewares)
retainSearchParams — 跨导航保留参数
import { retainSearchParams } from '@tanstack/react-router'
export const Route = createRootRoute({
validateSearch: z.object({
debug: z.boolean().optional(),
theme: z.enum(['light', 'dark']).default('light'),
}),
search: {
middlewares: [retainSearchParams(['debug', 'theme'])],
},
})
// debug 和 theme 参数在所有导航中都会被保留
stripSearchParams — 移除默认值
import { stripSearchParams } from '@tanstack/react-router'
const defaults = { sort: 'newest' as const, page: 1 }
export const Route = createFileRoute('/items')({
validateSearch: searchSchema,
search: {
middlewares: [stripSearchParams(defaults)],
},
})
// 当 sort='newest' 且 page=1 时,这些参数不会出现在 URL 中
loaderDeps — 精确控制 Loader 触发
默认情况下,任何搜索参数变化都会重新执行 loader。用 loaderDeps 精确控制:
export const Route = createFileRoute('/posts/')({
validateSearch: postSearchSchema,
// 只有当 page 变化时才重新执行 loader
// sort、filter 等变化不会触发 loader
loaderDeps: ({ search }) => ({
page: search.page,
}),
loader: async ({ deps }) => {
return fetchPosts({ page: deps.page })
},
})⚡ 性能提示:始终使用
loaderDeps选择 loader 真正依赖的字段。传入整个search对象会导致每次搜索参数变化都重新请求,浪费资源。
常见模式
列表分页(配合函数式更新)
function Pagination() {
const navigate = useNavigate({ from: '/posts/' })
const { page } = Route.useSearch()
return (
<div>
<button
onClick={() =>
navigate({ search: (prev) => ({ ...prev, page: page - 1 }) })
}
disabled={page <= 1}
>
上一页
</button>
<span>第 {page} 页</span>
<button
onClick={() =>
navigate({ search: (prev) => ({ ...prev, page: page + 1 }) })
}
>
下一页
</button>
</div>
)
}排序切换
function SortSelect() {
const navigate = useNavigate({ from: '/products' })
const { sort, page } = Route.useSearch()
return (
<select
value={sort}
onChange={(e) =>
navigate({
search: (prev) => ({
...prev,
sort: e.target.value as 'newest' | 'oldest',
page: 1, // 排序变化时重置到第一页
}),
})
}
>
<option value="newest">最新</option>
<option value="oldest">最旧</option>
</select>
)
}搜索过滤
function SearchFilter() {
const navigate = useNavigate({ from: '/products' })
const { filter, page } = Route.useSearch()
return (
<input
value={filter}
placeholder="搜索..."
onChange={(e) => {
// 防抖处理在实际项目中很重要
navigate({
search: (prev) => ({
...prev,
filter: e.target.value,
page: 1, // 过滤条件变化时重置到第一页
}),
replace: true, // 避免每个按键都产生历史记录
})
}}
/>
)
}路径参数 vs 搜索参数 选择指南
| 维度 | 路径参数 ($param) |
搜索参数 (?key=value) |
|---|---|---|
| 语义 | 标识"哪个资源" | 描述"如何展示" |
| 必填性 | 必填(路径结构的一部分) | 可选(可设默认值) |
| 例子 | /users/42, /posts/hello-world |
?page=2, ?sort=newest |
| SEO | 对搜索引擎更友好 | 通常被忽略 |
| 类型 | 通过 parse/stringify 转换 |
通过 validateSearch 验证 |
| 继承 | 不继承 | 自动从祖先路由继承 |
💡 最佳实践:用路径参数标识资源 ID(用户、文章、产品),用搜索参数控制展示方式(分页、排序、过滤、主题)。