搜索参数(Search Params)校验
本教程共 38 篇 · 第 19 篇 · 更新于 2026-07-27 · 约 13 分钟阅读
19. 搜索参数(Search Params)校验
本节目标:搞懂 TanStack Router 怎么把 URL 查询字符串变成类型安全的应用状态。学会
validateSearch校验、Zod/Valibot 适配器、retainSearchParams/stripSearchParams中间件、自定义序列化。学完你能把分页、过滤、排序这些状态安全地放进 URL,刷新不丢、分享可用。
19.1 搜索参数:URL 里的全局状态
URL 里 ?page=3&sort=desc 这串东西,传统上叫查询字符串(query string),TanStack Router 里叫搜索参数(Search Params)。
传统做法把搜索参数当字符串处理,URLSearchParams 拿出来全是字符串,数字要 Number() 转,布尔要判断 'true' === 'true',嵌套对象没法表达。TanStack Router 把搜索参数当成应用状态来管,给它类型、给它校验、给它生命周期。
把状态放进 URL 有几个好处:
- 可分享:复制链接发给别人,对方看到的状态和你一样。
- 可收藏:刷新页面、前进后退,状态不丢。
- 可新标签打开:cmd/ctrl + 点击链接,新标签里状态完整。
NoteTanStack Router 把搜索参数称为”OG 状态管理器”(最古老的状态管理器)。URL 本来就是全局状态容器,只是以前没人好好用它。
19.2 JSON-first:搜索参数默认就是 JSON
TanStack Router 默认用 JSON 序列化搜索参数。第一层是扁平的键值对(兼容 URLSearchParams),值可以是数字、布尔、字符串,嵌套结构自动转成 JSON 字符串。
<Link
to="/shop"
search={{
pageIndex: 3,
includeCategories: ['electronics', 'gifts'],
sortBy: 'price',
desc: true,
}}
/>
生成的 URL:
/shop?pageIndex=3&includeCategories=%5B%22electronics%22%2C%22gifts%22%5D&sortBy=price&desc=true
解析回来还是原来的对象:
{
"pageIndex": 3,
"includeCategories": ["electronics", "gifts"],
"sortBy": "price",
"desc": true
}
第一层是扁平的(其他工具也能读写),数字 3 和布尔 true 自动保留类型,数组转成 URL 安全的 JSON 字符串。这是默认行为,不用配置。
19.3 validateSearch:校验和类型化
JSON 解析出来的搜索参数,本质还是”用户输入的原始文本”。和表单输入一样,用之前要校验。validateSearch 选项就是干这个的。
19.3.1 手写校验函数
type ProductSearchSortOptions = 'newest' | 'oldest' | 'price'
type ProductSearch = {
page: number
filter: string
sort: ProductSearchSortOptions
}
export const Route = createFileRoute('/shop/products')({
validateSearch: (search: Record<string, unknown>): ProductSearch => {
return {
page: Number(search?.page ?? 1), // 没传默认 1
filter: (search.filter as string) || '', // 没传默认空串
sort: (search.sort as ProductSearchSortOptions) || 'newest', // 默认 newest
}
},
})
validateSearch 接收 JSON 解析后的 Record<string, unknown>,返回你定义的强类型对象。校验时给合理默认值,用户传了非法值也别崩,体验更稳。
校验后的搜索参数,路由的 beforeLoad、loader、component 都能用,子路由也能继承。
19.3.2 用 Zod 校验
手写校验啰嗦还容易漏。用 Schema 校验库(Zod、Valibot、Arktype)更省心。以 Zod 为例:
import { z } from 'zod'
const productSearchSchema = z.object({
page: z.number().catch(1), // 校验失败用 1
filter: z.string().catch(''),
sort: z.enum(['newest', 'oldest', 'price']).catch('newest'),
})
type ProductSearch = z.infer<typeof productSearchSchema>
export const Route = createFileRoute('/shop/products')({
validateSearch: (search) => productSearchSchema.parse(search),
})
z.object({...}) 定义 Schema,.catch(默认值) 表示校验失败时用默认值(不抛错)。productSearchSchema.parse(search) 校验并返回强类型对象。
validateSearch 还能直接接收 Schema 对象(内部会调 parse):
validateSearch: productSearchSchema
Tip用
.catch()而不是.default()。.catch()是校验失败时的兜底,用户传错值不报错;.default()是输入undefined时的默认值,校验失败会抛错。搜索参数是用户输入,失败兜底比抛错体验好。
19.4 适配器:解决 input/output 类型不一致
用 Zod v3 时,Schema 带 .default() 会有个问题:导航时 search 变成必填了。
const productSearchSchema = z.object({
page: z.number().default(1),
filter: z.string().default(''),
sort: z.enum(['newest', 'oldest', 'price']).default('newest'),
})
export const Route = createFileRoute('/shop/products/')({
validateSearch: productSearchSchema,
})
// 下面这行会类型报错:search 是必填!
<Link to="/shop/products" />
原因是 Zod v3 的 .default() 让 output 类型有值,但 input 类型是可选的,TanStack Router 不知道按哪个类型推导。
19.4.1 Zod 适配器
用 @tanstack/zod-adapter 解决:
npm install @tanstack/zod-adapter
import { zodValidator } from '@tanstack/zod-adapter'
import { z } from 'zod'
const productSearchSchema = z.object({
page: z.number().default(1),
filter: z.string().default(''),
sort: z.enum(['newest', 'oldest', 'price']).default('newest'),
})
export const Route = createFileRoute('/shop/products/')({
validateSearch: zodValidator(productSearchSchema),
})
zodValidator 适配器正确推导 input 和 output 类型,<Link to="/shop/products" /> 不传 search 也行。
Zod v3 用 .catch() 还会让类型变 unknown,适配器配套提供 fallback 函数保留类型:
import { fallback, zodValidator } from '@tanstack/zod-adapter'
import { z } from 'zod'
const productSearchSchema = z.object({
page: fallback(z.number(), 1).default(1),
filter: fallback(z.string(), '').default(''),
sort: fallback(z.enum(['newest', 'oldest', 'price']), 'newest').default('newest'),
})
export const Route = createFileRoute('/shop/products/')({
validateSearch: zodValidator(productSearchSchema),
})
19.4.2 Zod v4
Zod v4 原生支持 Standard Schema,不用适配器,直接传 Schema:
import { z } from 'zod'
const productSearchSchema = z.object({
page: z.number().default(1),
filter: z.string().default(''),
sort: z.enum(['newest', 'oldest', 'price']).default('newest'),
})
export const Route = createFileRoute('/shop/products/')({
validateSearch: productSearchSchema, // 直接传,不用适配器
})
19.4.3 Valibot / Arktype / Effect
这些库实现了 Standard Schema 规范,也不用适配器,直接传 Schema:
// Valibot
import * as v from 'valibot'
const productSearchSchema = v.object({
page: v.optional(v.fallback(v.number(), 1), 1),
filter: v.optional(v.fallback(v.string(), ''), ''),
sort: v.optional(v.fallback(v.picklist(['newest', 'oldest', 'price']), 'newest'), 'newest'),
})
export const Route = createFileRoute('/shop/products/')({
validateSearch: productSearchSchema,
})
// Arktype
import { type } from 'arktype'
const productSearchSchema = type({
page: 'number = 1',
filter: 'string = ""',
sort: '"newest" | "oldest" | "price" = "newest"',
})
export const Route = createFileRoute('/shop/products/')({
validateSearch: productSearchSchema,
})
NoteRouter 要求 Valibot 1.0+、Arktype 2.0-rc+。这些库都实现了 Standard Schema 规范,所以能直接用,不用适配器。Zod v3 是特例,需要
@tanstack/zod-adapter。
19.5 读取搜索参数
校验后的搜索参数,在路由各处都能读。
19.5.1 组件里用 useSearch
export const Route = createFileRoute('/shop/products')({
validateSearch: productSearchSchema,
})
function ProductList() {
const { page, filter, sort } = Route.useSearch()
return (
<div>
第 {page} 页,过滤:{filter},排序:{sort}
</div>
)
}
Route.useSearch() 是路由实例上的钩子,返回校验后的强类型搜索参数。
19.5.2 路由外用 useSearch + from
组件被代码分割、或者不在路由文件里时,用全局 useSearch 配 from:
import { useSearch } from '@tanstack/react-router'
function ProductListSidebar() {
const { page, filter, sort } = useSearch({
from: '/shop/products',
})
// ...
}
from 指定从哪个路由读搜索参数,类型安全。还能用 getRouteApi 帮手:
import { getRouteApi } from '@tanstack/react-router'
const routeApi = getRouteApi('/shop/products')
function ProductList() {
const { page, filter, sort } = routeApi.useSearch()
// ...
}
19.5.3 宽松模式
不确定在哪个路由时,用 strict: false 拿宽松类型:
const search = useSearch({ strict: false })
// {
// page: number | undefined
// filter: string | undefined
// sort: 'newest' | 'oldest' | 'price' | undefined
// }
所有字段变可选,类型变宽。适合跨路由复用的通用组件。
19.5.4 在 loader 里用
loader 里读搜索参数要用 loaderDeps(声明依赖),第 20 章会专门讲。这里先看个大概:
export const Route = createFileRoute('/shop/products')({
validateSearch: productSearchSchema,
loaderDeps: ({ page, filter, sort }) => ({ page, filter, sort }),
loader: async ({ deps }) => {
return fetchProducts(deps.page, deps.filter, deps.sort)
},
})
19.5.5 子路由继承父搜索参数
父路由的搜索参数,子路由能直接用:
export const Route = createFileRoute('/shop/products')({
validateSearch: productSearchSchema,
})
export const Route = createFileRoute('/shop/products/$productId')({
beforeLoad: ({ search }) => {
// search 这里是 ProductSearch 类型,从父路由继承
console.log(search.page, search.sort)
},
})
父路由校验的搜索参数会向下传递,子路由自动有类型。这是 TanStack Router 搜索参数”继承”机制。
19.6 修改搜索参数
修改搜索参数的方式和导航一样,用 search 选项。
19.6.1 <Link search />
<Link from={Route.fullPath} search={(prev) => ({ page: prev.page + 1 })}>
下一页
</Link>
from 指定当前路由,to 省略(表示留在当前路由),search 用函数形式拿到 prev,返回新值。这里只改 page,但其他参数会被清掉(因为没展开 prev)。要保留其他参数得 ...prev:
<Link
from={Route.fullPath}
search={(prev) => ({ ...prev, page: prev.page + 1 })}
>
下一页
</Link>
19.6.2 通用组件里用 to="."
通用组件不知道当前在哪个路由,用 to="."(当前路由)+ 函数 search:
function PageSelector() {
return (
<Link to="." search={(prev) => ({ ...prev, page: prev.page + 1 })}>
下一页
</Link>
)
}
to="." 配函数 search,类型是宽松的(所有字段可选)。适合在多个路由间复用。
19.6.3 useNavigate 和 router.navigate
const navigate = useNavigate({ from: Route.fullPath })
navigate({
search: (prev) => ({ page: prev.page + 1 }),
})
router.navigate({ search: ... }) 用法一样。命令式修改搜索参数用这俩。
19.7 搜索参数中间件
<Link> 生成 href 时,默认只看 search 属性。但有时候你想在所有链接里自动加上某些参数(比如保持 rootValue),或者自动去掉默认值参数(让 URL 更干净)。搜索参数中间件就是干这个的。
19.7.1 retainSearchParams:保留参数
让某个参数在所有链接里都保留(除非显式覆盖):
import { createRootRoute, retainSearchParams } from '@tanstack/react-router'
import { zodValidator } from '@tanstack/zod-adapter'
import { z } from 'zod'
const searchSchema = z.object({
rootValue: z.string().optional(),
})
export const Route = createRootRoute({
validateSearch: zodValidator(searchSchema),
search: {
middlewares: [retainSearchParams(['rootValue'])],
},
})
retainSearchParams(['rootValue']) 表示:当前 URL 里有 rootValue,生成的所有链接都带上它,除非链接显式指定了新的 rootValue。适合”全局上下文参数”,比如当前选中的组织 ID。
19.7.2 stripSearchParams:清理默认值
让默认值参数不出现在 URL 里,URL 更干净:
import { createFileRoute, stripSearchParams } from '@tanstack/react-router'
const defaultValues = {
one: 'abc',
two: 'xyz',
}
const searchSchema = z.object({
one: z.string().default(defaultValues.one),
two: z.string().default(defaultValues.two),
})
export const Route = createFileRoute('/hello')({
validateSearch: zodValidator(searchSchema),
search: {
middlewares: [stripSearchParams(defaultValues)],
},
})
stripSearchParams(defaultValues) 表示:参数值等于默认值时,从 URL 里去掉。one=abc&two=xyz 这种默认值组合不会出现在 URL 里,只有非默认值才显示。
19.7.3 组合多个中间件
中间件能链式组合:
export const Route = createFileRoute('/search')({
validateSearch: zodValidator(
z.object({
retainMe: z.string().optional(),
arrayWithDefaults: z.string().array().default(['foo', 'bar']),
required: z.string(),
}),
),
search: {
middlewares: [
retainSearchParams(['retainMe']), // 保留 retainMe
stripSearchParams({ arrayWithDefaults: ['foo', 'bar'] }), // 清理默认值数组
],
},
})
先保留 retainMe,再清理 arrayWithDefaults 的默认值。顺序执行。
19.7.4 自定义中间件
中间件本质是函数,接收 { search, next },返回变换后的搜索参数:
search: {
middlewares: [
({ search, next }) => {
const result = next(search) // 先让后续中间件处理
return {
rootValue: search.rootValue, // 强制保留 rootValue
...result,
}
},
],
},
next(search) 调用后续中间件链,拿到结果后再加自己的逻辑。retainSearchParams 就是这么实现的。
19.8 自定义序列化
默认 JSON 序列化够用,但有些场景要换。比如要 base64 编码(兼容老浏览器、URL 展开器),或用 query-string 库(更人性化的查询字符串)。
在 createRouter 时配 parseSearch 和 stringifySearch:
import {
createRouter,
parseSearchWith,
stringifySearchWith,
} from '@tanstack/react-router'
const router = createRouter({
// ...
parseSearch: parseSearchWith(JSON.parse),
stringifySearch: stringifySearchWith(JSON.stringify),
})
parseSearchWith 和 stringifySearchWith 是帮手函数,包装你的自定义解析/序列化函数。
19.8.1 Base64 编码
const router = createRouter({
parseSearch: parseSearchWith((value) => JSON.parse(decodeFromBinary(value))),
stringifySearch: stringifySearchWith((value) =>
encodeToBinary(JSON.stringify(value)),
),
})
// 安全的二进制编解码(处理非 UTF-8 字符)
function decodeFromBinary(str: string): string {
return decodeURIComponent(
Array.prototype.map
.call(atob(str), (c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join(''),
)
}
function encodeToBinary(str: string): string {
return btoa(
encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_, p1) =>
String.fromCharCode(parseInt(p1, 16)),
),
)
}
编码后 URL 变成 ?page=1&sort=asc&filters=eyJhdXRob3IiOiJ0YW5uZXIiLCJtaW5fd29yZHMiOjgwMH0%3D,嵌套结构看不出来。
Warning别直接用
atob/btoa处理含中文等非 UTF-8 字符的字符串,会乱码。用上面的encodeToBinary/decodeFromBinary安全编解码。
19.8.2 query-string 库
query-string 是流行的查询字符串库,支持更人性化的格式:
import qs from 'query-string'
const router = createRouter({
stringifySearch: stringifySearchWith((value) =>
qs.stringify(value, { /* options */ }),
),
parseSearch: parseSearchWith((value) =>
qs.parse(value, { /* options */ }),
),
})
序列化后变成 ?page=1&sort=asc&filters=author%3Dtanner%26min_words%3D800,嵌套对象被压扁成 key=value&key=value 形式。
19.8.3 JSURL2 / Zipson
JSURL2 压缩 URL 但保持可读,Zipson 是高性能 JSON 压缩。用法类似,import 后传给 parseSearchWith/stringifySearchWith:
// JSURL2
import { parse, stringify } from 'jsurl2'
const router = createRouter({
parseSearch: parseSearchWith(parse),
stringifySearch: stringifySearchWith(stringify),
})
Tip选序列化方案的标准:序列化后再反序列化,要能拿到一模一样的对象(幂等)。不然会丢数据。默认 JSON 满足这个要求,换库时也要验证。
19.9 常见坑
坑一:搜索参数没校验就用。 JSON 解析出来是 unknown,直接用类型不安全。每个用搜索参数的路由都配 validateSearch。
坑二:.default() 和 .catch() 搞混。 .default() 是输入 undefined 时的默认值,校验失败抛错;.catch() 是校验失败的兜底,不抛错。搜索参数用 .catch() 体验更好。
坑三:Zod v3 不用适配器导致 search 变必填。 带 .default() 的 Schema 直接传给 validateSearch,<Link> 的 search 会变必填。用 @tanstack/zod-adapter 的 zodValidator 解决。
坑四:修改搜索参数漏了 ...prev。 search: (prev) => ({ page: 2 }) 会把其他参数清掉。要保留其他参数得 search: (prev) => ({ ...prev, page: 2 })。
坑五:retainSearchParams 和 stripSearchParams 顺序错。 中间件按顺序执行,先保留再清理和先清理再保留结果可能不同。根据需求排好顺序。
坑六:base64 编码用了 atob/btoa 处理中文。 直接用会乱码。用 encodeToBinary/decodeFromBinary 安全编解码。
坑七:在 loader 里直接读 search。 loader 里读搜索参数要用 loaderDeps 声明依赖,不能直接读。第 20 章会讲。
19.10 小结
搜索参数校验的东西不少,捋一下:
- JSON-first:搜索参数默认按 JSON 序列化,第一层扁平兼容
URLSearchParams,嵌套结构自动转 JSON。 validateSearch:校验搜索参数,返回强类型对象,手写或用 Schema 库。- 适配器:Zod v3 用
@tanstack/zod-adapter,Zod v4 / Valibot / Arktype / Effect 实现 Standard Schema 直接用。 - 读取:组件里
Route.useSearch(),路由外useSearch({ from })或getRouteApi,loader 里loaderDeps。 - 修改:
<Link search>、useNavigate({ search })、router.navigate({ search }),函数形式拿prev。 - 中间件:
retainSearchParams保留参数、stripSearchParams清理默认值、自定义中间件灵活变换。 - 自定义序列化:
parseSearch/stringifySearch配parseSearchWith/stringifySearchWith,支持 base64、query-string、JSURL2、Zipson。
TanStack Router 把搜索参数从”字符串黑盒”变成”类型安全的应用状态”,这是它对比其他路由库最大的优势之一。下一章讲 Loader 与数据预取,把搜索参数和路由数据加载结合起来。