首页 / TanStack 生态入门教程 / 搜索参数(Search Params)校验

TanStack 生态入门教程

搜索参数(Search Params)校验

本教程共 38 篇 · 第 19 篇 · 更新于 2026-07-27 · 约 13 分钟阅读

TanStackTanStack 生态入门教程TanStack Router搜索参数validateSearchZodURL 状态搜索参数中间件

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 + 点击链接,新标签里状态完整。
Note

TanStack 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>,返回你定义的强类型对象。校验时给合理默认值,用户传了非法值也别崩,体验更稳。

校验后的搜索参数,路由的 beforeLoadloadercomponent 都能用,子路由也能继承

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,
})
Note

Router 要求 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

组件被代码分割、或者不在路由文件里时,用全局 useSearchfrom

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 选项。

<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 时配 parseSearchstringifySearch

import {
  createRouter,
  parseSearchWith,
  stringifySearchWith,
} from '@tanstack/react-router'

const router = createRouter({
  // ...
  parseSearch: parseSearchWith(JSON.parse),
  stringifySearch: stringifySearchWith(JSON.stringify),
})

parseSearchWithstringifySearchWith 是帮手函数,包装你的自定义解析/序列化函数。

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-adapterzodValidator 解决。

坑四:修改搜索参数漏了 ...prev search: (prev) => ({ page: 2 }) 会把其他参数清掉。要保留其他参数得 search: (prev) => ({ ...prev, page: 2 })

坑五:retainSearchParamsstripSearchParams 顺序错。 中间件按顺序执行,先保留再清理和先清理再保留结果可能不同。根据需求排好顺序。

坑六: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/stringifySearchparseSearchWith/stringifySearchWith,支持 base64、query-string、JSURL2、Zipson。

TanStack Router 把搜索参数从”字符串黑盒”变成”类型安全的应用状态”,这是它对比其他路由库最大的优势之一。下一章讲 Loader 与数据预取,把搜索参数和路由数据加载结合起来。