TanStack Router 基础搜索参数(Search Params)配置指南:Schema 校验与类型安全实战
【免费下载链接】router🤖 A client-first, server-capable, fully type-safe router and full-stack framework for the web (React and more).项目地址: https://gitcode.com/GitHub_Trending/ro/router
本指南以 TanStack Router(当前仓库 packages/react-router 与 packages/router-core 的 1.x 实现)为背景,系统讲解如何在路由中接入类型安全、可直接用于生产的搜索参数(URL query string)。你将掌握validateSearch的接入方式、Zod 等校验库的适配原理、Route.useSearch()与useSearch的读取姿势、常见业务模式(分页、枚举、数组、日期)以及常见坑位的排错方法,最终能在真实项目中落地一套"零运行时崩溃、全链路类型推导"的搜索参数方案。
快速开始:一行 Schema,让 URL 参数类型安全
搜索参数(Search Params)指 URL 中?之后的 query string。它们天然是"字符串、可能缺失、可能非法"的,因此在 TanStack Router 中推荐的(也是生产环境唯一推荐的)做法是:用 Schema 校验库对它们做运行时验证,同时获得 TypeScript 类型推导。
import { createFileRoute } from '@tanstack/react-router' import { z } from 'zod' const productSearchSchema = z.object({ page: z.number().default(1).catch(1), category: z.string().default('all').catch('all'), showSale: z.boolean().default(false).catch(false), }) export const Route = createFileRoute('/products')({ validateSearch: productSearchSchema, component: ProductsPage, }) function ProductsPage() { const { page, category, showSale } = Route.useSearch() return ( <div> <h1>Products</h1> <p>Page: {page}</p> <p>Category: {category}</p> <p>Show Sale Items: {showSale ? 'Yes' : 'No'}</p> </div> ) }这段代码做了三件事:
- 用
z.object声明了路由/products接受的三个搜索参数及其类型; - 通过路由选项
validateSearch把 Schema 挂到路由上; - 组件里用
Route.useSearch()拿到已经校验过、带完整类型的搜索对象。
当 URL 是/products?page=2&showSale=true时,组件里读到{ page: 2, category: 'all', showSale: true };当 URL 缺少参数或参数非法(如page=abc)时,.default()与.catch()会兜底为声明值,应用不会崩溃。
为什么必须用 Schema 校验搜索参数
搜索参数与路由路径参数的最大区别在于:路径参数通常由你控制,而 query string 可以被任何用户或外部链接随意构造。生产环境使用 Schema 校验带来五个直接收益:
- 类型安全:Schema 的类型可被自动推导进
Route.useSearch()、useNavigate、Link等所有 API,导航时传错字段会直接报 TS 错误; - 运行时校验:非法 URL 参数会被优雅地规整为默认值,而不是把
NaN、"undefined"之类的脏数据带进组件; - 默认值:缺失的参数自动补齐,组件内无需再写一层"或默认值"逻辑;
- 错误处理:校验失败有内置的错误管理(见下文"底层原理"中的
SearchParamError); - 可维护性:参数契约集中在 Schema 一处声明,改类型只需改一处。
校验库的接入:Zod v4、Zod v3 与任意标准校验库
TanStack Router 的validateSearch接受任何标准 Schema 兼容的校验器——只要它实现了parse方法或标准~standard接口。仓库中对此有明确的分支处理,见 router.ts:
function validateSearch(validateSearch: AnyValidator, input: unknown): unknown { if (validateSearch == null) return {} if ('~standard' in validateSearch) { const result = validateSearch['~standard'].validate(input) if (result instanceof Promise) throw new SearchParamError('Async validation not supported') if (result.issues) throw new SearchParamError(JSON.stringify(result.issues, undefined, 2), { cause: result, }) return result.value } if ('parse' in validateSearch) { return validateSearch.parse(input) } if (typeof validateSearch === 'function') { return validateSearch(input) } return {} }即校验器按三种形态被识别:实现了标准~standard接口的库(走标准 validate 协议)、实现了parse方法的库(如 Zod 的 schema 对象)、以及直接传入的普通函数(此时函数本身就是校验器)。注意:异步校验不被支持,会抛出SearchParamError。
使用 Zod v4(推荐)
Zod v4 的.default()自带宽松处理,直接传入 schema 即可,无需额外适配器:
import { z } from 'zod' const searchSchema = z.object({ page: z.number().default(1), category: z.string().default('all').catch('all'), }) export const Route = createFileRoute('/products')({ validateSearch: searchSchema, component: ProductsPage, })使用 Zod v3(需要官方适配器)
Zod v3 在严格模式下会保留未知字段,因此官方提供了@tanstack/zod-adapter:
npm install zod @tanstack/zod-adapterimport { zodValidator, fallback } from '@tanstack/zod-adapter' import { z } from 'zod' const searchSchema = z.object({ page: fallback(z.number(), 1).default(1), category: fallback(z.string(), 'all').default('all'), }) export const Route = createFileRoute('/products')({ validateSearch: zodValidator(searchSchema), component: ProductsPage, })zodValidator的适配器实现位于 packages/zod-adapter/src/index.ts:它把 schema 包装成一个带types与parse的ValidatorAdapter,types.input/types.output分别对应 Zod 的_input/_output,parse直接调用schema.parse(input)。而fallback的本质是z.custom<TInput>().pipe(schema.catch(fallback))——先用z.custom接受任意输入,再经.catch()保证非法输入被兜底。
Valibot 与 ArkType
仓库同样提供了官方适配器包,实现方式与 Zod 适配器同构:
- packages/valibot-adapter/src/index.ts:
valibotValidator(schema),内部parse: (input) => parse(options, input),类型由InferInput/InferOutput推导; - packages/arktype-adapter/src/index.ts:
arkTypeValidator(schema),内部parse: (input) => options.assert(input),类型取自inferIn/infer。
因此本指南的所有 Zod 示例,都可以机械地替换为 Valibot 或 ArkType 的等价写法。更完整的校验库对比与进阶模式见 Validate Search Parameters with Schemas。
分步实战:搭建一个带 Schema 校验的店铺页
以下用 Zod v4 逐步搭建一个/shop路由(模式对任意校验库通用)。
Step 1:定义搜索参数 Schema
先梳理路由需要哪些参数:分页、过滤、排序、可选搜索词。
import { z } from 'zod' const shopSearchSchema = z.object({ // Pagination page: z.number().default(1), limit: z.number().default(20), // Filtering category: z.string().default('all'), minPrice: z.number().default(0), maxPrice: z.number().default(1000), // Settings sort: z.enum(['name', 'price', 'date']).default('name'), ascending: z.boolean().default(true), // Optional parameters searchTerm: z.string().optional(), showOnlyInStock: z.boolean().default(false), }) type ShopSearch = z.infer<typeof shopSearchSchema>Step 2:把 Schema 挂到路由
export const Route = createFileRoute('/shop')({ validateSearch: shopSearchSchema, component: ShopPage, })Step 3:在组件中读取
function ShopPage() { const searchParams = Route.useSearch() // 所有属性均经过校验且类型完整 const { page, limit, category, sort, ascending, searchTerm, showOnlyInStock, } = searchParams return ( <div> <h1>Shop - Page {page}</h1> <div>Category: {category}</div> <div> Sort: {sort} ({ascending ? 'ascending' : 'descending'}) </div> <div>Items per page: {limit}</div> <div>In stock only: {showOnlyInStock ? 'Yes' : 'No'}</div> {searchTerm && <div>Search: "{searchTerm}"</div>} </div> ) }常见搜索参数模式
带约束的分页
page最小为 1,limit限制在 10~100 之间,天然防住恶意参数:
const paginationSchema = z.object({ page: z.number().min(1).default(1), limit: z.number().min(10).max(100).default(20), }) export const Route = createFileRoute('/posts')({ validateSearch: paginationSchema, component: PostsPage, }) function PostsPage() { const { page, limit } = Route.useSearch() // 计算 API 调用的 offset const offset = (page - 1) * limit return ( <div> <h1>Posts (Page {page})</h1> <p>Showing {limit} posts per page</p> <p>Offset: {offset}</p> {/* Render posts... */} </div> ) }枚举值校验与默认值
排序与分类用z.enum限定合法取值,非法值会回退到默认:
const catalogSchema = z.object({ sort: z.enum(['name', 'date', 'price']).default('name'), category: z.enum(['electronics', 'clothing', 'books', 'all']).default('all'), ascending: z.boolean().default(true), }) export const Route = createFileRoute('/catalog')({ validateSearch: catalogSchema, component: CatalogPage, })复杂数据类型
数组、嵌套对象等同样可以进入 query string(TanStack Router 会负责序列化与反序列化):
const dashboardSchema = z.object({ // Numbers with validation userId: z.number().positive().default(1), refreshInterval: z.number().min(1000).max(60000).default(5000), // Strings with validation theme: z.enum(['light', 'dark']).default('light'), timezone: z.string().optional(), // Arrays with validation selectedIds: z.number().array().default([]), tags: z.string().array().default([]), // Objects with validation filters: z .object({ status: z.enum(['active', 'inactive']).optional(), type: z.string().optional(), }) .prefault({}), })注意嵌套对象使用了.prefault({}):当整个filters缺失时给出空对象默认值,避免组件里出现undefined.filters之类的问题。
日期与进阶类型
URL 里没有"日期"类型,需要先用字符串承载、再经z.coerce.date()转换:
const reportSchema = z.object({ startDate: z.string().pipe(z.coerce.date()).optional(), endDate: z.string().pipe(z.coerce.date()).optional(), format: z.enum(['pdf', 'csv', 'excel']).default('pdf').catch('pdf'), includeCharts: z.boolean().default(true), })在组件外读取搜索参数
当搜索参数需要在路由组件之外(如 code-split 出来的子组件、独立工具函数文件)读取时,有两条路径。
使用 getRouteApi
对路由做一次getRouteApi('/products')绑定,即可在任意文件中获得与Route.useSearch()完全相同的类型体验:
// components/ProductFilters.tsx import { getRouteApi } from '@tanstack/react-router' const routeApi = getRouteApi('/products') export function ProductFilters() { const { category, sort, showSale } = routeApi.useSearch() return ( <div> <select value={category}> <option value="all">All Categories</option> <option value="electronics">Electronics</option> <option value="clothing">Clothing</option> </select> {/* More filters... */} </div> ) }使用带 from 的 useSearch
全局useSearch配合from指定来源路由,适合在通用组件中读取任意路由的搜索状态:
import { useSearch } from '@tanstack/react-router' function GenericSearchDisplay() { const search = useSearch({ from: '/products' }) return <div>Current filters: {JSON.stringify(search, null, 2)}</div> }useSearch的实现位于 packages/react-router/src/useSearch.tsx:它本质上是useMatch的封装——把from、strict、shouldThrow、structuralSharing透传下去,并通过select回调从 match 中取出search。它还支持select与structuralSharing选项,用于派生值与渲染优化。
手动校验:理解底层原语(教学向)
不用校验库、直接写一个校验函数是可行的——这能帮你理解搜索参数的本质。生产环境请用 Schema 校验。
// Educational example - use schema validation for production export const Route = createFileRoute('/example')({ validateSearch: (search: Record<string, unknown>) => ({ // Numbers need coercion from URL strings page: Number(search.page) || 1, // Strings can be cast with defaults category: (search.category as string) || 'all', // Booleans: TanStack Router auto-converts "true"/"false" to booleans showSale: Boolean(search.showSale), // Arrays need JSON parsing validation selectedIds: Array.isArray(search.selectedIds) ? search.selectedIds.map(Number).filter(Boolean) : [], }), component: ExamplePage, })从这个示例能看出 TanStack Router 的处理链路:URL 字符串先经默认解析器变成"宽松对象",然后进入validateSearch。当校验器是函数时,它直接收到这个宽松对象并返回规整后的对象,正如 router.ts 中的validateSearch(input)分支所示。
底层原理:validateSearch 在路由匹配链路中的位置
从源码看,validateSearch并非孤立执行,而是作为搜索中间件参与路由匹配,见 router.ts:
const routeValidateSearch = routeOptions.validateSearch if (includeValidateSearch && routeValidateSearch) { const validate: SearchMiddleware<any> = ({ search, next, meta }) => { const result = next(search) try { const validated = validateSearch(routeValidateSearch, result) as any if (meta && validated) { for (const key in validated) { if (!(key in result)) { ;(meta.defaulted ||= new Map()).set(key, validated[key]) } } } return { ...result, ...validated } } catch { // ignore errors here because they are already handled in matchRoutes } return result } middlewares.push(validate) }这段代码揭示了几点实现事实:
- 合并而非替换:校验结果通过
{ ...result, ...validated }与上游搜索合并,父路由的搜索参数会继续向下传递; - 默认值追踪:凡是由校验器补出的、原本不存在的字段,会被记录进
meta.defaulted,这是内部判断"参数是否被默认"的依据; - 错误在 matchRoutes 阶段处理:这里 catch 掉异常,真正的
SearchParamError处理发生在路由匹配环节; - 严格模式可配:Router 选项
search.strict控制"未知搜索参数(任何validateSearch未返回的字段)"如何处理,默认false(保留未知参数),见 router.ts 的类型注释。
生产环境检查清单
- 使用 Schema 校验:用校验库获得类型安全与运行时校验;
- 添加 fallback 值:
.catch()等机制保证非法输入不崩溃; - 设置默认值:可选但常缺的参数用
.default()补齐; - 校验约束:用校验库内置的
min/max/enum/positive等约束; - 妥善处理可选参数:区分"有默认值"与"真正可选(
.optional())"; - 类型推导自动化:正确配置 Schema 后,导航与读取 API 的类型自动联动;
- 错误边界:配置错误边界以兜住极端情况下的校验失败。
常见问题排查
问题:搜索参数导致 TypeScript 报错
原因:缺少 Schema 定义或类型不正确。
解决:确保 Schema 覆盖全部搜索参数,并使用正确类型:
// ❌ 缺少 Schema 或类型不正确 export const Route = createFileRoute('/page')({ component: MyPage, }) // ✅ 完整的 Schema 与正确校验 const searchSchema = z.object({ page: z.number().default(1).catch(1), category: z.string().default('all').catch('all'), }) export const Route = createFileRoute('/page')({ validateSearch: searchSchema, component: MyPage, })问题:非法 URL 参数导致应用崩溃
原因:没有 fallback 处理错误场景。
解决:用 fallback 提供安全默认值:
// ❌ 无 fallback:非法输入直接抛错 const schema = z.object({ page: z.number().default(1), // Will throw on invalid input }) // ✅ 优雅回退 const schema = z.object({ page: z.number().default(1).catch(1), // Safe fallback to 1 })问题:可选参数被 TypeScript 视为必填
原因:.default()会让参数在导航时"可省略,但在组件中恒存在"。
解决:真正可选的参数用.optional():
const schema = z.object({ // 有默认值:导航可省略,但组件中恒存在 page: z.number().default(1).catch(1), // 真正可选:组件中可能为 undefined searchTerm: z.string().optional(), })问题:复杂对象校验不生效
原因:嵌套对象需要显式声明完整 Schema。
解决:定义完整的嵌套 Schema:
const schema = z.object({ filters: z .object({ status: z.enum(['active', 'inactive']).optional(), tags: z.string().array().optional(), dateRange: z .object({ start: z.string().pipe(z.coerce.date()), end: z.string().pipe(z.coerce.date()), }) .optional(), }) .prefault({}) .catch({}), })下一步
搭建好基础搜索参数后,可以继续深入:
- Validate Search Parameters with Schemas:用 Zod、Valibot 或 ArkType 做更健壮的校验;
- Navigate with Search Parameters:用 Link 与导航更新搜索参数;
- Work with Arrays, Objects, and Dates:处理数组、对象、日期与嵌套数据结构;
- Search Parameters Guide:完整的搜索参数文档。
【免费下载链接】router🤖 A client-first, server-capable, fully type-safe router and full-stack framework for the web (React and more).项目地址: https://gitcode.com/GitHub_Trending/ro/router
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考